diff --git a/.github/workflows/aps-real-gam.yml b/.github/workflows/aps-real-gam.yml new file mode 100644 index 000000000..1b7d2a044 --- /dev/null +++ b/.github/workflows/aps-real-gam.yml @@ -0,0 +1,134 @@ +name: "APS real-GAM attestation" +run-name: >- + APS real-GAM / ${{ inputs.evidence_id }} / ${{ inputs.release_id }} + +permissions: + contents: read + +on: + workflow_call: + inputs: + release_id: + description: Exact TSJS release id deployed to the protected test network + required: true + type: string + evidence_id: + description: Unique cutover evidence identifier + required: true + type: string + previous_artifact_id: + description: Immutable artifact identifier used for rollback + required: true + type: string + workflow_dispatch: + inputs: + release_id: + description: Exact TSJS release id deployed to the protected test network + required: true + type: string + evidence_id: + description: Unique cutover evidence identifier + required: true + type: string + previous_artifact_id: + description: Immutable artifact identifier used for rollback + required: true + type: string + +jobs: + attest: + name: Chromium, Firefox, and WebKit attestation + runs-on: ubuntu-latest + timeout-minutes: 90 + environment: aps-real-gam + env: + TS_REAL_GAM_PAGE_URL: ${{ secrets.TS_REAL_GAM_PAGE_URL }} + TS_REAL_GAM_AUTH_HEADER: ${{ secrets.TS_REAL_GAM_AUTH_HEADER }} + TS_REAL_GAM_EXPECTED_RELEASE_ID: ${{ vars.TS_REAL_GAM_EXPECTED_RELEASE_ID }} + steps: + - uses: actions/checkout@v4 + + - name: Validate protected inputs and release binding + env: + DISPATCH_EVIDENCE_ID: ${{ inputs.evidence_id }} + DISPATCH_PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }} + DISPATCH_RELEASE_ID: ${{ inputs.release_id }} + run: | + test -n "$TS_REAL_GAM_PAGE_URL" + test -n "$TS_REAL_GAM_AUTH_HEADER" + test -n "$TS_REAL_GAM_EXPECTED_RELEASE_ID" + test -n "$DISPATCH_RELEASE_ID" + test "$DISPATCH_RELEASE_ID" = "$TS_REAL_GAM_EXPECTED_RELEASE_ID" + test -n "$DISPATCH_EVIDENCE_ID" + test -n "$DISPATCH_PREVIOUS_ARTIFACT_ID" + + - name: Read Node.js version + id: node-version + run: echo "version=$(awk '$1 == \"nodejs\" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.version }} + cache: npm + cache-dependency-path: crates/trusted-server-integration-tests/browser/package-lock.json + + - name: Install isolated browser-test dependencies + working-directory: crates/trusted-server-integration-tests/browser + run: npm ci + + - name: Install all required browsers + working-directory: crates/trusted-server-integration-tests/browser + run: npx playwright install --with-deps chromium firefox webkit + + - name: Run protected real-GAM contract + id: real-gam + working-directory: crates/trusted-server-integration-tests/browser + run: >- + npm exec -- playwright test + --config=playwright.real-gam.config.ts + tests/shared/aps-real-gam.spec.ts + --project=chromium --project=firefox --project=webkit + + - name: Write release attestation + if: always() + env: + ATTESTATION_EVIDENCE_ID: ${{ inputs.evidence_id }} + ATTESTATION_RELEASE_ID: ${{ inputs.release_id }} + ATTESTATION_PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }} + ATTESTATION_TEST_OUTCOME: ${{ steps.real-gam.outcome }} + run: >- + node -e 'const fs=require("node:fs"); + const path="crates/trusted-server-integration-tests/browser/real-gam-evidence/evidence-manifest.json"; + fs.mkdirSync(require("node:path").dirname(path),{recursive:true}); + fs.writeFileSync(path,JSON.stringify({schemaVersion:1,evidenceId:process.env.ATTESTATION_EVIDENCE_ID, + releaseId:process.env.ATTESTATION_RELEASE_ID,previousArtifactId:process.env.ATTESTATION_PREVIOUS_ARTIFACT_ID, + commitSha:process.env.GITHUB_SHA,runId:process.env.GITHUB_RUN_ID, + conclusion:process.env.ATTESTATION_TEST_OUTCOME},null,2)+"\n",{mode:384});' + + - name: Scrub browser evidence before upload + if: always() + env: + REAL_GAM_TEST_OUTCOME: ${{ steps.real-gam.outcome }} + working-directory: crates/trusted-server-integration-tests/browser + run: >- + node -e 'const fs=require("node:fs"),path=require("node:path"); + const roots=["real-gam-evidence","playwright-report","test-results"]; + const forbiddenExt=new Set([".har",".zip",".webm"]), secrets=[process.env.TS_REAL_GAM_PAGE_URL,process.env.TS_REAL_GAM_AUTH_HEADER].filter(Boolean); + const files=[]; const walk=p=>{if(!fs.existsSync(p))return; for(const e of fs.readdirSync(p,{withFileTypes:true})){const q=path.join(p,e.name); e.isDirectory()?walk(q):files.push(q)}}; roots.forEach(walk); + for(const file of files){if(forbiddenExt.has(path.extname(file)))throw Error("native capture forbidden: "+path.extname(file)); const body=fs.readFileSync(file); for(const secret of secrets){if(body.includes(Buffer.from(secret)))throw Error("protected value found in browser evidence")}} + const traces=files.filter(file=>file.endsWith("sanitized-trace-v1.json")); + for(const file of traces){const text=fs.readFileSync(file,"utf8"); if(/"(?:accountId|aaxResponse|adm|authorization|capabilities?|creativeBody|descriptor|lifecycleTicket|nonce|postData|requestHeaders|responseBody|responseHeaders)"\s*:/.test(text)||/)|- + Integration Tests / ${{ inputs.evidence_id || format('PR {0}', github.event.pull_request.number) }} permissions: contents: read @@ -9,6 +11,19 @@ on: pull_request: types: [opened, synchronize, reopened] workflow_dispatch: + inputs: + evidence_id: + description: Unique identifier used to bind this run to an evidence artifact + required: true + type: string + release_id: + description: Exact generated TSJS release id + required: true + type: string + previous_artifact_id: + description: Immutable artifact identifier used for rollback + required: true + type: string env: ORIGIN_PORT: 8888 @@ -19,8 +34,31 @@ env: CF_BUILD_ARTIFACT_PATH: /tmp/integration-test-artifacts/cloudflare/build jobs: + tsjs-performance-gate: + name: TSJS first-display performance evidence + if: >- + github.event_name == 'workflow_dispatch' && + (startsWith(inputs.evidence_id, 'aps-tsjs-preswitch-') || + startsWith(inputs.evidence_id, 'aps-tsjs-postswitch-')) + uses: ./.github/workflows/tsjs-performance-gate.yml + with: + evidence_id: ${{ inputs.evidence_id }} + mode: ${{ startsWith(inputs.evidence_id, 'aps-tsjs-postswitch-') && 'postswitch' || 'preswitch' }} + + real-gam-attestation: + name: protected real-GAM attestation + if: >- + github.event_name == 'workflow_dispatch' && + startsWith(inputs.evidence_id, 'aps-tsjs-cutover-') + uses: ./.github/workflows/aps-real-gam.yml + with: + evidence_id: ${{ inputs.evidence_id }} + release_id: ${{ inputs.release_id }} + previous_artifact_id: ${{ inputs.previous_artifact_id }} + prepare-artifacts: name: prepare integration artifacts + if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -57,6 +95,7 @@ jobs: integration-tests: name: integration tests + if: github.event_name == 'pull_request' needs: prepare-artifacts runs-on: ubuntu-latest timeout-minutes: 20 @@ -116,6 +155,7 @@ jobs: integration-tests-fastly-ec: name: integration tests (Fastly EC lifecycle) + if: github.event_name == 'pull_request' needs: prepare-artifacts runs-on: ubuntu-latest timeout-minutes: 15 @@ -152,10 +192,60 @@ jobs: VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml RUST_LOG: info + aps-runner-proxy: + name: APS runner proxy (${{ matrix.runtime }}) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + runtime: [axum, fastly, cloudflare, spin] + steps: + - uses: actions/checkout@v4 + + - name: Set up APS proxy test environment + id: shared-setup + uses: ./.github/actions/setup-integration-test-env + with: + origin-port: ${{ env.ORIGIN_PORT }} + install-viceroy: ${{ matrix.runtime == 'fastly' && 'true' || 'false' }} + build-wasm: "false" + build-axum: "false" + build-test-images: "false" + build-cloudflare: "false" + + - name: Add Cloudflare wasm target + if: matrix.runtime == 'cloudflare' + run: rustup target add wasm32-unknown-unknown + + - name: Set up Node.js for Wrangler + if: matrix.runtime == 'cloudflare' + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.shared-setup.outputs.node-version }} + + - name: Install Wrangler + if: matrix.runtime == 'cloudflare' + run: npm install -g wrangler@4.64.0 + + - name: Install Spin + if: matrix.runtime == 'spin' + uses: fermyon/actions/spin/setup@v1 + with: + version: "v4.0.2" + + - name: Run actual-adapter APS runner-proxy corpus + run: ./scripts/integration-tests-aps-runner-proxy.sh --runtime ${{ matrix.runtime }} + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + RUST_LOG: info + browser-tests: name: browser integration tests + if: github.event_name == 'pull_request' needs: prepare-artifacts - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -244,3 +334,204 @@ jobs: name: playwright-traces path: crates/trusted-server-integration-tests/browser/test-results/ retention-days: 7 + + browser-tests-aps-tsjs-conformance: + name: browser integration tests (APS/TSJS conformance) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Set up APS/TSJS browser test runtime + id: shared-setup + uses: ./.github/actions/setup-integration-test-env + with: + origin-port: ${{ env.ORIGIN_PORT }} + install-viceroy: "true" + build-wasm: "false" + build-axum: "false" + build-test-images: "false" + build-cloudflare: "false" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.shared-setup.outputs.node-version }} + cache: npm + cache-dependency-path: | + crates/trusted-server-integration-tests/browser/package-lock.json + crates/trusted-server-js/lib/package-lock.json + + - name: Run focused APS/TSJS three-browser conformance matrix + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + TS_BROWSER_FRAMEWORKS: nextjs + TS_BROWSER_PROJECTS: chromium,firefox,webkit + run: >- + ./scripts/integration-tests-browser.sh + tests/shared/aps-renderer.spec.ts + tests/shared/aps-puc-lifecycle.spec.ts + tests/shared/tsjs-runtime.spec.ts + tests/shared/creative-sandbox.spec.ts + tests/nextjs/gpt-diagnostics.spec.ts + tests/nextjs/navigation.spec.ts + --project=chromium --project=firefox --project=webkit + + - name: Upload APS/TSJS Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-aps-tsjs-conformance + path: crates/trusted-server-integration-tests/browser/playwright-report/ + retention-days: 7 + + cutover-suite: + name: exact APS/TSJS integration evidence + if: github.event_name == 'workflow_dispatch' && startsWith(inputs.evidence_id, 'aps-tsjs-cutover-') + runs-on: ubuntu-24.04 + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up the complete integration environment + id: shared-setup + uses: ./.github/actions/setup-integration-test-env + with: + origin-port: ${{ env.ORIGIN_PORT }} + install-viceroy: "true" + build-cloudflare: "true" + + - name: Set up pinned Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.shared-setup.outputs.node-version }} + cache: npm + cache-dependency-path: | + crates/trusted-server-js/lib/package-lock.json + crates/trusted-server-integration-tests/browser/package-lock.json + + - name: Install exact integration runtimes + run: npm install -g wrangler@4.64.0 + + - name: Install pinned Spin + uses: fermyon/actions/spin/setup@v1 + with: + version: "v4.0.2" + + - name: Generate integration Viceroy configs + run: ./scripts/generate-integration-viceroy-configs.sh + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + + - name: Build and validate the exact TSJS release + env: + EXPECTED_RELEASE_ID: ${{ inputs.release_id }} + shell: bash + run: | + set -euo pipefail + mkdir -p target/aps-tsjs-cutover-evidence + npm --prefix crates/trusted-server-js/lib ci + npm --prefix crates/trusted-server-js/lib run build + npm --prefix crates/trusted-server-js/lib run build:prebid-external + npm --prefix crates/trusted-server-js/lib run check:bundle 2>&1 | tee target/aps-tsjs-cutover-evidence/bundle.log + actual_release_id="$(npm --prefix crates/trusted-server-js/lib run --silent print:release-id)" + test -n "$EXPECTED_RELEASE_ID" + test "$actual_release_id" = "$EXPECTED_RELEASE_ID" + cp crates/trusted-server-js/dist/tsjs-release-v1.json target/aps-tsjs-cutover-evidence/ + cp crates/trusted-server-js/dist/tsjs-build-metrics-v1.json target/aps-tsjs-cutover-evidence/ + + - name: Run route parity and the full adapter integration suite + env: + WASM_BINARY_PATH: ${{ github.workspace }}/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm + AXUM_BINARY_PATH: ${{ github.workspace }}/target/debug/trusted-server-axum + CLOUDFLARE_WRANGLER_DIR: ${{ github.workspace }}/crates/trusted-server-adapter-cloudflare + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml + RUST_LOG: info + shell: bash + run: | + set -euo pipefail + cargo test \ + --manifest-path crates/trusted-server-integration-tests/Cargo.toml \ + --test parity 2>&1 | tee target/aps-tsjs-cutover-evidence/route-parity.log + cargo test \ + --manifest-path crates/trusted-server-integration-tests/Cargo.toml \ + --target x86_64-unknown-linux-gnu \ + -- --include-ignored \ + --skip test_wordpress_fastly --skip test_nextjs_fastly \ + --test-threads=1 2>&1 | tee target/aps-tsjs-cutover-evidence/integration.log + + - name: Install Chromium, Firefox, and WebKit + working-directory: crates/trusted-server-integration-tests/browser + run: | + npm ci + npx playwright install --with-deps chromium firefox webkit + + - name: Run the focused three-browser APS/TSJS matrix + working-directory: crates/trusted-server-integration-tests/browser + env: + WASM_BINARY_PATH: ${{ github.workspace }}/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml + TEST_FRAMEWORK: nextjs + TS_BROWSER_PROJECTS: chromium,firefox,webkit + shell: bash + run: | + set -euo pipefail + npx playwright test \ + tests/shared/aps-renderer.spec.ts \ + tests/shared/aps-puc-lifecycle.spec.ts \ + tests/shared/tsjs-runtime.spec.ts \ + tests/shared/creative-sandbox.spec.ts \ + tests/nextjs/gpt-diagnostics.spec.ts \ + tests/nextjs/navigation.spec.ts \ + --project=chromium --project=firefox --project=webkit \ + --reporter=list \ + 2>&1 | tee "$GITHUB_WORKSPACE/target/aps-tsjs-cutover-evidence/playwright-sanitized-report.log" + + - name: Run the APS runner-proxy corpus on every adapter + shell: bash + run: | + set -euo pipefail + for runtime in axum fastly cloudflare spin; do + ./scripts/integration-tests-aps-runner-proxy.sh --runtime "$runtime" \ + 2>&1 | tee "target/aps-tsjs-cutover-evidence/aps-proxy-$runtime.log" + done + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + RUST_LOG: info + + - name: Write exact integration evidence manifest + env: + EVIDENCE_ID: ${{ inputs.evidence_id }} + RELEASE_ID: ${{ inputs.release_id }} + PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }} + run: >- + node -e 'const fs=require("node:fs"); + fs.mkdirSync("target/aps-tsjs-cutover-evidence",{recursive:true}); + fs.writeFileSync("target/aps-tsjs-cutover-evidence/evidence-manifest.json", + JSON.stringify({schemaVersion:1,evidenceId:process.env.EVIDENCE_ID, + releaseId:process.env.RELEASE_ID,previousArtifactId:process.env.PREVIOUS_ARTIFACT_ID, + commitSha:process.env.GITHUB_SHA,runId:process.env.GITHUB_RUN_ID, + conclusion:"success"},null,2)+"\n",{mode:384});' + + - name: Scrub all integration evidence before upload + env: + INTEGRATION_AUTHORIZATION: integration-test-proxy-secret + run: >- + node -e 'const fs=require("node:fs"),path=require("node:path"); + const root="target/aps-tsjs-cutover-evidence", files=[]; + const walk=p=>{for(const e of fs.readdirSync(p,{withFileTypes:true})){const q=path.join(p,e.name);e.isDirectory()?walk(q):files.push(q)}}; walk(root); + const forbiddenExt=new Set([".har",".zip",".webm"]), forbiddenField=/"(?:accountId|aaxResponse|adm|authorization|capabilities?|creativeBody|descriptor|lifecycleTicket|nonce|postData|requestHeaders|responseBody|responseHeaders)"\s*:/u; + for(const file of files){if(forbiddenExt.has(path.extname(file)))throw Error("native capture forbidden: "+file);if(path.extname(file)===".png")continue;const text=fs.readFileSync(file,"utf8");if(text.includes(process.env.INTEGRATION_AUTHORIZATION)||forbiddenField.test(text)||/)|- + Run Tests / ${{ inputs.evidence_id || format('PR {0}', github.event.pull_request.number) }} permissions: contents: read @@ -7,6 +9,16 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + inputs: + evidence_id: + description: Unique cutover evidence identifier + required: true + type: string + release_id: + description: Exact generated TSJS release id + required: true + type: string jobs: test-rust: @@ -226,6 +238,8 @@ jobs: working-directory: crates/trusted-server-js/lib steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Retrieve Node.js version id: node-version @@ -246,5 +260,134 @@ jobs: - name: Build bundle run: npm run build + - name: Build pure external Prebid artifact + run: npm run build:prebid-external + + - name: Verify release inventory + run: npm run test:release + + - name: Enforce bundle budgets + run: npm run check:bundle + + - name: Typecheck full TSJS package + run: npm run typecheck + + - name: Lint full TSJS package + run: npm run lint + + - name: Verify generated APS renderer contract + run: npm run check:aps-contract + + - name: Enforce hard-cutover absence + run: npm run check:hard-cutover-absence + + - name: Run embedded APS renderer contract + run: node --test test/contract/aps-renderer-es5.test.mjs + + - name: Verify retired concept audit + run: npm run check:concept-audit + - name: Run unit tests run: npm test -- --run + + cutover-quality-evidence: + name: APS/TSJS cutover quality evidence + if: github.event_name == 'workflow_dispatch' + needs: + - test-rust + - test-axum + - test-cloudflare + - test-spin + - test-parity + - test-cli + - test-typescript + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read repository toolchain pins + id: toolchains + shell: bash + run: | + node_version="$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" + rust_version="$(awk '$1 == "rust" { print $2 }' .tool-versions)" + test -n "$node_version" + test -n "$rust_version" + echo "node=$node_version" >> "$GITHUB_OUTPUT" + echo "rust=$rust_version" >> "$GITHUB_OUTPUT" + + - name: Set up pinned Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.toolchains.outputs.node }} + cache: npm + cache-dependency-path: | + crates/trusted-server-js/lib/package-lock.json + docs/package-lock.json + + - name: Set up pinned Rust quality targets + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.toolchains.outputs.rust }} + components: clippy, rustfmt + target: wasm32-wasip1,wasm32-unknown-unknown + cache-shared-key: cargo-${{ runner.os }}-aps-tsjs-quality + + - name: Install exact JavaScript dependencies + run: | + npm --prefix crates/trusted-server-js/lib ci + npm --prefix docs ci + + - name: Validate release id and run final quality gates + env: + EXPECTED_RELEASE_ID: ${{ inputs.release_id }} + shell: bash + run: | + set -euo pipefail + mkdir -p target/aps-tsjs-quality-evidence + { + cargo fmt --all -- --check + npm --prefix crates/trusted-server-js/lib run format + npm --prefix docs run format + cargo clippy-fastly + cargo clippy-axum + cargo clippy-cloudflare + cargo clippy-cloudflare-wasm + cargo clippy-spin-native + cargo clippy-spin-wasm + cargo clippy --manifest-path crates/trusted-server-integration-tests/Cargo.toml --all-targets -- -D warnings + npm --prefix crates/trusted-server-js/lib run build + npm --prefix crates/trusted-server-js/lib run build:prebid-external + npm --prefix crates/trusted-server-js/lib run check:aps-contract + npm --prefix crates/trusted-server-js/lib run check:hard-cutover-absence + npm --prefix crates/trusted-server-js/lib run check:bundle + npm --prefix crates/trusted-server-js/lib run check:concept-audit + npm --prefix docs run lint + npm --prefix docs run build + actual_release_id="$(npm --prefix crates/trusted-server-js/lib run --silent print:release-id)" + test -n "$EXPECTED_RELEASE_ID" + test "$actual_release_id" = "$EXPECTED_RELEASE_ID" + } 2>&1 | tee target/aps-tsjs-quality-evidence/quality.log + cp crates/trusted-server-js/dist/tsjs-release-v1.json target/aps-tsjs-quality-evidence/ + cp crates/trusted-server-js/dist/tsjs-build-metrics-v1.json target/aps-tsjs-quality-evidence/ + + - name: Write exact quality evidence manifest + env: + EVIDENCE_ID: ${{ inputs.evidence_id }} + RELEASE_ID: ${{ inputs.release_id }} + run: >- + node -e 'const fs=require("node:fs"); + const path="target/aps-tsjs-quality-evidence/evidence-manifest.json"; + fs.writeFileSync(path,JSON.stringify({schemaVersion:1,evidenceId:process.env.EVIDENCE_ID, + releaseId:process.env.RELEASE_ID,commitSha:process.env.GITHUB_SHA, + runId:process.env.GITHUB_RUN_ID,conclusion:"success"},null,2)+"\n",{mode:384});' + + - name: Upload exact quality evidence + uses: actions/upload-artifact@v4 + with: + name: aps-tsjs-quality-${{ github.run_id }} + path: target/aps-tsjs-quality-evidence/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/tsjs-performance-gate.yml b/.github/workflows/tsjs-performance-gate.yml new file mode 100644 index 000000000..f759f710a --- /dev/null +++ b/.github/workflows/tsjs-performance-gate.yml @@ -0,0 +1,176 @@ +name: "TSJS Performance Gate" +run-name: >- + TSJS Performance Gate / ${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }} / ${{ inputs.mode || 'pull-request' }} + +permissions: + contents: read + +on: + pull_request: + paths: + - ".github/workflows/tsjs-performance-gate.yml" + - ".tool-versions" + - "Cargo.toml" + - "Cargo.lock" + - "crates/trusted-server-core/**" + - "crates/trusted-server-core/src/auction/**" + - "crates/trusted-server-integration-tests/Cargo.toml" + - "crates/trusted-server-integration-tests/browser/**" + - "crates/trusted-server-integration-tests/src/bin/generate-tsjs-prospective-fixture.rs" + - "crates/trusted-server-js/**" + - "scripts/validate-tsjs-performance-evidence.mjs" + workflow_dispatch: + inputs: + evidence_id: + description: Unique identifier bound to the uploaded evidence + required: true + type: string + mode: + description: Cutover side measured by this run + required: true + type: choice + options: + - preswitch + - postswitch + workflow_call: + inputs: + evidence_id: + description: Unique identifier bound to the uploaded evidence + required: true + type: string + mode: + description: Cutover side measured by this run (preswitch or postswitch) + required: true + type: string + +env: + TSJS_PERF_MACHINE_CLASS: github-hosted:ubuntu-24.04 + TSJS_PERF_RUNNER_IMAGE: ubuntu-24.04 + TSJS_PERF_WORKFLOW_NAME: TSJS Performance Gate + TSJS_PERF_WORKFLOW_FILE: .github/workflows/tsjs-performance-gate.yml + TSJS_PERF_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + +jobs: + measure: + name: measure ${{ inputs.mode || 'pull-request' }} (${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }}) + runs-on: ubuntu-24.04 + timeout-minutes: 35 + env: + TSJS_EVIDENCE_ID: ${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }} + TSJS_PERF_MODE: ${{ inputs.mode || 'pull-request' }} + TSJS_PERF_ARTIFACT_NAME: tsjs-performance-${{ inputs.evidence_id || format('tsjs-pr-{0}', github.run_id) }} + TSJS_PERF_OUTPUT: crates/trusted-server-integration-tests/browser/test-results/tsjs-performance-${{ inputs.mode || 'pull-request' }}.json + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate immutable measurement inputs + shell: bash + run: | + test "$TSJS_PERF_MODE" = preswitch || test "$TSJS_PERF_MODE" = postswitch || test "$TSJS_PERF_MODE" = pull-request + [[ "$TSJS_EVIDENCE_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$ ]] + + - name: Read repository toolchain pins + id: toolchains + shell: bash + run: | + node_version="$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" + rust_version="$(awk '$1 == "rust" { print $2 }' .tool-versions)" + test -n "$node_version" + test -n "$rust_version" + echo "node=$node_version" >> "$GITHUB_OUTPUT" + echo "rust=$rust_version" >> "$GITHUB_OUTPUT" + + - name: Set up pinned Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.toolchains.outputs.node }} + cache: npm + cache-dependency-path: | + crates/trusted-server-js/lib/package-lock.json + crates/trusted-server-integration-tests/browser/package-lock.json + + - name: Set up pinned Rust for the generated controller fixture + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.toolchains.outputs.rust }} + cache-shared-key: cargo-${{ runner.os }}-tsjs-performance + + - name: Verify installed toolchain pins + shell: bash + run: | + test "$(node --version)" = "v24.12.0" + test "$(npm --version)" = "11.6.2" + test "$(rustc --version | awk '{ print $2 }')" = "1.95.0" + + - name: Build the real TSJS artifacts once + working-directory: crates/trusted-server-js/lib + run: | + npm ci + npm run build + npm run check:bundle + + - name: Build the exact current main artifacts + shell: bash + run: | + git fetch origin main + main_sha="$(git rev-parse origin/main)" + main_root="$RUNNER_TEMP/tsjs-performance-main" + [[ "$main_sha" =~ ^[0-9a-f]{40}$ ]] + test "$(git cat-file -t "$main_sha")" = commit + git worktree add --detach "$main_root" "$main_sha" + npm --prefix "$main_root/crates/trusted-server-js/lib" ci + npm --prefix "$main_root/crates/trusted-server-js/lib" run build + echo "TSJS_PERF_MAIN_SHA=$main_sha" >> "$GITHUB_ENV" + echo "TSJS_PERF_MAIN_ROOT=$main_root" >> "$GITHUB_ENV" + + - name: Install the lockfile-pinned Chromium setup + working-directory: crates/trusted-server-integration-tests/browser + run: | + npm ci + node -e 'const version = require("@playwright/test/package.json").version; if (version !== "1.58.2") throw new Error("unexpected Playwright " + version)' + npx playwright install --with-deps chromium + + - name: Run the complete TSJS performance sample exactly once + working-directory: crates/trusted-server-integration-tests/browser + env: + CI: "true" + GITHUB_SHA: ${{ env.TSJS_PERF_HEAD_SHA }} + run: | + config_file="$RUNNER_TEMP/tsjs-performance-playwright.config.mjs" + printf "%s\n" \ + "export default {" \ + " testDir: process.cwd()," \ + " timeout: 30000," \ + " retries: 0," \ + " workers: 1," \ + " use: { headless: true }," \ + " projects: [{ name: 'chromium', use: { browserName: 'chromium' } }]," \ + " reporter: [['list']]," \ + " outputDir: './test-results'" \ + "};" > "$config_file" + npx playwright test \ + tests/shared/tsjs-performance.spec.ts \ + --config="$config_file" \ + --project=chromium \ + --workers=1 + + - name: Validate the generated evidence before upload + if: always() + run: >- + node scripts/validate-tsjs-performance-evidence.mjs + --file "$TSJS_PERF_OUTPUT" + --evidence-id "$TSJS_EVIDENCE_ID" + --head-sha "$TSJS_PERF_HEAD_SHA" + --main-sha "$TSJS_PERF_MAIN_SHA" + --mode "$TSJS_PERF_MODE" + + - name: Upload immutable TSJS performance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ env.TSJS_PERF_ARTIFACT_NAME }} + path: ${{ env.TSJS_PERF_OUTPUT }} + if-no-files-found: error + retention-days: 30 diff --git a/.tool-versions b/.tool-versions index 758146800..5330e3de6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,5 @@ fastly 15.1.0 rust 1.95.0 nodejs 24.12.0 -viceroy 0.17.0 +viceroy 0.19.0 wasmtime 44.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index c00487769..20cf4f781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration now uses canonical `account_id` (`pub_id` remains a compatibility alias), no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. `aps` entries in Prebid bidder lists are logged and stripped. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry `ext.trusted_server.renderer` instead of `adm`; external `/auction` consumers must support this response shape. -- **Breaking** — All auction paths now forward only a validated publisher-owned page URL as `site.page`, removing query and fragment data. APS OpenRTB omits `site.ref`; the existing Prebid Server path continues to forward the browser `Referer` as `site.ref`. Query-driven sites may lose contextual targeting and per-page reporting signals that previously came from query parameters. +- **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration accepts only canonical `account_id`, no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, remove `pub_id`, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. `aps` entries in Prebid bidder lists are logged and stripped. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry a typed render source instead of executable markup on the public browser wire. +- **Breaking** — All auction paths forward only a validated publisher-owned page URL as `site.page`, removing query and fragment data. APS OpenRTB omits `site.ref`; the Prebid Server path continues to forward the browser `Referer` as `site.ref`. Query-driven sites may lose contextual targeting and per-page reporting signals that previously came from query parameters. +- Publisher HTML now uses `Cache-Control: max-age=60` when server-side ad templates are inactive, while preserving origin `private`/`no-store` policies and CDN-specific cache headers. Set `[creative_opportunities].enabled = false` to disable publisher HTML and SPA template delivery without disabling direct `POST /auction` callers. - **Breaking** — `bid_param_zone_overrides` inner values must now be JSON objects; previously non-object or empty values (`"header" = "x"`, `"header" = {}`) were accepted and silently produced a dead rule at runtime. They now fail at startup with a configuration error. Operators upgrading should audit their `bid_param_zone_overrides` config for non-object zone entries. - **Breaking** — Integration configuration strings are no longer globally reinterpreted as JSON scalars. Operators upgrading should audit `[integrations.*]` settings and use native TOML/typed-config booleans and numbers (for example, `enabled = true`, not `enabled = "true"`); quoted numeric and boolean scalars now fail validation instead of silently converting. - **Breaking** — Sourcepoint browser module inclusion now requires explicit `[integrations.sourcepoint].enabled = true`; operators relying on the previous unconditional Sourcepoint module should enable the integration before upgrading. -- **Breaking** — Auction creative sanitization is now opt-in: the new `[auction].sanitize_creatives` defaults to `false` because unconditional sanitization blanked script-based creatives (the majority of programmatic display) while recording normal impressions. `[auction].rewrite_creatives` keeps its `true` default. The per-creative cap is now enforced on rewritten output as well as raw input and in every processing mode (1 MiB for auction `adm`; proxied HTML documents keep the proxy's own 10 MiB bound), rewriting fails closed on parser errors instead of emitting partial output and never turns a rejected creative into a runtime-only `adm`, and `hb_cache_host`/`hb_cache_path` are emitted only for bids that supplied no creative — any bid carrying its own `adm` ships without them, so a processed or rejected creative can never be re-fetched raw from PBS Cache. Creative markup with no `` token now receives the click-guard runtime, and bidder `` elements are stripped whenever rewriting is enabled. The creative iframe sandbox no longer grants `allow-same-origin`, restoring origin isolation; rewritten-click recovery from the resulting opaque-origin iframe uses the GET `/first-party/proxy-rebuild` navigation fallback, now registered in every adapter and documented alongside the POST JSON form. Inside those iframes, dynamic resource signing and CORS-mode subresources (ES modules, `crossorigin` fonts) are unavailable pending the constrained asset capability in [#982](https://github.com/IABTechLab/trusted-server/issues/982); ordinary image, script, and stylesheet loads are unaffected. Upgrading: binaries that predate `sanitize_creatives` reject a blob carrying it, so upgrade the binary first, then push the config. Rollback: non-default values (`sanitize_creatives = true`, `rewrite_creatives = false`) are serialized into the config blob and older binaries reject unknown fields — before rolling back to a binary that predates a field, restore its default, push the default-compatible blob, then roll back. -- The SPA re-auction endpoint moved from `/__ts/page-bids` to `/_ts/page-bids`, joining every other internal route in the `/_ts/` namespace. The old path stays registered as a deprecated alias so already-loaded bundles keep serving ads, and responses on it carry a `Link: …; rel="deprecation"` header so remaining traffic is measurable from edge logs; removal is tracked in [#970](https://github.com/IABTechLab/trusted-server/issues/970). Two deployment notes: audit `[[handlers]]` for patterns broad enough to cover `/_ts` (for example `^/_ts`), which would put this browser-facing endpoint behind Basic Auth and return `401` to every visitor — scope them to `^/_ts/admin`; and prefer rolling forward over rolling back, since a server reverted past this release does not register the canonical path. In both cases the shipped client falls back to the deprecated alias, so the exposure is bounded until that alias is removed. +- **Breaking** — Auction creative sanitization is now opt-in: `[auction].sanitize_creatives` defaults to `false` because unconditional sanitization blanked script-based creatives while recording normal impressions; `[auction].rewrite_creatives` stays default-on. The auction `adm` cap is 1 MiB while full proxied HTML keeps the proxy's 10 MiB bound. Rewriting now fails closed on parser or output-limit errors, never resurrects rejected markup as a runtime-only `adm`, removes bidder `` elements, and injects the click guard into body-less fragments. Creative frames omit `allow-same-origin`; mutated clicks recover through GET or form-POST `/first-party/proxy-rebuild` navigation. The generic proxy strips upstream CORS grants, so opaque frames cannot read proxied bodies; CORS-mode subresources and dynamic resource signing remain unavailable pending [#982](https://github.com/IABTechLab/trusted-server/issues/982), while ordinary image, script, stylesheet, and media loads are unaffected. +- **Breaking** — The SPA re-auction endpoint is `/_ts/page-bids`. The removed `/__ts/page-bids` spelling is an unknown route; update handler rules and callers at cutover. - Added optional APS `inventory_domain` and `inventory_page_origin` overrides for deployments whose edge hostname differs from the APS-authorized inventory identity. -- Preserved APS renderer capabilities through the client-side `trustedServer` Prebid adapter, allowing its generated `hb_adid` to render through GAM and Prebid Universal Creative instead of producing an empty creative. +- APS/ADM Prebid delivery now crosses Prebid normalization using only standard `adId` plus per-bid `meta` identity; executable renderer sources remain in the bounded server-owned reservation and cannot be stripped with an unknown top-level bid field. ### Security @@ -26,10 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Protocol-relative creative URLs now honor `rewrite.exclude_domains`, so excluded creative assets stay direct and excluded absolute or protocol-relative URLs submitted to `/first-party/sign` are rejected. -- Server-side ad template bids now always carry `hb_adid` in `window.tsjs.bids`. Bidders that return neither a Prebid Cache UUID nor an `adid` previously produced no `hb_adid` at all, so no `hb_adid` GPT targeting key was set and the Universal Creative render bridge had nothing to match — the winning creative never rendered. The OpenRTB bid `id`, which is mandatory per spec, is now the last-resort source; `cache_id` and `adid` still take priority where present. Blank `cacheId`/`adid` values no longer win that precedence and emit an unusable empty `hb_adid`, and `hb_cache_host`/`hb_cache_path` are now emitted only alongside a real Prebid Cache UUID — without one they pointed the Universal Creative at a guaranteed cache miss instead of letting it fall through to the inline creative. +- The canonical browser auction projection now rejects blank upstream bid IDs per winner, uses a server-minted renderer reservation as the sole GAM render identity, and emits cache coordinates only as part of a validated cache render source. This replaces the legacy `window.tsjs.bids` `hb_adid` fallback chain. ### Added +- Added opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape. +- Added typed APS renderer transport for direct auctions and GAM/Prebid Universal Creative, using a minimized one-bid envelope, a fragment-bound nonce, and an opaque sandboxed renderer endpoint. - Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (proxy/click URL conversion, bidder `` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery. - `creative_opportunities.slot.gam_unit_path` is now a template supporting `{network_id}`, `{slot_id}`, and `{section}`, so a publisher whose ad unit varies by site section expresses it in one slot rule instead of one per (slot × section). `{section}` derives from the request path: `[creative_opportunities].section_segment` selects which path segment names the section (0-based, default `0`; set `1` for locale-prefixed URLs), and `section_root` supplies the value for paths with no such segment. `section_root` is required when a template uses `{section}`. Existing static and absent `gam_unit_path` configs are unchanged. Startup rejects a blank `gam_network_id` only when an absent/default path or `{network_id}` template consumes it. Trusted Server conservatively caps whole rendered dynamic paths at 100 UTF-8 bytes, informed by Google's 100-character per-ad-unit-code limit; an over-limit request-specific path omits that slot without failing the response. During typed/startup finalization, every placeholder-bearing template that omits `section_segment` materializes `section_segment = 0`, so an older binary rejects the blob loudly. Static and absent paths remain legacy-schema compatible only when both `section_root` and `section_segment` are omitted. Before rolling back below this feature, replace or remove dynamic paths, remove both keys, re-push and finalize the config, then roll back the binary. - Added opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape. diff --git a/CLAUDE.md b/CLAUDE.md index 546a3bf52..dd1c4041f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Supporting files: `edgezero.toml`, `fastly.toml`, | WASM target | `wasm32-wasip1` | | Node | 24.12.0 (from `.tool-versions`) | | Fastly CLI | 15.1.0 (from `.tool-versions`) | -| Viceroy | 0.17.0 (from `.tool-versions`) | +| Viceroy | 0.19.0 (from `.tool-versions`) | | Wasmtime | 44.0.1 (from `.tool-versions`) | --- @@ -139,7 +139,7 @@ cd crates/trusted-server-js/lib && node build-all.mjs ### Install prerequisites ```bash -cargo install viceroy --version 0.17.0 --locked --force +cargo install viceroy --version 0.19.0 --locked --force ``` --- diff --git a/Cargo.lock b/Cargo.lock index cb8f40c68..159548088 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5287,6 +5287,7 @@ dependencies = [ "trusted-server-core", "url", "urlencoding", + "web-time", ] [[package]] @@ -5415,6 +5416,7 @@ dependencies = [ "reqwest 0.12.28", "scraper", "serde_json", + "tempfile", "testcontainers", "tokio", "toml", @@ -5432,6 +5434,8 @@ version = "0.1.0" dependencies = [ "build-print", "hex", + "serde", + "serde_json", "sha2 0.10.9", "which", ] diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 15b6ee59d..ab9a72942 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -18,8 +18,13 @@ path = "src/lib.rs" name = "trusted-server-axum" path = "src/main.rs" +[features] +default = [] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] + [dependencies] async-trait = { workspace = true } +axum = { workspace = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } edgezero-core = { workspace = true } error-stack = { workspace = true } @@ -31,7 +36,6 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "ti trusted-server-core = { workspace = true } [dev-dependencies] -axum = { workspace = true } base64 = { workspace = true } temp-env = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 1bed830ac..01ce83bc6 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -19,8 +19,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, buffer_publisher_response_async, - handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_PATH, buffer_publisher_response_async, handle_page_bids, + handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -29,6 +29,7 @@ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; +use trusted_server_core::trace_cookie::handle_trace_mode; use trusted_server_core::platform::RuntimeServices; @@ -79,6 +80,91 @@ fn build_state_with_settings( })) } +async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_runtime_services(&ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +#[derive(Clone)] +/// Dispatcher that owns one startup-built registry for hard-cutover route families. +pub struct ReservedApsDispatcher { + state: Arc, +} + +impl ReservedApsDispatcher { + /// Build the dispatcher from the adapter's startup settings. + /// + /// # Errors + /// + /// Returns an error when settings, the orchestrator, or the integration + /// registry cannot be initialized. + pub fn from_startup_settings() -> Result> { + // The outer Axum router cannot share EdgeZero's private application + // state, so the dev adapter builds one additional immutable startup + // snapshot for only the two reserved APS browser resources. Production + // adapters do not take this native development path. + Ok(Self { + state: build_state()?, + }) + } + + /// Build the dispatcher from explicit settings. + /// + /// # Errors + /// + /// Returns an error when the orchestrator or integration registry cannot be + /// initialized from `settings`. + pub fn from_settings(settings: Settings) -> Result> { + Ok(Self { + state: build_state_with_settings(settings)?, + }) + } + + /// Dispatch a request when it belongs to the reserved APS family. + pub async fn dispatch(&self, req: Request) -> Option { + dispatch_reserved_for_state(&self.state, req).await + } +} + +/// Dispatch a reserved APS request using explicit settings. +/// +/// # Errors +/// +/// Returns an error when the dispatcher cannot be initialized. +pub async fn dispatch_reserved_with_settings( + settings: Settings, + req: Request, +) -> Result, Report> { + Ok(ReservedApsDispatcher::from_settings(settings)? + .dispatch(req) + .await) +} + +/// Dispatch a reserved APS request using startup settings. +/// +/// # Errors +/// +/// Returns an error when startup settings or the dispatcher +/// cannot be initialized. +pub async fn dispatch_reserved( + req: Request, +) -> Result, Report> { + Ok(ReservedApsDispatcher::from_startup_settings()? + .dispatch(req) + .await) +} + // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -182,7 +268,7 @@ async fn dispatch_fallback( let path = req.uri().path().to_string(); let method = req.method().clone(); - if method == Method::GET && path.starts_with("/static/tsjs=") { + if path.starts_with("/static/tsjs=") { return handle_tsjs_dynamic(&req, &state.registry); } @@ -262,6 +348,7 @@ enum NamedRouteHandler { /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never /// reach the publisher fallback (which would leak admin credentials). LegacyAdminDenied, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -286,7 +373,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 13] { +fn named_routes() -> [NamedRoute; 14] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -327,6 +414,11 @@ fn named_routes() -> [NamedRoute; 13] { primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::LegacyAdminDenied, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -339,13 +431,12 @@ fn named_routes() -> [NamedRoute; 13] { primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, - // Deprecated double-underscore alias, kept so tsjs bundles served before - // the `/_ts/page-bids` rename keep getting ads on SPA navigations until - // they age out of browser caches. See `PAGE_BIDS_LEGACY_PATH`. + // This removed route must never reach the publisher fallback, which + // would make the hard cutover depend on the origin response. NamedRoute { - path: PAGE_BIDS_LEGACY_PATH, - primary_methods: &[Method::GET, Method::OPTIONS], - handler: NamedRouteHandler::PageBids, + path: "/__ts/page-bids", + primary_methods: LEGACY_ADMIN_DENY_METHODS, + handler: NamedRouteHandler::LegacyAdminDenied, }, NamedRoute { path: "/first-party/proxy", @@ -408,6 +499,9 @@ fn named_route_handler( Ok(resp) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), + NamedRouteHandler::TraceMode => { + handle_trace_mode(&state.settings, req.uri().query()) + } NamedRouteHandler::Auction => { // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 960982176..2899a22ac 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,9 +1,14 @@ -use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; +use edgezero_adapter_axum::dev_server::AxumDevServerConfig; use edgezero_core::app::Hooks as _; use trusted_server_adapter_axum::app::TrustedServerApp; +#[tokio::main] #[allow(clippy::print_stderr)] -fn main() { +async fn main() { + use axum::Router; + use axum::routing::any; + use edgezero_adapter_axum::service::EdgeZeroAxumService; + if let Err(e) = simple_logger::SimpleLogger::new().init() { eprintln!("warning: logger init failed: {e}"); } @@ -19,11 +24,65 @@ fn main() { None => AxumDevServerConfig::default(), }; + let dispatcher = + trusted_server_adapter_axum::app::ReservedApsDispatcher::from_startup_settings() + .expect("should build the reserved APS dispatcher"); + let reserved = any(move |request: axum::http::Request| { + let dispatcher = dispatcher.clone(); + async move { + // The core reserved dispatcher is intentionally `?Send`, while this + // native-only development adapter runs on Tokio's multi-threaded + // executor. Keep that bridge explicit: a runner request can occupy + // this blocking-pool thread for its bounded five-second budget. + let response = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async move { + let request = + match edgezero_adapter_axum::request::into_core_request(request).await { + Ok(request) => request, + Err(error) => { + log::warn!("reserved APS request conversion failed: {error:?}"); + return Err(axum::http::StatusCode::BAD_REQUEST); + } + }; + match dispatcher.dispatch(request).await { + Some(response) => Ok(response), + None => { + log::error!( + "reserved APS entry route reached a request outside its family" + ); + Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + } + } + }) + }); + match response { + Ok(response) => edgezero_adapter_axum::response::into_axum_response(response), + Err(status) => axum::response::IntoResponse::into_response(status), + } + } + }); + let app = Router::new() + .route("/integrations/aps", reserved.clone()) + .route("/integrations/aps/{*rest}", reserved) + .fallback_service(EdgeZeroAxumService::new(TrustedServerApp::routes())); + let listener = tokio::net::TcpListener::bind(config.addr) + .await + .expect("should bind the configured address"); log::info!("Listening on http://{}", config.addr); - let router = TrustedServerApp::routes(); - if let Err(err) = AxumDevServer::with_config(router, config).run() { - log::error!("trusted-server-adapter-axum failed: {err}"); - std::process::exit(1); + let server = axum::serve(listener, app); + let result = if config.enable_ctrl_c { + server + .with_graceful_shutdown(async { + if let Err(error) = tokio::signal::ctrl_c().await { + log::error!("failed to install Ctrl-C handler: {error}"); + } + }) + .await + } else { + server.await + }; + if let Err(error) = result { + log::error!("trusted-server-adapter-axum failed: {error}"); } } diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index a511daab2..a44ceb147 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -11,7 +11,8 @@ use error_stack::{Report, ResultExt as _}; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, + PlatformSecretStore, PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, + RawProxyPolicyV1, RawProxyResponseV1, RuntimeServices, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -285,6 +286,9 @@ pub struct AxumPlatformHttpClient { client: reqwest::Client, } +#[cfg(feature = "aps-runner-proxy-integration-test")] +const APS_RUNNER_PROXY_TEST_ENDPOINT_ENV: &str = "TS_APS_RUNNER_PROXY_TEST_ENDPOINT"; + impl AxumPlatformHttpClient { /// Create a new client with sensible dev-server timeouts. /// @@ -307,6 +311,38 @@ impl AxumPlatformHttpClient { } } + #[cfg(feature = "aps-runner-proxy-integration-test")] + fn aps_runner_proxy_test_transport_uri( + logical_uri: &str, + ) -> Result, Report> { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + if logical_uri != APS_RUNNER_UPSTREAM_URL { + return Ok(None); + } + let endpoint = std::env::var(APS_RUNNER_PROXY_TEST_ENDPOINT_ENV).map_err(|_| { + Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration artifact requires its loopback fixture endpoint", + ) + })?; + let parsed = reqwest::Url::parse(&endpoint) + .change_context(PlatformError::HttpClient) + .attach("invalid APS runner proxy integration fixture endpoint")?; + if parsed.scheme() != "http" + || !matches!(parsed.host_str(), Some("127.0.0.1" | "::1")) + || parsed.port().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration fixture endpoint must be an explicit loopback HTTP URL", + )); + } + Ok(Some(parsed.into())) + } + /// Drain `body` to a `Vec`. /// /// For `Body::Stream` this awaits every chunk in the current async context @@ -380,6 +416,127 @@ impl AxumPlatformHttpClient { Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) } + + fn raw_header_evidence( + headers: &reqwest::header::HeaderMap, + name: reqwest::header::HeaderName, + ) -> ProxyHeaderEvidenceV1 { + ProxyHeaderEvidenceV1::Occurrences( + headers + .get_all(name) + .iter() + .map(|value| value.as_bytes().to_vec()) + .collect(), + ) + } + + fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else { + return None; + }; + let [value] = values.as_slice() else { + return None; + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() + } + + async fn execute_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + if request.image_optimizer.is_some() || request.stream_response { + return Err(Report::new(PlatformError::HttpClient) + .attach("unsupported option on Axum raw proxy request")); + } + + let logical_uri = request.request.uri().to_string(); + #[cfg(feature = "aps-runner-proxy-integration-test")] + let transport_uri = Self::aps_runner_proxy_test_transport_uri(&logical_uri)?; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let uri = transport_uri.as_deref().unwrap_or(&logical_uri); + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] + let uri = logical_uri.as_str(); + let method = reqwest::Method::from_bytes(request.request.method().as_str().as_bytes()) + .change_context(PlatformError::HttpClient)?; + let mut builder = self.client.request(method, uri); + for (name, value) in request.request.headers() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + #[cfg(feature = "aps-runner-proxy-integration-test")] + if transport_uri.is_some() { + builder = builder + .header(reqwest::header::HOST, "client.aps.amazon-adsystem.com") + .header("x-ts-aps-logical-url", logical_uri.as_str()); + } + let (_, request_body) = request.request.into_parts(); + let request_body = Self::buffer_body(request_body).await?; + if !request_body.is_empty() { + builder = builder.body(request_body); + } + + tokio::time::timeout(policy.total_timeout, async move { + let mut response = tokio::time::timeout(policy.first_byte_timeout, builder.send()) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte deadline exceeded") + })? + .change_context(PlatformError::HttpClient)?; + let evidence = ProxyResponseEvidenceV1 { + status: response.status().as_u16(), + content_type: Self::raw_header_evidence( + response.headers(), + reqwest::header::CONTENT_TYPE, + ), + content_encoding: Self::raw_header_evidence( + response.headers(), + reqwest::header::CONTENT_ENCODING, + ), + content_length: Self::raw_header_evidence( + response.headers(), + reqwest::header::CONTENT_LENGTH, + ), + }; + if Self::canonical_declared_length(&evidence.content_length) + .is_some_and(|length| length > policy.max_response_bytes) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy declared body exceeds configured cap")); + } + + let mut body = Vec::new(); + loop { + let chunk = tokio::time::timeout(policy.blocking_read_timeout, response.chunk()) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy blocking-read deadline exceeded") + })? + .change_context(PlatformError::HttpClient)?; + let Some(chunk) = chunk else { break }; + let next_len = body.len().checked_add(chunk.len()).ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&chunk); + } + Ok(RawProxyResponseV1 { evidence, body }) + }) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient).attach("raw proxy total deadline exceeded") + })? + } } impl Default for AxumPlatformHttpClient { @@ -397,6 +554,14 @@ impl PlatformHttpClient for AxumPlatformHttpClient { self.execute(request).await } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + self.execute_raw_proxy_v1(request, policy).await + } + async fn send_async( &self, request: PlatformHttpRequest, @@ -756,6 +921,223 @@ mod tests { ); } + fn raw_proxy_request(url: &str) -> PlatformHttpRequest { + PlatformHttpRequest::new( + edgezero_core::http::request_builder() + .uri(url) + .header(header::ACCEPT_ENCODING, "identity") + .body(EdgeBody::empty()) + .expect("should build raw proxy request"), + "test_backend", + ) + } + + fn raw_proxy_policy(timeout: Duration, max_response_bytes: usize) -> RawProxyPolicyV1 { + RawProxyPolicyV1 { + total_timeout: timeout, + first_byte_timeout: timeout, + blocking_read_timeout: timeout, + max_response_bytes, + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_preserves_header_occurrences_and_exact_bytes() { + let url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/javascript\r\n\ + Content-Encoding: identity\r\n\ + Content-Length: 2\r\n\ + Set-Cookie: must-not-enter-core=1\r\n\ + \r\n\ + ok", + ) + .await; + + let response = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&url), + raw_proxy_policy(Duration::from_secs(1), 2), + ) + .await + .expect("valid raw response should be collected"); + + assert_eq!(response.evidence.status, 200); + assert_eq!( + response.evidence.content_type, + ProxyHeaderEvidenceV1::one("application/javascript") + ); + assert_eq!( + response.evidence.content_encoding, + ProxyHeaderEvidenceV1::one("identity") + ); + assert_eq!( + response.evidence.content_length, + ProxyHeaderEvidenceV1::one("2") + ); + assert_eq!(response.body, b"ok"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_preserves_duplicate_security_headers_for_core_rejection() { + let url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/javascript\r\n\ + Content-Type: text/javascript\r\n\ + Content-Length: 2\r\n\ + \r\n\ + ok", + ) + .await; + + let response = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&url), + raw_proxy_policy(Duration::from_secs(1), 2), + ) + .await + .expect("transport should preserve duplicate evidence"); + + assert_eq!( + response.evidence.content_type, + ProxyHeaderEvidenceV1::Occurrences(vec![ + b"application/javascript".to_vec(), + b"text/javascript".to_vec(), + ]) + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_cancels_on_body_overflow_and_total_deadline() { + let overflow_url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/javascript\r\n\ + Transfer-Encoding: chunked\r\n\ + \r\n\ + 2\r\n\ + ok\r\n\ + 0\r\n\ + \r\n", + ) + .await; + let overflow = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&overflow_url), + raw_proxy_policy(Duration::from_secs(1), 1), + ) + .await; + assert!(overflow.is_err(), "one byte over the cap must fail"); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind deadline test server"); + let addr = listener.local_addr().expect("should read local address"); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("should accept request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read request"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\n\r\nok", + ) + .await; + }); + let deadline = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{addr}/")), + raw_proxy_policy(Duration::from_millis(20), 2), + ) + .await; + assert!(deadline.is_err(), "total deadline must cover first byte"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_enforces_first_byte_and_blocking_read_deadlines() { + let first_byte_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind first-byte deadline server"); + let first_byte_addr = first_byte_listener + .local_addr() + .expect("should read first-byte server address"); + tokio::spawn(async move { + let (mut stream, _) = first_byte_listener + .accept() + .await + .expect("should accept first-byte request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read first-byte request"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\n\r\nok", + ) + .await; + }); + let first_byte = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{first_byte_addr}/")), + RawProxyPolicyV1 { + total_timeout: Duration::from_secs(1), + first_byte_timeout: Duration::from_millis(20), + blocking_read_timeout: Duration::from_secs(1), + max_response_bytes: 2, + }, + ) + .await; + assert!( + first_byte.is_err(), + "response headers after the first-byte deadline must fail" + ); + + let body_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind blocking-read deadline server"); + let body_addr = body_listener + .local_addr() + .expect("should read blocking-read server address"); + tokio::spawn(async move { + let (mut stream, _) = body_listener + .accept() + .await + .expect("should accept blocking-read request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read blocking-read request"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nTransfer-Encoding: chunked\r\n\r\n1\r\no\r\n", + ) + .await + .expect("should write first body chunk"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream.write_all(b"1\r\nk\r\n0\r\n\r\n").await; + }); + let blocking_read = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{body_addr}/")), + RawProxyPolicyV1 { + total_timeout: Duration::from_secs(1), + first_byte_timeout: Duration::from_secs(1), + blocking_read_timeout: Duration::from_millis(20), + max_response_bytes: 2, + }, + ) + .await; + assert!( + blocking_read.is_err(), + "a body read blocked past its deadline must fail" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn select_attributes_failed_backend_name() { // Bind and immediately drop a listener so the port is closed — the diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 03caa3d11..d7cfa40c4 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -18,14 +18,19 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// The settings baked into the binary contain placeholder secrets that /// `get_settings()` rejects by design, which would turn every route into a /// startup error page (and its route table into the fallback-only set). -fn test_router() -> edgezero_core::router::RouterService { - let settings = trusted_server_core::settings::Settings::from_toml( +fn test_settings() -> trusted_server_core::settings::Settings { + trusted_server_core::settings::Settings::from_toml( r#" [[handlers]] path = "^/_ts/admin" username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -34,14 +39,33 @@ fn test_router() -> edgezero_core::router::RouterService { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [integrations.aps] + enabled = true + account_id = "route-test-aps-account" + allow_script_creatives = true "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") +} - TrustedServerApp::routes_with_settings(settings) +fn test_router() -> edgezero_core::router::RouterService { + TrustedServerApp::routes_with_settings(test_settings()) .expect("should build router from test settings") } +async fn route_reserved(request: Request) -> axum::http::Response { + let request = edgezero_adapter_axum::request::into_core_request(request) + .await + .expect("should convert reserved APS request"); + let response = + trusted_server_adapter_axum::app::dispatch_reserved_with_settings(test_settings(), request) + .await + .expect("should build APS dispatcher") + .expect("APS family should be reserved"); + edgezero_adapter_axum::response::into_axum_response(response) +} + fn make_service() -> EdgeZeroAxumService { EdgeZeroAxumService::new(test_router()) } @@ -77,16 +101,9 @@ fn all_explicit_routes_are_registered() { ("POST", "/admin/keys/rotate"), ("POST", "/admin/keys/deactivate"), ("POST", "/auction"), - // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both - // paths are spelled out as literals rather than referencing - // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the - // actual URL the tsjs client fetches — asserting a const against itself - // would still pass if the const's value changed out from under the - // client. + // Pin the canonical literal fetched by the hard-cutover client. ("GET", "/_ts/page-bids"), ("OPTIONS", "/_ts/page-bids"), - ("GET", "/__ts/page-bids"), - ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), @@ -98,6 +115,9 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } + for method in LEGACY_ADMIN_DENY_METHODS { + assert_route_registered(method, "/__ts/page-bids"); + } } /// Verify the legacy non-`/_ts` admin aliases ARE registered — to the local @@ -208,6 +228,133 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_wrong_methods_are_local_no_store_404s() { + for method in ["HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] { + let req = Request::builder() + .method(method) + .uri(format!( + "/static/tsjs=tsjs-unified.min.js?v={}", + "0".repeat(64) + )) + .body(AxumBody::empty()) + .expect("should build wrong-method TSJS request"); + let response = make_service() + .oneshot(req) + .await + .expect("should reject TSJS request locally"); + + assert_eq!(response.status().as_u16(), 404, "method {method}"); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "method {method}" + ); + assert!( + !response.headers().contains_key("location"), + "method {method}" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_cutover_renderer_and_family_failures_are_local() { + let renderer = Request::builder() + .method("GET") + .uri("/integrations/aps/renderer/v1") + .header("authorization", "Bearer must-not-reach-publisher") + .body(AxumBody::empty()) + .expect("should build APS renderer request"); + let response = route_reserved(renderer).await; + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("text/html; charset=utf-8") + ); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable") + ); + assert!(response.headers().get("x-frame-options").is_none()); + let body = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("renderer body should be bounded"); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ("POST", "/integrations/aps/runner.js", 405), + ("TRACE", "/integrations/aps/renderer/v1", 405), + ("CONNECT", "/integrations/aps/renderer/v1", 405), + ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("GET", "/integrations/aps/renderer", 404), + ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/runner/v1.js", 404), + ("GET", "/integrations/aps", 404), + ] { + let request = Request::builder() + .method(method) + .uri(path) + .header("authorization", "Bearer must-not-reach-publisher") + .body(AxumBody::empty()) + .expect("should build APS family request"); + let response = route_reserved(request).await; + assert_eq!(response.status().as_u16(), expected, "{method} {path}"); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "{method} {path}" + ); + assert!( + response.headers().get("x-geo-info-available").is_none(), + "{method} {path} must not receive generic finalizer headers" + ); + if expected == 405 { + assert_eq!( + response + .headers() + .get("allow") + .and_then(|v| v.to_str().ok()), + Some("GET") + ); + assert_eq!(response.headers().len(), 2, "{method} {path}"); + } else { + assert_eq!(response.headers().len(), 1, "{method} {path}"); + } + let body = axum::body::to_bytes(response.into_body(), 1) + .await + .expect("local APS failure body should be empty"); + assert!(body.is_empty(), "{method} {path}"); + } + + let protected_control = Request::builder() + .method("GET") + .uri("/integrations/apsx") + .body(AxumBody::empty()) + .expect("should build protected non-APS boundary request"); + let response = make_service() + .ready() + .await + .expect("should be ready") + .call(protected_control) + .await + .expect("should auth-gate non-APS boundary request"); + assert_eq!(response.status().as_u16(), 401); +} + // --------------------------------------------------------------------------- // Middleware tests // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 097844012..e4e5e4ca7 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -19,6 +19,7 @@ crate-type = ["cdylib", "rlib"] default = [] # Keep for explicit `cargo check --features cloudflare --target wasm32-unknown-unknown` cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] [dependencies] async-trait = { workspace = true } diff --git a/crates/trusted-server-adapter-cloudflare/build.sh b/crates/trusted-server-adapter-cloudflare/build.sh index dcabdee8e..cbd78c23a 100644 --- a/crates/trusted-server-adapter-cloudflare/build.sh +++ b/crates/trusted-server-adapter-cloudflare/build.sh @@ -33,4 +33,9 @@ if [ -z "$WORKER_VERSION" ]; then echo "error: could not determine the worker crate version from Cargo.lock" >&2 exit 1 fi -cargo install -q --force --version "=$WORKER_VERSION" worker-build && worker-build --release +cargo install -q --force --version "=$WORKER_VERSION" worker-build +if [ -n "${TS_WORKER_BUILD_FEATURES:-}" ]; then + worker-build --release . --features "$TS_WORKER_BUILD_FEATURES" +else + worker-build --release +fi diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 644676fc5..0990668e9 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -21,14 +21,14 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse, - buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_PATH, PublisherResponse, buffer_publisher_response_async, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::build_runtime_services; @@ -117,6 +117,47 @@ fn build_state_with_settings( })) } +async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_runtime_services(&ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +/// Dispatch a reserved request using explicit settings. +/// +/// # Errors +/// +/// Returns an error when the adapter state cannot be built from `settings`. +pub async fn dispatch_reserved_with_settings( + settings: Settings, + req: Request, +) -> Result, Report> { + let state = build_state_with_settings(settings)?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + +/// Dispatch a reserved request using the configured adapter state. +/// +/// # Errors +/// +/// Returns an error when the configured adapter state cannot be built. +pub async fn dispatch_reserved( + req: Request, +) -> Result, Report> { + let state = build_state()?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + // --------------------------------------------------------------------------- // Per-request RuntimeServices // --------------------------------------------------------------------------- @@ -361,7 +402,7 @@ fn build_router(state: &Arc) -> RouterService { { let state = Arc::clone(state); - // Shared fallback dispatch: routes to tsjs (GET only), integration proxy, or publisher. + // Shared fallback dispatch: routes to tsjs (GET/HEAD), integration proxy, or publisher. async fn dispatch( state: Arc, ctx: RequestContext, @@ -376,10 +417,7 @@ fn build_router(state: &Arc) -> RouterService { } let path = req.uri().path().to_owned(); let method = req.method().clone(); - // tsjs assets are served for GET only, matching the Axum/Fastly adapters. - let allow_tsjs = method == Method::GET; - - let result = if allow_tsjs && path.starts_with("/static/tsjs=") { + let result = if path.starts_with("/static/tsjs=") { handle_tsjs_dynamic(&req, &state.registry) } else if state.registry.has_route(&method, &path) { let mut ec_context = EcContext::default(); @@ -474,6 +512,15 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async { Ok::(admin_key_management_not_supported()) }) + // Render-trace toggle: arms/disarms the ts-trace cookie and + // redirects to `/`. Gated by [debug] trace_route_enabled (404 when + // off). + .get( + "/_ts/trace", + make_handler(Arc::clone(&state), |s, _services, req| async move { + handle_trace_mode(&s.settings, req.uri().query()) + }), + ) .post( "/auction", make_handler(Arc::clone(&state), |s, services, req| async move { @@ -534,15 +581,8 @@ fn build_router(state: &Arc) -> RouterService { }), ); - // SPA re-auction endpoint, registered on the canonical path and on the - // deprecated `PAGE_BIDS_LEGACY_PATH` double-underscore alias. The alias - // keeps tsjs bundles served before the `/_ts/page-bids` rename getting - // ads on SPA navigations until they age out of browser caches. - // - // The OPTIONS preflight is denied on both so the GET handler's - // `X-TSJS-Page-Bids` gate stays trustworthy — an alias that let the - // preflight fall through to a permissive origin would reopen exactly - // the cross-site hole the canonical path closes. + // SPA re-auction endpoint. OPTIONS is denied so the GET handler's + // `X-TSJS-Page-Bids` gate stays trustworthy. let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { let ec_context = build_ec_context(&s.settings, &services, &req); let auction = AuctionDispatch { @@ -556,10 +596,8 @@ fn build_router(state: &Arc) -> RouterService { make_handler(Arc::clone(&state), |_s, _services, _req| async move { Ok(page_bids_preflight_denied()) }); - for path in [PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH] { - router = router.route(path, Method::GET, page_bids.clone()); - router = router.route(path, Method::OPTIONS, page_bids_preflight.clone()); - } + router = router.route(PAGE_BIDS_PATH, Method::GET, page_bids); + router = router.route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_preflight); let legacy_admin_deny = make_handler(Arc::clone(&state), |_s, _services, _req| async move { @@ -573,6 +611,9 @@ fn build_router(state: &Arc) -> RouterService { ); router = router.route("/admin/keys/deactivate", method, legacy_admin_deny.clone()); } + for method in publisher_fallback_methods() { + router = router.route("/__ts/page-bids", method, legacy_admin_deny.clone()); + } for method in publisher_fallback_methods() { router = router.route("/", method.clone(), fallback.clone()); diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index 2ce435b17..3ab7d3434 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -15,6 +15,11 @@ pub mod platform; #[cfg(target_arch = "wasm32")] use worker::{Context, Env, Request, Response, Result, event}; +#[cfg(any(target_arch = "wasm32", test))] +fn preserved_reserved_method(value: &str) -> Option { + edgezero_core::http::Method::from_bytes(value.as_bytes()).ok() +} + #[cfg(target_arch = "wasm32")] #[event(fetch)] /// Dispatches an incoming Cloudflare Worker fetch event. @@ -28,6 +33,31 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { app::set_cloudflare_config_json(config.to_string()); } + let is_reserved = req + .url() + .is_ok_and(|url| trusted_server_core::integrations::aps::is_aps_family_path(url.path())); + if is_reserved { + // workers-rs maps unknown methods to GET; the underlying Fetch request + // preserves the original method token, so capture it before conversion. + let method = preserved_reserved_method(&req.inner().method()).ok_or_else(|| { + worker::Error::RustError("reserved APS request method is invalid".to_string()) + })?; + let mut request = edgezero_adapter_cloudflare::request::into_core_request(req, env, ctx) + .await + .map_err(|error| worker::Error::RustError(error.to_string()))?; + *request.method_mut() = method; + let response = app::dispatch_reserved(request) + .await + .map_err(|error| worker::Error::RustError(error.to_string()))? + .ok_or_else(|| { + worker::Error::RustError( + "reserved APS path has no hard-cutover handler".to_string(), + ) + })?; + return edgezero_adapter_cloudflare::response::from_core_response(response) + .map_err(|error| worker::Error::RustError(error.to_string())); + } + match edgezero_adapter_cloudflare::run_app::(req, env, ctx).await { Ok(resp) => Ok(resp), Err(e) => { @@ -36,3 +66,16 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { } } } + +#[cfg(test)] +mod tests { + use super::preserved_reserved_method; + + #[test] + fn reserved_method_parser_preserves_extension_methods() { + let method = preserved_reserved_method("PROPFIND") + .expect("should preserve a syntactically valid extension method"); + + assert_eq!(method.as_str(), "PROPFIND"); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index fff0bfed1..4984b5999 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -20,6 +20,7 @@ use error_stack::ResultExt as _; #[cfg(target_arch = "wasm32")] use trusted_server_core::platform::{ PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, + ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1, }; // --------------------------------------------------------------------------- @@ -204,7 +205,13 @@ struct CloudflarePendingResponse { /// fetch layer; the Workers runtime's global CPU budget (~30 s on paid plans) /// is the only implicit deadline. #[cfg(target_arch = "wasm32")] -pub struct CloudflareHttpClient; +pub struct CloudflareHttpClient { + #[cfg(feature = "aps-runner-proxy-integration-test")] + aps_runner_proxy_test_fetcher: Option, +} + +#[cfg(all(target_arch = "wasm32", feature = "aps-runner-proxy-integration-test"))] +const APS_RUNNER_PROXY_TEST_SERVICE_BINDING: &str = "APS_RUNNER_PROXY_FIXTURE"; /// Maximum buffered upstream response body, mirroring the Fastly adapter's cap. /// @@ -286,6 +293,27 @@ fn outbound_cache_mode(bypass_cache: bool) -> OutboundCacheMode { #[cfg(target_arch = "wasm32")] impl CloudflareHttpClient { + fn new(request_context: &edgezero_core::context::RequestContext) -> Self { + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] + let _ = request_context; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let aps_runner_proxy_test_fetcher = + edgezero_adapter_cloudflare::context::CloudflareRequestContext::get( + request_context.request(), + ) + .and_then(|cloudflare_context| { + cloudflare_context + .env() + .service(APS_RUNNER_PROXY_TEST_SERVICE_BINDING) + .ok() + }); + + Self { + #[cfg(feature = "aps-runner-proxy-integration-test")] + aps_runner_proxy_test_fetcher, + } + } + async fn execute( &self, request: PlatformHttpRequest, @@ -444,6 +472,219 @@ impl CloudflareHttpClient { Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) } + + fn raw_header_evidence(headers: &worker::Headers, name: &str) -> ProxyHeaderEvidenceV1 { + match headers.get(name) { + Ok(Some(value)) => ProxyHeaderEvidenceV1::Combined(value.into_bytes()), + Ok(None) => ProxyHeaderEvidenceV1::absent(), + Err(_) => ProxyHeaderEvidenceV1::Unavailable, + } + } + + fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let value = match evidence { + ProxyHeaderEvidenceV1::Occurrences(values) => { + let [value] = values.as_slice() else { + return None; + }; + value.as_slice() + } + ProxyHeaderEvidenceV1::Combined(value) => value.as_slice(), + ProxyHeaderEvidenceV1::Unavailable => return None, + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() + } + + async fn execute_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + use futures::{FutureExt as _, StreamExt as _, future::Either}; + use worker::{ + AbortController, CacheMode, Fetch, Headers, Method, Request, RequestInit, + RequestRedirect, ResponseBody, + }; + + if request.image_optimizer.is_some() || request.stream_response { + return Err(Report::new(PlatformError::HttpClient) + .attach("unsupported option on Cloudflare raw proxy request")); + } + + let cache_mode = outbound_cache_mode(request.bypass_cache); + let uri = request.request.uri().to_string(); + #[cfg(feature = "aps-runner-proxy-integration-test")] + let use_test_service_binding = { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + uri == APS_RUNNER_UPSTREAM_URL + }; + let method = Method::from(request.request.method().to_string()); + let headers = Headers::new(); + for (name, value) in request.request.headers() { + let value = + std::str::from_utf8(value.as_bytes()).change_context(PlatformError::HttpClient)?; + headers + .append(name.as_str(), value) + .change_context(PlatformError::HttpClient)?; + } + #[cfg(feature = "aps-runner-proxy-integration-test")] + if use_test_service_binding { + headers + .set("x-ts-aps-logical-url", &uri) + .change_context(PlatformError::HttpClient)?; + } + + let (_, body) = request.request.into_parts(); + let body = match body { + edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + edgezero_core::body::Body::Stream(_) => { + return Err(Report::new(PlatformError::HttpClient) + .attach("streaming request bodies are not supported on Cloudflare raw proxy")); + } + }; + let mut init = RequestInit::new(); + init.with_method(method) + .with_headers(headers) + .with_redirect(RequestRedirect::Manual); + if cache_mode == OutboundCacheMode::NoStore { + init.with_cache(CacheMode::NoStore); + } + if !body.is_empty() { + init.with_body(Some(js_sys::Uint8Array::from(body.as_slice()).into())); + } + let worker_request = + Request::new_with_init(&uri, &init).change_context(PlatformError::HttpClient)?; + + let controller = AbortController::default(); + let signal = controller.signal(); + #[cfg(feature = "aps-runner-proxy-integration-test")] + let test_fetcher = if use_test_service_binding { + Some(self.aps_runner_proxy_test_fetcher.clone().ok_or_else(|| { + Report::new(PlatformError::HttpClient) + .attach("APS runner proxy integration service binding is unavailable") + })?) + } else { + None + }; + let operation = async { + let fetch_operation = async { + #[cfg(feature = "aps-runner-proxy-integration-test")] + let response = if let Some(fetcher) = test_fetcher { + let mut bound_request: worker::HttpRequest = worker_request + .try_into() + .change_context(PlatformError::HttpClient)?; + bound_request.extensions_mut().insert(signal.clone()); + let bound_response = fetcher + .fetch_request(bound_request) + .await + .change_context(PlatformError::HttpClient)?; + worker::Response::try_from(bound_response) + .change_context(PlatformError::HttpClient)? + } else { + let fetch = Fetch::Request(worker_request); + fetch + .send_with_signal(&signal) + .await + .change_context(PlatformError::HttpClient)? + }; + #[cfg(not(feature = "aps-runner-proxy-integration-test"))] + let response = { + let fetch = Fetch::Request(worker_request); + fetch + .send_with_signal(&signal) + .await + .change_context(PlatformError::HttpClient)? + }; + Ok::>(response) + } + .boxed_local(); + let first_byte_deadline = worker::Delay::from(policy.first_byte_timeout).boxed_local(); + let mut response = + match futures::future::select(fetch_operation, first_byte_deadline).await { + Either::Left((response, _)) => response?, + Either::Right(((), _)) => { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte deadline exceeded")); + } + }; + let evidence = ProxyResponseEvidenceV1 { + status: response.status_code(), + content_type: Self::raw_header_evidence(response.headers(), "content-type"), + content_encoding: Self::raw_header_evidence(response.headers(), "content-encoding"), + content_length: Self::raw_header_evidence(response.headers(), "content-length"), + }; + if Self::canonical_declared_length(&evidence.content_length) + .is_some_and(|length| length > policy.max_response_bytes) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy declared body exceeds configured cap")); + } + + let mut body = match response.body().clone() { + ResponseBody::Empty => Vec::new(), + ResponseBody::Body(bytes) => bytes, + ResponseBody::Stream(_) => { + let mut stream = response + .stream() + .change_context(PlatformError::HttpClient)?; + let mut body = Vec::new(); + loop { + let read = stream.next().boxed_local(); + let read_deadline = + worker::Delay::from(policy.blocking_read_timeout).boxed_local(); + let chunk = match futures::future::select(read, read_deadline).await { + Either::Left((chunk, _)) => chunk, + Either::Right(((), _)) => { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy blocking-read deadline exceeded")); + } + }; + let Some(chunk) = chunk else { break }; + let chunk = chunk.change_context(PlatformError::HttpClient)?; + let next_len = body.len().checked_add(chunk.len()).ok_or_else(|| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&chunk); + } + body + } + }; + if body.len() > policy.max_response_bytes { + body.clear(); + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy buffered body exceeds configured cap")); + } + Ok(RawProxyResponseV1 { evidence, body }) + } + .boxed_local(); + let deadline = worker::Delay::from(policy.total_timeout).boxed_local(); + + match futures::future::select(operation, deadline).await { + Either::Left((result, _)) => { + if result.is_err() { + controller.abort(); + } + result + } + Either::Right(((), _)) => { + controller.abort(); + Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded")) + } + } + } } #[cfg(target_arch = "wasm32")] @@ -456,6 +697,14 @@ impl PlatformHttpClient for CloudflareHttpClient { self.execute(request).await } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + self.execute_raw_proxy_v1(request, policy).await + } + fn supports_concurrent_fanout(&self) -> bool { // `send_async` executes each request eagerly, so multiple pending // requests run sequentially. The auction orchestrator checks this @@ -602,7 +851,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R let client_ip = extract_client_ip(ctx); #[cfg(target_arch = "wasm32")] - let http_client: Arc = Arc::new(CloudflareHttpClient); + let http_client: Arc = Arc::new(CloudflareHttpClient::new(ctx)); #[cfg(not(target_arch = "wasm32"))] let http_client: Arc = Arc::new(UnavailableHttpClient); diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 09e3ed324..668f86f95 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -21,14 +21,19 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. -fn test_router() -> RouterService { - let settings = Settings::from_toml( +fn test_settings() -> Settings { + Settings::from_toml( r#" [[handlers]] path = "^/_ts/admin" username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -37,11 +42,18 @@ fn test_router() -> RouterService { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [integrations.aps] + enabled = true + account_id = "route-test-aps-account" + allow_script_creatives = true "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") +} - TrustedServerApp::routes_with_settings(settings) +fn test_router() -> RouterService { + TrustedServerApp::routes_with_settings(test_settings()) .expect("should build router from test settings") } @@ -58,6 +70,13 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } +async fn route_reserved(req: Request) -> Response { + trusted_server_adapter_cloudflare::app::dispatch_reserved_with_settings(test_settings(), req) + .await + .expect("should build APS dispatcher") + .expect("APS family should be reserved") +} + fn assert_route_registered(method: &str, path: &str) { let routes = registered_routes(); assert!( @@ -101,6 +120,74 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_cutover_renderer_and_family_failures_are_local() { + let renderer = request_builder() + .method("GET") + .uri("/integrations/aps/renderer/v1") + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS renderer request"); + let response = route_reserved(renderer).await; + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + response.headers()["content-type"], + "text/html; charset=utf-8" + ); + assert_eq!( + response.headers()["cache-control"], + "public, max-age=31536000, immutable" + ); + assert!(!response.headers().contains_key("x-frame-options")); + let body = response.into_body().into_bytes().unwrap_or_default(); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ("POST", "/integrations/aps/runner.js", 405), + ("TRACE", "/integrations/aps/renderer/v1", 405), + ("CONNECT", "/integrations/aps/renderer/v1", 405), + ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("GET", "/integrations/aps/renderer", 404), + ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/runner/v1.js", 404), + ("GET", "/integrations/aps", 404), + ] { + let request = request_builder() + .method(method) + .uri(path) + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS family request"); + let response = route_reserved(request).await; + assert_eq!(response.status().as_u16(), expected, "{method} {path}"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key("x-geo-info-available")); + if expected == 405 { + assert_eq!(response.headers()["allow"], "GET"); + assert_eq!(response.headers().len(), 2, "{method} {path}"); + } else { + assert_eq!(response.headers().len(), 1, "{method} {path}"); + } + assert!( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty() + ); + } + + let protected_control = request_builder() + .method("GET") + .uri("/integrations/apsx") + .body(edgezero_core::body::Body::empty()) + .expect("should build protected non-APS boundary request"); + let response = route(test_router(), protected_control).await; + assert_eq!(response.status().as_u16(), 401); +} + // --------------------------------------------------------------------------- // Middleware regression tests — verify FinalizeResponseMiddleware and // AuthMiddleware are wired so they cannot be removed silently. @@ -203,6 +290,35 @@ async fn tsjs_route_is_routed_not_5xx() { assert!(status < 500, "tsjs route must not 5xx: got {status}"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_wrong_methods_are_local_no_store_404s() { + for method in ["HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] { + let req = request_builder() + .method(method) + .uri(format!( + "/static/tsjs=tsjs-unified.min.js?v={}", + "0".repeat(64) + )) + .body(edgezero_core::body::Body::empty()) + .expect("should build wrong-method TSJS request"); + let response = route(test_router(), req).await; + + assert_eq!(response.status().as_u16(), 404, "method {method}"); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "method {method}" + ); + assert!( + !response.headers().contains_key("location"), + "method {method}" + ); + } +} + /// Verify that every expected explicit route is registered in the route table. /// /// Uses [`RouterService::routes()`] for introspection rather than checking @@ -216,16 +332,9 @@ fn all_explicit_routes_are_registered() { ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), ("POST", "/auction"), - // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both - // paths are spelled out as literals rather than referencing - // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the - // actual URL the tsjs client fetches — asserting a const against itself - // would still pass if the const's value changed out from under the - // client. + // Pin the canonical literal fetched by the hard-cutover client. ("GET", "/_ts/page-bids"), ("OPTIONS", "/_ts/page-bids"), - ("GET", "/__ts/page-bids"), - ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), @@ -237,6 +346,9 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } + for method in LEGACY_ADMIN_DENY_METHODS { + assert_route_registered(method, "/__ts/page-bids"); + } for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { for method in LEGACY_ADMIN_DENY_METHODS { diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml b/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml new file mode 100644 index 000000000..90ec710b0 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml @@ -0,0 +1,16 @@ +name = "trusted-server-aps-runner-proxy-integration" +main = "build/index.js" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat", "cache_option_enabled"] + +[[kv_namespaces]] +binding = "TRUSTED_SERVER_KV" +id = "aps-runner-proxy-local-kv" + +[[services]] +binding = "APS_RUNNER_PROXY_FIXTURE" +service = "aps-runner-proxy-fixture" + +[vars] +# Replaced in a temporary copy by the integration-test controller. +TRUSTED_SERVER_CONFIG = "{}" diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index b6bc0f1a1..78477f714 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -10,6 +10,10 @@ version = { workspace = true } [lints] workspace = true +[features] +default = [] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] + [dependencies] async-trait = { workspace = true } base64 = { workspace = true } @@ -29,6 +33,7 @@ sha2 = { workspace = true } trusted-server-core = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } +web-time = { workspace = true } [dev-dependencies] bytes = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index d6090c983..721880172 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -26,6 +26,7 @@ //! | GET | `/_ts/api/v1/identify` | [`handle_identify`] | //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | //! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] | +//! | GET | `/_ts/trace` | [`handle_trace_mode`] | //! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | //! | POST | `/auction` | [`handle_auction`] | //! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] | @@ -117,9 +118,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, - publisher_response_into_streaming_response, + AuctionDispatch, PAGE_BIDS_PATH, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, publisher_response_into_streaming_response, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -130,6 +130,7 @@ use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ @@ -163,6 +164,25 @@ pub(crate) fn build_state() -> Result, Report> build_state_from_settings(load_settings_from_config_store()?) } +pub(crate) async fn dispatch_reserved_for_state( + state: &Arc, + req: Request, +) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_per_request_services(state, &ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + pub(crate) fn load_settings_from_config_store() -> Result> { let store_name = default_config_store_name(); let config_key = default_config_key(); @@ -277,8 +297,8 @@ fn publisher_fallback_methods() -> [Method; 7] { ] } -fn uses_dynamic_tsjs_fallback(method: &Method, path: &str) -> bool { - *method == Method::GET && path.starts_with("/static/tsjs=") +fn uses_dynamic_tsjs_fallback(_method: &Method, path: &str) -> bool { + path.starts_with("/static/tsjs=") } // --------------------------------------------------------------------------- @@ -596,6 +616,7 @@ async fn run_named_route( } NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::TraceMode => handle_trace_mode(&state.settings, req.uri().query()), NamedRouteHandler::Auction => { // The auction reads consent data, so the consent KV store must be // available — fail closed with 503 when it is configured but @@ -1008,6 +1029,7 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -1089,6 +1111,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -1101,15 +1128,12 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, - // Deprecated double-underscore alias. tsjs bundles served before the - // `/_ts/page-bids` rename keep requesting this path from already-loaded - // pages and browser caches; dropping it would strand SPA navigations - // without ads until those bundles age out. See `PAGE_BIDS_LEGACY_PATH`; - // removal is tracked by IABTechLab/trusted-server#970. + // A removed route must be denied here, before the publisher fallback, so + // its response is always a local unknown-route result rather than an alias. NamedRoute { - path: PAGE_BIDS_LEGACY_PATH, - primary_methods: &[Method::GET, Method::OPTIONS], - handler: NamedRouteHandler::PageBids, + path: "/__ts/page-bids", + primary_methods: LEGACY_ADMIN_DENY_METHODS, + handler: NamedRouteHandler::LegacyAdminDenied, }, NamedRoute { path: "/first-party/proxy", @@ -1237,9 +1261,11 @@ impl Hooks for TrustedServerApp { mod tests { use std::sync::Arc; + #[cfg(feature = "aps-runner-proxy-integration-test")] + use super::dispatch_reserved_for_state; use super::{ - AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, - TrustedServerApp, build_state_from_settings, startup_error_router, + AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_PATH, TrustedServerApp, + build_state_from_settings, startup_error_router, }; use bytes::Bytes; use edgezero_core::body::Body; @@ -1343,6 +1369,11 @@ mod tests { username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -1365,6 +1396,11 @@ mod tests { server_url = "https://test-prebid.com/openrtb2/auction" external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" + [integrations.aps] + enabled = true + account_id = "route-test-aps-account" + allow_script_creatives = true + [auction] enabled = true providers = ["prebid"] @@ -1379,6 +1415,100 @@ mod tests { TrustedServerApp::routes_for_state(&state) } + #[cfg(feature = "aps-runner-proxy-integration-test")] + fn route_reserved(request: edgezero_core::http::Request) -> Response { + let state = build_state_from_settings(test_settings()).expect("should build test state"); + block_on(dispatch_reserved_for_state(&state, request)) + .expect("APS family should be reserved") + } + + #[cfg(feature = "aps-runner-proxy-integration-test")] + #[test] + fn aps_cutover_renderer_and_family_failures_are_local() { + let response = route_reserved(empty_request(Method::GET, "/integrations/aps/renderer/v1")); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + assert!(!response.headers().contains_key("x-frame-options")); + let body = response.into_body().into_bytes().unwrap_or_default(); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ( + Method::POST, + "/integrations/aps/runner.js", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::TRACE, + "/integrations/aps/renderer/v1", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::CONNECT, + "/integrations/aps/renderer/v1", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::from_bytes(b"PROPFIND").expect("PROPFIND should be a valid method"), + "/integrations/aps/renderer/v1", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + Method::GET, + "/integrations/aps/renderer", + StatusCode::NOT_FOUND, + ), + ( + Method::GET, + "/integrations/aps/renderer/v2", + StatusCode::NOT_FOUND, + ), + ( + Method::GET, + "/integrations/aps/runner/v1.js", + StatusCode::NOT_FOUND, + ), + (Method::GET, "/integrations/aps", StatusCode::NOT_FOUND), + ] { + let mut request = empty_request(method.clone(), path); + request.headers_mut().insert( + header::AUTHORIZATION, + "Bearer must-not-reach-publisher" + .parse() + .expect("should parse authorization header"), + ); + let response = route_reserved(request); + assert_eq!(response.status(), expected, "{method} {path}"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert!(!response.headers().contains_key(HEADER_X_GEO_INFO_AVAILABLE)); + if expected == StatusCode::METHOD_NOT_ALLOWED { + assert_eq!(response.headers()[header::ALLOW], "GET"); + } + assert!( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty() + ); + } + + let response = route( + &test_router(), + empty_request(Method::GET, "/integrations/apsx"), + ); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + /// Builds a router whose `AppState` uses a registry containing the given /// request filters (and no routes), so dispatch-level request-filter /// behavior can be exercised without a real integration. @@ -1512,18 +1642,18 @@ mod tests { } #[test] - fn dynamic_tsjs_fallback_is_get_only() { + fn dynamic_tsjs_fallback_rejects_every_wrong_method_locally() { assert!( super::uses_dynamic_tsjs_fallback(&Method::GET, "/static/tsjs=tsjs-unified.js"), "GET should use the dynamic tsjs shortcut" ); assert!( - !super::uses_dynamic_tsjs_fallback(&Method::HEAD, "/static/tsjs=tsjs-unified.js"), - "HEAD should fall through to the publisher/integration fallback" + super::uses_dynamic_tsjs_fallback(&Method::HEAD, "/static/tsjs=tsjs-unified.js"), + "HEAD should use the local TSJS rejection path" ); assert!( - !super::uses_dynamic_tsjs_fallback(&Method::OPTIONS, "/static/tsjs=tsjs-unified.js"), - "OPTIONS should fall through to the publisher/integration fallback" + super::uses_dynamic_tsjs_fallback(&Method::OPTIONS, "/static/tsjs=tsjs-unified.js"), + "OPTIONS should use the local TSJS rejection path" ); } @@ -1652,45 +1782,29 @@ mod tests { } #[test] - fn page_bids_serves_canonical_path_and_deprecated_alias() { - // The SPA re-auction endpoint lives at the canonical single-underscore - // `/_ts/page-bids`, matching every other internal route. The deprecated - // `/__ts/page-bids` alias must stay registered to the same handler with - // the same methods until pre-rename tsjs bundles age out of browser - // caches — dropping it would leave those clients without ads on SPA - // navigations. - // - // The paths are literals, not `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`. - // Looking a route up by the same const it was registered with is - // tautological: it keeps passing if the const's value changes, which is - // exactly the break that would silently desync the server from the tsjs - // client's hardcoded fetch path. Pin the consts to their literals too so - // a rename has to be deliberate. + fn page_bids_keeps_the_canonical_handler_and_denies_the_removed_alias_locally() { + // The hard cutover exposes only the canonical single-underscore page-bids + // handler. The removed path is an explicit local 404, never an alias or + // publisher-fallback route. assert_eq!( PAGE_BIDS_PATH, "/_ts/page-bids", "canonical page-bids path must match the path tsjs fetches" ); - assert_eq!( - PAGE_BIDS_LEGACY_PATH, "/__ts/page-bids", - "legacy alias must match the path pre-rename tsjs bundles fetch" - ); - - for path in ["/_ts/page-bids", "/__ts/page-bids"] { - let route = NAMED_ROUTES - .iter() - .find(|route| route.path == path) - .unwrap_or_else(|| panic!("{path} should be registered")); - - assert!( - matches!(route.handler, NamedRouteHandler::PageBids), - "{path} must map to the page-bids handler" - ); - assert_eq!( - route.primary_methods, - &[Method::GET, Method::OPTIONS], - "{path} must handle GET and OPTIONS directly, not fall through to the publisher" - ); - } + let route = NAMED_ROUTES + .iter() + .find(|route| route.path == "/_ts/page-bids") + .expect("canonical page-bids path should be registered"); + assert!(matches!(route.handler, NamedRouteHandler::PageBids)); + assert_eq!(route.primary_methods, &[Method::GET, Method::OPTIONS]); + let removed = NAMED_ROUTES + .iter() + .find(|route| route.path == "/__ts/page-bids") + .expect("removed page-bids path should be denied locally"); + assert!(matches!( + removed.handler, + NamedRouteHandler::LegacyAdminDenied + )); + assert_eq!(removed.primary_methods, super::LEGACY_ADMIN_DENY_METHODS); } #[test] @@ -1815,6 +1929,55 @@ mod tests { ); } + #[test] + fn dispatch_trace_route_is_disabled_by_default() { + let router = test_router(); + let response = route(&router, empty_request(Method::GET, "/_ts/trace")); + + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "disabled trace route should return 404" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "disabled trace route should not set a cookie" + ); + } + + #[test] + fn dispatch_trace_route_arms_cookie_and_redirects() { + let mut settings = test_settings(); + settings.debug.trace_route_enabled = true; + let state = app_state_for_settings(settings); + let router = TrustedServerApp::routes_for_state(&state); + let response = route(&router, empty_request(Method::GET, "/_ts/trace")); + + assert_eq!( + response.status(), + StatusCode::FOUND, + "enabled trace route should redirect to root" + ); + assert_eq!( + response + .headers() + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()), + Some("/"), + "trace route should redirect to /" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set trace cookie") + .to_str() + .expect("should render set-cookie as utf-8"); + assert!( + set_cookie.starts_with("ts-trace=1;"), + "trace route should arm the ts-trace cookie" + ); + } + #[test] fn dispatch_set_tester_sets_cookie_on_configured_domain() { let mut settings = test_settings(); diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs index f2ff5d9e5..db55aa07b 100644 --- a/crates/trusted-server-adapter-fastly/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -328,10 +328,11 @@ impl<'a> BackendConfig<'a> { /// Ensure a dynamic backend exists for this configuration and return its name. /// - /// The name is a collision-resistant function of the complete backend spec - /// (see `Self::compute_name`), so different specs — for example, different - /// timeout values — always produce different backend registrations and a - /// tight deadline cannot be silently widened by an earlier registration. + /// The backend name is derived from the scheme, host, port, certificate + /// setting, `first_byte_timeout`, and `between_bytes_timeout` to avoid + /// collisions. Different timeout values produce different backend + /// registrations so that a tight deadline cannot be silently widened by an + /// earlier registration. /// /// # Errors /// diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 39d35b198..d5c2c2119 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -167,7 +167,20 @@ fn edgezero_main(mut req: FastlyRequest) { core_req.extensions_mut().insert(config_store); core_req.extensions_mut().insert(device_signals); core_req.extensions_mut().insert(client_info); - match futures::executor::block_on(app.router().oneshot(core_req)) { + let routed = if let Some(state) = app_state + .as_ref() + .filter(|state| state.registry.has_reserved_path(core_req.uri().path())) + { + Ok( + futures::executor::block_on(crate::app::dispatch_reserved_for_state( + state, core_req, + )) + .expect("reserved path should dispatch before RouterService"), + ) + } else { + futures::executor::block_on(app.router().oneshot(core_req)) + }; + match routed { Ok(response) => response, Err(error) => edge_error_response(error), } @@ -186,7 +199,12 @@ fn edgezero_main(mut req: FastlyRequest) { let asset_cache_policy = response.extensions_mut().remove::(); let request_filter_effects = response.extensions_mut().remove::(); - if !take_finalize_sentinel(&mut response) { + let should_finalize = response + .extensions() + .get::() + .is_none() + && !take_finalize_sentinel(&mut response); + if should_finalize { if let Some(settings) = settings_snapshot.as_deref() { apply_entry_point_finalize_headers(settings, &mut response, client_ip); } else { diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 9e7920e1c..423a95a55 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -13,13 +13,18 @@ use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; use crate::backend::BackendConfig; +#[cfg(feature = "aps-runner-proxy-integration-test")] +use trusted_server_core::integrations::aps::{ + APS_RUNNER_BLOCKING_READ_TIMEOUT, APS_RUNNER_FIRST_BYTE_TIMEOUT, APS_RUNNER_UPSTREAM_URL, +}; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, + PlatformSecretStore, PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, + RawProxyPolicyV1, RawProxyResponseV1, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -531,6 +536,31 @@ fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) } } +fn fastly_raw_header_evidence(response: &fastly::Response, name: &str) -> ProxyHeaderEvidenceV1 { + ProxyHeaderEvidenceV1::Occurrences( + response + .get_header_all(name) + .map(|value| value.as_bytes().to_vec()) + .collect(), + ) +} + +fn canonical_fastly_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else { + return None; + }; + let [value] = values.as_slice() else { + return None; + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() +} + /// Fastly implementation of [`PlatformHttpClient`]. /// /// - [`send`](PlatformHttpClient::send) converts the platform request to a @@ -545,6 +575,51 @@ fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) /// `fastly::http::request::select()`. pub struct FastlyPlatformHttpClient; +#[cfg(feature = "aps-runner-proxy-integration-test")] +const APS_RUNNER_PROXY_TEST_BACKEND: &str = "aps_runner_proxy_fixture"; + +#[cfg(feature = "aps-runner-proxy-integration-test")] +fn aps_runner_proxy_test_backend( + policy: RawProxyPolicyV1, +) -> Result> { + if policy.first_byte_timeout != APS_RUNNER_FIRST_BYTE_TIMEOUT + || policy.blocking_read_timeout != APS_RUNNER_BLOCKING_READ_TIMEOUT + { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner raw proxy policy does not match the static fixture timeouts")); + } + let fixture = fastly::Backend::from_name(APS_RUNNER_PROXY_TEST_BACKEND) + .change_context(PlatformError::HttpClient)?; + if !fixture.exists() || fixture.is_ssl() { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend must exist as plain HTTP")); + } + let fixture_host = fixture.get_host(); + let fixture_address = fixture_host.parse::().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend host must be a literal IP address") + })?; + if !fixture_address.is_loopback() { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend host must be loopback")); + } + let logical_url = + url::Url::parse(APS_RUNNER_UPSTREAM_URL).change_context(PlatformError::HttpClient)?; + let logical_host = logical_url.host_str().ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("APS runner logical URL must contain a host") + })?; + if fixture + .get_host_override() + .as_ref() + .and_then(|host| host.to_str().ok()) + != Some(logical_host) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("APS runner fixture backend must preserve the logical host")); + } + Ok(APS_RUNNER_PROXY_TEST_BACKEND.to_string()) +} + #[async_trait::async_trait(?Send)] impl PlatformHttpClient for FastlyPlatformHttpClient { fn supports_streaming_responses(&self) -> bool { @@ -571,6 +646,88 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { fastly_response_to_platform(fastly_resp, backend_name, stream_response, request_is_head) } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + if request.image_optimizer.is_some() || request.stream_response { + return Err(Report::new(PlatformError::HttpClient) + .attach("unsupported option on Fastly raw proxy request")); + } + + let started = web_time::Instant::now(); + if policy.first_byte_timeout > policy.total_timeout { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte timeout exceeds total deadline")); + } + let backend_name = request.backend_name; + let mut fastly_request = edge_request_to_fastly(request.request)?; + #[cfg(feature = "aps-runner-proxy-integration-test")] + let backend_name = { + if fastly_request.get_url_str() == APS_RUNNER_UPSTREAM_URL { + fastly_request.set_header("x-ts-aps-logical-url", APS_RUNNER_UPSTREAM_URL); + aps_runner_proxy_test_backend(policy)? + } else { + backend_name + } + }; + apply_fastly_cache_bypass(&mut fastly_request, request.bypass_cache); + let pending = fastly_request + .send_async(&backend_name) + .change_context(PlatformError::HttpClient)?; + // The backend carries the requested first-byte timeout. Waiting in the + // SDK lets the host suspend the guest instead of guest-side polling. + let mut response = pending.wait().change_context(PlatformError::HttpClient)?; + if started.elapsed() >= policy.total_timeout { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded before response headers")); + } + + let evidence = ProxyResponseEvidenceV1 { + status: response.get_status().as_u16(), + content_type: fastly_raw_header_evidence(&response, "content-type"), + content_encoding: fastly_raw_header_evidence(&response, "content-encoding"), + content_length: fastly_raw_header_evidence(&response, "content-length"), + }; + if canonical_fastly_declared_length(&evidence.content_length) + .is_some_and(|length| length > policy.max_response_bytes) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy declared body exceeds configured cap")); + } + + let mut reader = response.take_body(); + let mut body = Vec::new(); + let mut chunk = [0_u8; 64 * 1024]; + loop { + if started.elapsed() >= policy.total_timeout { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded before blocking body read")); + } + let read = reader + .read(&mut chunk) + .change_context(PlatformError::HttpClient)?; + if started.elapsed() >= policy.total_timeout { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded while reading body")); + } + if read == 0 { + break; + } + let next_len = body.len().checked_add(read).ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&chunk[..read]); + } + + Ok(RawProxyResponseV1 { evidence, body }) + } + async fn send_async( &self, request: PlatformHttpRequest, @@ -760,6 +917,33 @@ mod tests { ); } + #[test] + fn raw_proxy_waits_with_the_sdk_without_sleep_polling() { + let source = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/platform.rs")); + let raw_proxy = source + .split("async fn send_raw_proxy_v1(") + .nth(1) + .and_then(|source| source.split("fn supports_concurrent_fanout(").next()) + .expect("should locate the Fastly raw proxy implementation"); + + assert!( + raw_proxy.contains("pending.wait()"), + "raw proxy should block in the Fastly SDK instead of guest-side polling" + ); + assert!(!raw_proxy.contains("pending.poll()")); + assert!(!raw_proxy.contains("std::thread::sleep")); + assert!( + raw_proxy.contains("policy.total_timeout"), + "raw proxy should preserve the complete policy-owned total timeout" + ); + assert!( + !raw_proxy.contains("call_start_deadline") + && !raw_proxy.contains("reduced deadline") + && !raw_proxy.contains("SAFETY_MARGIN"), + "raw proxy must not reserve time outside the exact transport window" + ); + } + // --- FastlyPlatformBackend::predict_name -------------------------------- #[test] diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 77c4139bc..43ba8741f 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -18,6 +18,7 @@ crate-type = ["cdylib", "rlib"] [features] default = [] spin = ["edgezero-adapter-spin/spin"] +aps-runner-proxy-integration-test = ["trusted-server-core/test-utils"] [dependencies] anyhow = { workspace = true } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 960bafc41..2dfd634ca 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -10,6 +10,8 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +#[cfg(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32"))] +use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -20,14 +22,14 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse, - buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_PATH, PublisherResponse, buffer_publisher_response_async, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware}; use crate::platform::build_runtime_services; @@ -49,11 +51,26 @@ pub struct AppState { /// /// Returns an error when settings, the auction orchestrator, or the integration /// registry fail to initialise. +#[cfg(not(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32")))] fn build_state() -> Result, Report> { let settings = Settings::from_toml(include_str!("../../../trusted-server.example.toml"))?; build_state_with_settings(settings) } +#[cfg(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32"))] +fn build_state() -> Result, Report> { + let envelope = + futures::executor::block_on(spin_sdk::variables::get("v_trusted_x5fserver_x5fconfig")) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to read the Spin APS proxy test app config".to_string(), + }) + .attach(error.to_string()) + })?; + let settings = settings_from_config_blob(&envelope)?; + build_state_with_settings(settings) +} + /// Build the application state from explicit settings. /// /// # Errors @@ -73,6 +90,49 @@ fn build_state_with_settings( })) } +async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { + if !state.registry.has_reserved_path(req.uri().path()) { + return None; + } + let ctx = RequestContext::new(req, edgezero_core::params::PathParams::default()); + let services = build_runtime_services(&ctx); + Some( + state + .registry + .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) + .await + .expect("reserved path should have a hard-cutover handler") + .unwrap_or_else(|report| http_error(&report)), + ) +} + +/// Dispatch a reserved APS request using explicit settings. +/// +/// # Errors +/// +/// Returns an error when the application state cannot be +/// initialized from `settings`. +pub async fn dispatch_reserved_with_settings( + settings: Settings, + req: Request, +) -> Result, Report> { + let state = build_state_with_settings(settings)?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + +/// Dispatch a reserved APS request using startup settings. +/// +/// # Errors +/// +/// Returns an error when startup settings or the application +/// state cannot be initialized. +pub async fn dispatch_reserved( + req: Request, +) -> Result, Report> { + let state = build_state()?; + Ok(dispatch_reserved_for_state(&state, req).await) +} + // --------------------------------------------------------------------------- // Publisher response helper // --------------------------------------------------------------------------- @@ -142,7 +202,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 14] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -150,9 +210,10 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { ("/_ts/admin/keys/deactivate", &[Method::POST]), ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), + ("/_ts/trace", &[Method::GET]), ("/auction", &[Method::POST]), (PAGE_BIDS_PATH, &[Method::GET, Method::OPTIONS]), - (PAGE_BIDS_LEGACY_PATH, &[Method::GET, Method::OPTIONS]), + ("/__ts/page-bids", LEGACY_ADMIN_DENY_METHODS), ("/first-party/proxy", &[Method::GET]), ("/first-party/click", &[Method::GET]), ("/first-party/sign", &[Method::GET, Method::POST]), @@ -551,6 +612,21 @@ fn build_router(state: &Arc) -> RouterService { } }; + // GET /_ts/trace — render-trace toggle: arms/disarms the ts-trace + // cookie and redirects to `/`. Gated by [debug] trace_route_enabled + // (404 when off). + let s = Arc::clone(&state); + let trace_mode_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let req = ctx.into_request(); + Ok::( + handle_trace_mode(&s.settings, req.uri().query()) + .unwrap_or_else(|e| http_error(&e)), + ) + } + }; + // GET /_ts/page-bids — SPA re-auction endpoint. let s = Arc::clone(&state); let page_bids_handler = move |ctx: RequestContext| { @@ -662,9 +738,7 @@ fn build_router(state: &Arc) -> RouterService { let path = req.uri().path().to_owned(); let method = req.method().clone(); - // Dynamic tsjs serving is GET-only; other methods fall through to the - // integration/publisher fallback. - let result = if method == Method::GET && path.starts_with("/static/tsjs=") { + let result = if path.starts_with("/static/tsjs=") { handle_tsjs_dynamic(&req, &state.registry) } else if state.registry.has_route(&method, &path) { let mut ec_context = EcContext::default(); @@ -758,19 +832,10 @@ fn build_router(state: &Arc) -> RouterService { // credentials and key-management payloads to the origin. .post("/_ts/admin/keys/rotate", admin_not_supported_handler) .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) + .get("/_ts/trace", trace_mode_handler) .post("/auction", auction_handler) - .get(PAGE_BIDS_PATH, page_bids_handler.clone()) + .get(PAGE_BIDS_PATH, page_bids_handler) .route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler) - // Deprecated double-underscore alias, kept so tsjs bundles served - // before the `/_ts/page-bids` rename keep getting ads on SPA - // navigations until they age out of browser caches. See - // `PAGE_BIDS_LEGACY_PATH`. - .get(PAGE_BIDS_LEGACY_PATH, page_bids_handler) - .route( - PAGE_BIDS_LEGACY_PATH, - Method::OPTIONS, - page_bids_options_handler, - ) .get("/first-party/proxy", fp_proxy_handler) .get("/first-party/click", fp_click_handler) .get("/first-party/sign", fp_sign_handler) @@ -781,6 +846,7 @@ fn build_router(state: &Arc) -> RouterService { for method in LEGACY_ADMIN_DENY_METHODS { builder = builder.route("/admin/keys/rotate", method.clone(), legacy_admin_deny); builder = builder.route("/admin/keys/deactivate", method.clone(), legacy_admin_deny); + builder = builder.route("/__ts/page-bids", method.clone(), legacy_admin_deny); } // Mirror the Fastly/Axum publisher fallback: every supported method that is diff --git a/crates/trusted-server-adapter-spin/src/lib.rs b/crates/trusted-server-adapter-spin/src/lib.rs index f47877ff2..5a6b20bc1 100644 --- a/crates/trusted-server-adapter-spin/src/lib.rs +++ b/crates/trusted-server-adapter-spin/src/lib.rs @@ -13,5 +13,15 @@ use spin_sdk::http_service; #[http_service] // FORCED: edgezero_adapter_spin::run_app returns anyhow::Result — EdgeZero SDK constraint, not a project choice. async fn handle(req: Request) -> anyhow::Result { + if trusted_server_core::integrations::aps::is_aps_family_path(req.uri().path()) { + let request = edgezero_adapter_spin::request::into_core_request(req).await?; + let response = app::dispatch_reserved(request) + .await + .map_err(|error| anyhow::anyhow!("{error:?}"))? + .expect("reserved APS path should dispatch before RouterService"); + return edgezero_adapter_spin::response::from_core_response(response) + .await + .map_err(Into::into); + } edgezero_adapter_spin::run_app::(req).await } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 492f1a518..e5b5f2daf 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -25,7 +25,8 @@ use std::io::Read as _; use trusted_server_core::platform::PlatformHttpRequest; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use trusted_server_core::platform::{ - PlatformPendingRequest, PlatformResponse, PlatformSelectResult, + PlatformPendingRequest, PlatformResponse, PlatformSelectResult, ProxyHeaderEvidenceV1, + ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1, }; // 8 MiB ceiling: conservative for ad-server responses while leaving headroom in @@ -472,8 +473,56 @@ struct SpinPendingResponse { #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub struct SpinPlatformHttpClient; +#[cfg(all( + feature = "aps-runner-proxy-integration-test", + any(test, all(feature = "spin", target_arch = "wasm32")) +))] +fn aps_runner_proxy_transport_uri( + logical_uri: &str, + endpoint: &str, +) -> Result, Report> { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + if logical_uri != APS_RUNNER_UPSTREAM_URL { + return Ok(None); + } + let parsed: edgezero_core::http::Uri = endpoint.parse().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("invalid APS runner proxy integration fixture endpoint") + })?; + if parsed.scheme_str() != Some("http") + || !matches!(parsed.host(), Some("127.0.0.1" | "::1")) + || parsed.port_u16().is_none() + || parsed.path().is_empty() + || parsed.query().is_some() + { + return Err(Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration fixture endpoint must be an explicit loopback HTTP URL", + )); + } + Ok(Some(endpoint.to_owned())) +} + #[cfg(all(feature = "spin", target_arch = "wasm32"))] impl SpinPlatformHttpClient { + #[cfg(all( + feature = "aps-runner-proxy-integration-test", + feature = "spin", + target_arch = "wasm32" + ))] + async fn aps_runner_proxy_test_transport_uri( + logical_uri: &str, + ) -> Result, Report> { + let endpoint = spin_sdk::variables::get("aps_runner_proxy_test_endpoint") + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient).attach( + "APS runner proxy integration artifact requires its loopback fixture endpoint", + ) + })?; + aps_runner_proxy_transport_uri(logical_uri, &endpoint) + } + async fn execute( &self, request: PlatformHttpRequest, @@ -559,6 +608,173 @@ impl SpinPlatformHttpClient { Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) } + + fn raw_header_evidence( + headers: &spin_sdk::wasip3::http::types::Headers, + name: &str, + ) -> ProxyHeaderEvidenceV1 { + ProxyHeaderEvidenceV1::Occurrences(headers.get(name)) + } + + fn canonical_declared_length(evidence: &ProxyHeaderEvidenceV1) -> Option { + let ProxyHeaderEvidenceV1::Occurrences(values) = evidence else { + return None; + }; + let [value] = values.as_slice() else { + return None; + }; + if value.is_empty() + || !value.iter().all(u8::is_ascii_digit) + || (value.len() > 1 && value[0] == b'0') + { + return None; + } + std::str::from_utf8(value).ok()?.parse().ok() + } + + async fn execute_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + use futures::{FutureExt as _, future::Either}; + use spin_sdk::http::IntoRequest as _; + use spin_sdk::wasip3::http::types::RequestOptions; + use spin_sdk::wasip3::http_compat::{IncomingResponseBody, RequestOptionsExtension}; + + reject_unsupported_request_contracts(&request)?; + let method = request.request.method().clone(); + let logical_uri = request.request.uri().to_string(); + #[cfg(feature = "aps-runner-proxy-integration-test")] + let transport_uri = Self::aps_runner_proxy_test_transport_uri(&logical_uri).await?; + let mut builder = spin_sdk::http::Request::builder() + .method(into_spin_method(&method)) + .uri(&logical_uri); + for (name, value) in request.request.headers() { + if is_wasi_forbidden_outbound_header(name.as_str()) { + continue; + } + builder = builder.header(name.as_str(), value.as_bytes()); + } + #[cfg(feature = "aps-runner-proxy-integration-test")] + if transport_uri.is_some() { + builder = builder.header("x-ts-aps-logical-url", logical_uri); + } + + let (_, request_body) = request.request.into_parts(); + let request_body = match request_body { + edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + edgezero_core::body::Body::Stream(_) => { + return Err(Report::new(PlatformError::HttpClient) + .attach("streaming request bodies are not supported on Spin raw proxy")); + } + }; + let mut spin_request = builder + .body(spin_sdk::http::FullBody::new(Bytes::from(request_body))) + .map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to build Spin raw proxy request: {error}")) + })?; + + // Spin/Wasmtime owns the wire `Host` header and forbids guests from + // setting it. Keep the fixed APS URL through the core→adapter contract, + // then apply the loopback-only integration target at the final lowering + // boundary. Production builds have no transport override constructor. + #[cfg(feature = "aps-runner-proxy-integration-test")] + if let Some(transport_uri) = transport_uri { + *spin_request.uri_mut() = transport_uri.parse().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("failed to lower APS loopback transport URI") + })?; + } + + let timeout_nanos = policy.total_timeout.as_nanos().try_into().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy timeout exceeds WASI HTTP duration range") + })?; + let options = RequestOptions::new(); + options + .set_connect_timeout(Some(timeout_nanos)) + .map_err(|_| { + Report::new(PlatformError::Unsupported) + .attach("Spin raw proxy connect timeout is unavailable") + })?; + options + .set_first_byte_timeout(Some(timeout_nanos)) + .map_err(|_| { + Report::new(PlatformError::Unsupported) + .attach("Spin raw proxy first-byte timeout is unavailable") + })?; + options + .set_between_bytes_timeout(Some(timeout_nanos)) + .map_err(|_| { + Report::new(PlatformError::Unsupported) + .attach("Spin raw proxy between-bytes timeout is unavailable") + })?; + spin_request + .extensions_mut() + .insert(RequestOptionsExtension(options)); + let wasi_request = spin_request.into_request().map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to lower Spin raw proxy request: {error}")) + })?; + + let operation = async move { + let response = spin_sdk::wasip3::http::client::send(wasi_request) + .await + .map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("Spin raw proxy request failed: {error}")) + })?; + let status = response.get_status_code(); + let headers = response.get_headers(); + let evidence = ProxyResponseEvidenceV1 { + status, + content_type: Self::raw_header_evidence(&headers, "content-type"), + content_encoding: Self::raw_header_evidence(&headers, "content-encoding"), + content_length: Self::raw_header_evidence(&headers, "content-length"), + }; + if Self::canonical_declared_length(&evidence.content_length) + .is_some_and(|length| length > policy.max_response_bytes) + { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy declared body exceeds configured cap")); + } + + let mut incoming = IncomingResponseBody::new(response).map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to open Spin raw proxy body: {error}")) + })?; + let mut body = Vec::new(); + while let Some(frame) = incoming.frame().await { + let frame = frame.map_err(|error| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to read Spin raw proxy body: {error}")) + })?; + let Ok(data) = frame.into_data() else { + continue; + }; + let next_len = body.len().checked_add(data.len()).ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow") + })?; + if next_len > policy.max_response_bytes { + return Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy body exceeds configured cap")); + } + body.extend_from_slice(&data); + } + Ok(RawProxyResponseV1 { evidence, body }) + } + .boxed_local(); + let deadline = spin_sdk::time::sleep(policy.total_timeout).boxed_local(); + match futures::future::select(operation, deadline).await { + Either::Left((result, _)) => result, + Either::Right(((), _)) => { + Err(Report::new(PlatformError::HttpClient) + .attach("raw proxy total deadline exceeded")) + } + } + } } #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -578,6 +794,14 @@ impl PlatformHttpClient for SpinPlatformHttpClient { self.execute(request).await } + async fn send_raw_proxy_v1( + &self, + request: PlatformHttpRequest, + policy: RawProxyPolicyV1, + ) -> Result> { + self.execute_raw_proxy_v1(request, policy).await + } + async fn send_async( &self, request: PlatformHttpRequest, @@ -801,6 +1025,22 @@ mod tests { use flate2::write::GzEncoder; use std::io::Write as _; + #[cfg(feature = "aps-runner-proxy-integration-test")] + #[test] + fn aps_test_transport_mapping_preserves_logical_authority_until_lowering() { + use trusted_server_core::integrations::aps::APS_RUNNER_UPSTREAM_URL; + + let endpoint = "http://127.0.0.1:49152/prebid-creative.js"; + let transport = aps_runner_proxy_transport_uri(APS_RUNNER_UPSTREAM_URL, endpoint) + .expect("loopback integration endpoint should be accepted") + .expect("fixed APS URL should select the integration transport"); + assert_eq!(transport.to_string(), endpoint); + let logical: edgezero_core::http::Uri = APS_RUNNER_UPSTREAM_URL + .parse() + .expect("fixed APS URL should parse"); + assert_eq!(logical.host(), Some("client.aps.amazon-adsystem.com")); + } + fn make_ctx_without_spin_context() -> RequestContext { let req = request_builder() .method("GET") diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 2f7b1037e..7fb15c135 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -20,14 +20,19 @@ use trusted_server_core::settings::Settings; /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. -fn test_router() -> RouterService { - let settings = Settings::from_toml( +fn test_settings() -> Settings { + Settings::from_toml( r#" [[handlers]] path = "^/_ts/admin" username = "admin" password = "admin-pass" + [[handlers]] + path = "^/integrations/aps" + username = "aps-user" + password = "aps-pass" + [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -36,11 +41,18 @@ fn test_router() -> RouterService { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [integrations.aps] + enabled = true + account_id = "route-test-aps-account" + allow_script_creatives = true "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") +} - TrustedServerApp::routes_with_settings(settings) +fn test_router() -> RouterService { + TrustedServerApp::routes_with_settings(test_settings()) .expect("should build router from test settings") } @@ -48,6 +60,13 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } +async fn route_reserved(req: Request) -> Response { + trusted_server_adapter_spin::app::dispatch_reserved_with_settings(test_settings(), req) + .await + .expect("should build APS dispatcher") + .expect("APS family should be reserved") +} + #[test] fn routes_build_without_panic() { // build_state() may fail (no real settings in CI) — startup_error_router @@ -55,6 +74,74 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn aps_cutover_renderer_and_family_failures_are_local() { + let renderer = request_builder() + .method("GET") + .uri("/integrations/aps/renderer/v1") + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS renderer request"); + let response = route_reserved(renderer).await; + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + response.headers()["content-type"], + "text/html; charset=utf-8" + ); + assert_eq!( + response.headers()["cache-control"], + "public, max-age=31536000, immutable" + ); + assert!(!response.headers().contains_key("x-frame-options")); + let body = response.into_body().into_bytes().unwrap_or_default(); + let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(!body.contains("client.aps.amazon-adsystem.com")); + + for (method, path, expected) in [ + ("POST", "/integrations/aps/runner.js", 405), + ("TRACE", "/integrations/aps/renderer/v1", 405), + ("CONNECT", "/integrations/aps/renderer/v1", 405), + ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("GET", "/integrations/aps/renderer", 404), + ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/runner/v1.js", 404), + ("GET", "/integrations/aps", 404), + ] { + let request = request_builder() + .method(method) + .uri(path) + .header("authorization", "Bearer must-not-reach-publisher") + .body(edgezero_core::body::Body::empty()) + .expect("should build APS family request"); + let response = route_reserved(request).await; + assert_eq!(response.status().as_u16(), expected, "{method} {path}"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key("x-geo-info-available")); + if expected == 405 { + assert_eq!(response.headers()["allow"], "GET"); + assert_eq!(response.headers().len(), 2, "{method} {path}"); + } else { + assert_eq!(response.headers().len(), 1, "{method} {path}"); + } + assert!( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty() + ); + } + + let protected_control = request_builder() + .method("GET") + .uri("/integrations/apsx") + .body(edgezero_core::body::Body::empty()) + .expect("should build protected non-APS boundary request"); + let response = route(test_router(), protected_control).await; + assert_eq!(response.status().as_u16(), 401); +} + #[test] fn edgezero_manifest_loads_and_resolves_spin_stores() { let loader = edgezero_core::manifest::ManifestLoader::load_from_str(include_str!( @@ -209,6 +296,35 @@ async fn tsjs_route_is_routed_not_5xx() { assert!(status < 500, "tsjs route must not 5xx: got {status}"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_wrong_methods_are_local_no_store_404s() { + for method in ["HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"] { + let req = request_builder() + .method(method) + .uri(format!( + "/static/tsjs=tsjs-unified.min.js?v={}", + "0".repeat(64) + )) + .body(edgezero_core::body::Body::empty()) + .expect("should build wrong-method TSJS request"); + let response = route(test_router(), req).await; + + assert_eq!(response.status().as_u16(), 404, "method {method}"); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "method {method}" + ); + assert!( + !response.headers().contains_key("location"), + "method {method}" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn verify_signature_is_routed() { let router = test_router(); @@ -330,54 +446,30 @@ async fn auction_is_routed() { assert_ne!(resp.status().as_u16(), 404, "/auction must be routed"); } -/// `GET` on the SPA re-auction endpoint must reach the page-bids handler on -/// both the canonical path and its deprecated `/__ts/` alias. -/// -/// The alias is what pre-rename tsjs bundles still request, and on a SPA that -/// path is what delivers ads for in-session navigations — so a dropped or -/// misspelled registration silently costs revenue rather than erroring loudly. -/// Spin registers `GET` and `OPTIONS` separately, so the preflight-denial parity -/// test does not imply the `GET` side is wired. -/// -/// Paths are literals rather than `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`: -/// this pins the actual URL the client fetches, which asserting a const against -/// itself would not. -/// -/// These test settings configure no creative opportunities, so the handler's own -/// deterministic answer is a 404 `Creative opportunities not configured`. That -/// body is the anchor: an unregistered path would instead fall through to the -/// publisher fallback and attempt an outbound fetch to the (nonexistent) test -/// origin, which cannot produce this message. A bare `!= 404` check would be -/// wrong here — the handler legitimately returns 404 under this config. +/// The canonical SPA re-auction path reaches page-bids, while the removed +/// double-underscore alias is denied locally with 404. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn page_bids_get_is_routed_on_canonical_path_and_alias() { - let mut responses = Vec::new(); - - for path in ["/_ts/page-bids", "/__ts/page-bids"] { - let req = request_builder() - .method("GET") - .uri(path) - .header("sec-fetch-site", "same-origin") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let resp = route(test_router(), req).await; - let status = resp.status().as_u16(); - let body = String::from_utf8_lossy(&resp.into_body().into_bytes().unwrap_or_default()) +async fn page_bids_get_is_routed_only_on_the_canonical_path() { + let canonical = request_builder() + .method("GET") + .uri("/_ts/page-bids") + .header("sec-fetch-site", "same-origin") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let canonical = route(test_router(), canonical).await; + let canonical_body = + String::from_utf8_lossy(&canonical.into_body().into_bytes().unwrap_or_default()) .into_owned(); + assert!(canonical_body.contains("Creative opportunities not configured")); - assert!( - body.contains("Creative opportunities not configured"), - "GET {path} must reach the page-bids handler, \ - got status {status} body {body:?}" - ); - - responses.push((status, body)); - } - - assert_eq!( - responses[0], responses[1], - "the deprecated alias must answer identically to the canonical path" - ); + let former_alias = request_builder() + .method("GET") + .uri("/__ts/page-bids") + .header("sec-fetch-site", "same-origin") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let former_alias = route(test_router(), former_alias).await; + assert_eq!(former_alias.status().as_u16(), 404); } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 7c1303dd4..dd78a1538 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -13,6 +13,7 @@ fn make_config() -> HtmlProcessorConfig { ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, } } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index c4af6fd3d..d0c498aa8 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1,6 +1,6 @@ //! HTTP endpoint handlers for auction requests. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; @@ -19,20 +19,21 @@ use crate::ec::log_id; use crate::ec::prebid_eids::parse_prebid_eids_cookie; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; +use crate::http_util::RequestInfo; use crate::openrtb::{Eid, Uid}; use crate::platform::RuntimeServices; use crate::settings::Settings; use super::AuctionOrchestrator; -use super::formats::{ - convert_to_openrtb_response, convert_to_openrtb_response_with_report, - convert_tsjs_to_auction_request, -}; +use super::formats::{attach_auction_response_headers, convert_tsjs_to_auction_request}; use super::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, emit_auction_events_best_effort_lazy, }; -use super::types::AuctionContext; +use super::types::{ + AuctionContext, AuctionDecisionSetV1, AuctionRequest, AuctionSlotFailureReason, + SlotAuctionDecisionV1, SystemAuctionIdentityGenerator, +}; const MAX_CLIENT_EID_SOURCES: usize = 64; const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32; @@ -44,6 +45,66 @@ const MAX_CLIENT_EID_SOURCE_BYTES: usize = 255; /// arbitrary WASM linear memory. const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; +struct ExactAuctionResponseV1 { + response: Response, + delivered_winner_slots: HashSet, + dropped_winner_count: usize, +} + +fn exact_auction_response_v1( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + request_origin: &str, + ec_allowed: bool, +) -> Result> { + let price_granularity = settings + .creative_opportunities + .as_ref() + .map(|config| config.price_granularity) + .unwrap_or_default(); + let canonical = crate::publisher::coordinated_cutover_v1::build_browser_auction_projection_v1( + result, + price_granularity, + settings, + request_origin, + None, + &SystemAuctionIdentityGenerator, + )?; + let body = crate::auction::formats::coordinated_cutover_v1::serialize_trusted_server_auction_response_v1( + &canonical, + )?; + let delivered_winner_slots: HashSet = canonical + .projection + .auction + .results + .iter() + .filter_map(|decision| match decision { + SlotAuctionDecisionV1::Winner { slot, .. } => Some(slot.clone()), + _ => None, + }) + .collect(); + let projected_winner_count = result + .decision_set + .results + .iter() + .filter(|decision| matches!(decision, SlotAuctionDecisionV1::Winner { .. })) + .count(); + let mut response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(body)) + .change_context(TrustedServerError::Auction { + message: "Failed to build exact auction response".to_string(), + })?; + attach_auction_response_headers(&mut response, auction_request, ec_allowed)?; + Ok(ExactAuctionResponseV1 { + response, + dropped_winner_count: projected_winner_count.saturating_sub(delivered_winner_slots.len()), + delivered_winner_slots, + }) +} + /// Handle auction request from `POST /auction`. /// /// Accepts a JSON body matching [`AdRequest`][`super::formats::AdRequest`]. @@ -167,6 +228,22 @@ pub async fn handle_auction( ); let http_req = Request::from_parts(parts, EdgeBody::empty()); + let request_info = RequestInfo::from_request(&http_req, services.client_info()); + let request_scheme = if request_info.scheme.is_empty() { + http_req.uri().scheme_str().unwrap_or("https") + } else { + &request_info.scheme + }; + let request_host = if request_info.host.is_empty() { + http_req + .uri() + .authority() + .map(http::uri::Authority::as_str) + .unwrap_or(&settings.publisher.domain) + } else { + &request_info.host + }; + let request_origin = format!("{request_scheme}://{request_host}"); // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. @@ -220,15 +297,21 @@ pub async fn handle_auction( provider_responses: Vec::new(), mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1::failed( + &auction_request, + AuctionSlotFailureReason::ConsentDenied, + ), total_time_ms: 0, metadata: HashMap::new(), }; - return convert_to_openrtb_response( + return Ok(exact_auction_response_v1( &empty_result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), - ); + )? + .response); } // Parse client-provided EIDs from the current request body. When the @@ -325,10 +408,11 @@ pub async fn handle_auction( } }; - let conversion = match convert_to_openrtb_response_with_report( + let conversion = match exact_auction_response_v1( &result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), ) { Ok(conversion) => conversion, @@ -356,7 +440,7 @@ pub async fn handle_auction( AuctionTerminalOutcome::Completed { request: &auction_request, result: &result, - delivered_winner_slots: Some(&conversion.delivery.delivered_winner_slots), + delivered_winner_slots: Some(&conversion.delivered_winner_slots), }, ) }) @@ -365,8 +449,8 @@ pub async fn handle_auction( log::info!( "Auction completed: {} providers, {} delivered winning bids, {} dropped winners, {}ms total", result.provider_responses.len(), - conversion.delivery.delivered_winner_slots.len(), - conversion.delivery.dropped_winner_count, + conversion.delivered_winner_slots.len(), + conversion.dropped_winner_count, result.total_time_ms ); @@ -740,6 +824,20 @@ mod tests { seatbid_empty, "gated auction must return no bids, got: {parsed}" ); + assert_eq!(parsed["cur"], "USD"); + assert_eq!( + parsed["ext"]["trusted_server"]["slot_results"]["results"][0], + json!({ + "slot": "div-gpt-ad-1", + "outcome": "failed", + "reason": "consent_denied" + }), + "the production endpoint must emit the exact decision-set extension" + ); + assert!( + parsed["ext"].get("orchestrator").is_none(), + "the removed legacy response extension must not survive the hard cutover" + ); let batches = telemetry_sink .batches diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index e09912aab..80d99f7db 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -7,7 +7,7 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt, ensure}; use http::{HeaderValue, Request, Response, StatusCode, header}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::{BTreeMap, HashMap, HashSet}; use url::Url; @@ -29,8 +29,12 @@ use crate::settings::Settings; use super::orchestrator::OrchestrationResult; use super::types::{ - AdFormat, AdSlot, AuctionRequest, BidRenderer, DeviceInfo, MediaType, OrchestratorExt, - ProviderSummary, PublisherInfo, SiteInfo, UserInfo, + AdFormat, AdSlot, AuctionDecisionSetV1, AuctionDropReason, AuctionDropReasons, AuctionRequest, + AuctionSlotFailureReason, BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, DeviceInfo, MAX_BROWSER_AUCTION_PROJECTION_BYTES, + MAX_BROWSER_AUCTION_RESULTS, MAX_BROWSER_AUCTION_TARGETING_ENTRIES, MediaType, OrchestratorExt, + ProviderSummary, PublisherInfo, RENDER_DIMENSION_MAX, RENDER_DIMENSION_MIN, SiteInfo, + SlotAuctionDecisionV1, UserInfo, classify_aps_renderer_v1, record_auction_drop, }; /// Request body for `POST /auction` (tsjs / Prebid.js wire format). @@ -281,6 +285,499 @@ pub fn convert_tsjs_to_auction_request( }) } +/// Attach the consent/EID headers shared by every `/auction` response wire. +pub(crate) fn attach_auction_response_headers( + response: &mut Response, + auction_request: &AuctionRequest, + ec_allowed: bool, +) -> Result<(), Report> { + if ec_allowed { + response + .headers_mut() + .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok")); + } + + if let Some(ref eids) = auction_request.user.eids { + let (encoded, truncated) = encode_eids_header(eids)?; + let header_val = + HeaderValue::from_str(&encoded).change_context(TrustedServerError::Auction { + message: "Failed to encode EIDs header value".to_string(), + })?; + response.headers_mut().insert(HEADER_X_TS_EIDS, header_val); + if truncated { + response + .headers_mut() + .insert(HEADER_X_TS_EIDS_TRUNCATED, HeaderValue::from_static("true")); + } + } + + Ok(()) +} + +#[allow( + dead_code, + reason = "pure coordinated-cutover contract is exercised directly until Task 19 wires endpoints" +)] +pub(crate) mod coordinated_cutover_v1 { + use super::*; + + /// Validated projection plus its exact canonical UTF-8 representation. + #[derive(Debug, Clone)] + pub(crate) struct CanonicalBrowserAuctionProjectionV1 { + /// Deep-owned, validated projection in canonical result/bid/targeting order. + pub projection: BrowserAuctionProjectionV1, + /// Whitespace-free JSON using schema field order. + pub json: Vec, + /// Whether the exact aggregate overflow rule replaced every winner. + pub reduced_for_size: bool, + } + + fn projection_contract_error(message: impl Into) -> Report { + Report::new(TrustedServerError::Auction { + message: message.into(), + }) + } + + fn is_base64url_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') + } + + fn valid_auction_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') + }) + } + + fn valid_candidate_id(value: &str) -> bool { + value.len() == 12 && value.bytes().all(is_base64url_byte) + } + + fn valid_renderer_reservation_id(value: &str) -> bool { + value + .strip_prefix("r1_") + .is_some_and(|token| token.len() == 22 && token.bytes().all(is_base64url_byte)) + } + + fn valid_provider_name(value: &str) -> bool { + let bytes = value.as_bytes(); + (1..=64).contains(&bytes.len()) + && bytes[0].is_ascii_alphanumeric() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b'-')) + } + + fn valid_bounded_text(value: &str, maximum_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= maximum_bytes + && !value + .chars() + .any(|character| matches!(character, '\0'..='\u{1f}' | '\u{7f}')) + } + + fn valid_targeting_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= 20 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + } + + fn valid_targeting(targeting: &BTreeMap) -> bool { + targeting.len() <= MAX_BROWSER_AUCTION_TARGETING_ENTRIES + && targeting.iter().all(|(key, value)| { + key != "hb_adid" + && valid_targeting_key(key) + && valid_bounded_text(value, 160) + && value.chars().count() <= 40 + }) + } + + fn valid_render_dimension(value: u32) -> bool { + (RENDER_DIMENSION_MIN..=RENDER_DIMENSION_MAX).contains(&u64::from(value)) + } + + fn render_source_dimensions(source: &BidRenderSourceV1) -> (u32, u32) { + match source { + BidRenderSourceV1::Aps(source) => (source.width, source.height), + BidRenderSourceV1::Adm(source) => (source.width, source.height), + BidRenderSourceV1::PbsCache(source) => (source.width, source.height), + } + } + + fn valid_render_source(source: &BidRenderSourceV1, publisher_origin: &str) -> bool { + match source { + BidRenderSourceV1::Aps(source) => { + valid_render_dimension(source.width) + && valid_render_dimension(source.height) + && source.version == 1 + && serde_json::to_value(BidRenderSourceV1::Aps(source.clone())).is_ok_and( + |value| { + classify_aps_renderer_v1(&value, publisher_origin) + == crate::auction::types::ApsRendererValidationResult::Accepted + }, + ) + } + BidRenderSourceV1::Adm(source) => { + valid_render_dimension(source.width) + && valid_render_dimension(source.height) + && source.version == 1 + && !source.adm.is_empty() + && source.adm.len() <= 512 * 1024 + } + BidRenderSourceV1::PbsCache(source) => { + source.version == 1 + && !source.cache_id.is_empty() + && !source.cache_host.is_empty() + && !source.cache_path.is_empty() + } + } + } + + fn valid_browser_bid(bid: &BrowserAuctionBidV1, publisher_origin: &str) -> bool { + valid_candidate_id(&bid.candidate_id) + && valid_bounded_text(&bid.slot, 256) + && valid_provider_name(&bid.provider) + && valid_bounded_text(&bid.upstream_bid_id, 64) + && bid.cpm.is_finite() + && bid.cpm >= 0.0 + && bid.currency == "USD" + && valid_targeting(&bid.targeting) + && valid_render_source(&bid.render_source, publisher_origin) + && match &bid.render_source { + BidRenderSourceV1::Aps(_) | BidRenderSourceV1::Adm(_) => bid + .renderer_reservation_id + .as_deref() + .is_some_and(valid_renderer_reservation_id), + BidRenderSourceV1::PbsCache(_) => bid.renderer_reservation_id.is_none(), + } + } + + fn valid_browser_slot(slot: &BrowserAuctionSlotV1) -> bool { + valid_bounded_text(&slot.slot, 256) + && valid_bounded_text(&slot.gam_unit_path, 256) + && valid_bounded_text(&slot.div_id, 256) + && !slot.formats.is_empty() + && slot.formats.len() <= 64 + && slot.formats.iter().all(|[width, height]| { + valid_render_dimension(*width) && valid_render_dimension(*height) + }) + && valid_targeting(&slot.targeting) + } + + fn validate_decision_set( + decision_set: &AuctionDecisionSetV1, + ) -> Result<(), Report> { + ensure!( + decision_set.version == 1, + projection_contract_error("Browser auction decision version must be 1") + ); + ensure!( + valid_auction_id(&decision_set.auction_id), + projection_contract_error("Browser auction id violates the version-1 grammar") + ); + ensure!( + decision_set.results.len() <= MAX_BROWSER_AUCTION_RESULTS, + projection_contract_error("Browser auction result count exceeds 256") + ); + + let mut slots = HashSet::new(); + let mut candidates = HashSet::new(); + for result in &decision_set.results { + ensure!( + valid_bounded_text(result.slot(), 256) && slots.insert(result.slot()), + projection_contract_error("Browser auction result slots must be valid and unique") + ); + if let SlotAuctionDecisionV1::Winner { candidate_id, .. } = result { + ensure!( + valid_candidate_id(candidate_id) && candidates.insert(candidate_id), + projection_contract_error( + "Browser auction winner candidates must be valid and unique" + ) + ); + } + } + Ok(()) + } + + /// Validate, reorder, and canonically serialize a complete browser auction projection. + /// + /// Winner-local projection failures become `winner_not_renderable`. Aggregate + /// overflow applies the contract's all-winners reduction; it never selects a + /// response-order-dependent subset. + pub(crate) fn canonicalize_browser_auction_projection_v1( + input: BrowserAuctionProjectionV1, + publisher_origin: &str, + ) -> Result> { + ensure!( + input.version == 1, + projection_contract_error("Browser auction projection version must be 1") + ); + validate_decision_set(&input.auction)?; + ensure!( + input.slots.len() <= MAX_BROWSER_AUCTION_RESULTS, + projection_contract_error("Browser auction slot count exceeds 256") + ); + if !input.slots.is_empty() { + ensure!( + input.slots.len() == input.auction.results.len(), + projection_contract_error( + "Browser auction slots must cover every decision or be empty for direct serialization" + ) + ); + let mut slot_ids = HashSet::with_capacity(input.slots.len()); + for (index, slot) in input.slots.iter().enumerate() { + ensure!( + valid_browser_slot(slot) + && slot_ids.insert(slot.slot.as_str()) + && input.auction.results[index].slot() == slot.slot, + projection_contract_error( + "Browser auction slots must be valid, unique, and follow decision order" + ) + ); + } + } + ensure!( + input.bids.len() <= MAX_BROWSER_AUCTION_RESULTS, + projection_contract_error("Browser auction bid count exceeds 256") + ); + + let publisher_origin = Url::parse(publisher_origin) + .ok() + .filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some()) + .map(|url| url.origin().ascii_serialization()) + .ok_or_else(|| projection_contract_error("Publisher origin is invalid"))?; + + let mut bids_by_candidate = HashMap::with_capacity(input.bids.len()); + for bid in input.bids { + let candidate_id = bid.candidate_id.clone(); + ensure!( + bids_by_candidate.insert(candidate_id, bid).is_none(), + projection_contract_error("Browser auction candidate bids must be unique") + ); + } + + let mut reservation_ids = HashSet::new(); + let mut canonical_bids = Vec::new(); + let mut canonical_results = Vec::with_capacity(input.auction.results.len()); + for result in input.auction.results { + match result { + SlotAuctionDecisionV1::Winner { slot, candidate_id } => { + let bid = bids_by_candidate.remove(&candidate_id); + if let Some(bid) = bid.filter(|bid| { + bid.slot == slot + && valid_browser_bid(bid, &publisher_origin) + && bid + .renderer_reservation_id + .as_ref() + .is_none_or(|id| reservation_ids.insert(id.clone())) + }) { + canonical_results + .push(SlotAuctionDecisionV1::Winner { slot, candidate_id }); + canonical_bids.push(bid); + } else { + canonical_results.push(SlotAuctionDecisionV1::Failed { + slot, + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }); + } + } + non_winner => canonical_results.push(non_winner), + } + } + ensure!( + bids_by_candidate.is_empty(), + projection_contract_error("Browser auction contains a bid without a winner decision") + ); + + let mut projection = BrowserAuctionProjectionV1 { + version: 1, + auction: AuctionDecisionSetV1 { + version: 1, + auction_id: input.auction.auction_id, + results: canonical_results, + }, + slots: input.slots, + bids: canonical_bids, + }; + let mut json = + serde_json::to_vec(&projection).change_context(TrustedServerError::Auction { + message: "Failed to serialize browser auction projection".to_string(), + })?; + let reduced_for_size = json.len() > MAX_BROWSER_AUCTION_PROJECTION_BYTES; + if reduced_for_size { + projection.auction.results = projection + .auction + .results + .into_iter() + .map(|result| match result { + SlotAuctionDecisionV1::Winner { slot, .. } => SlotAuctionDecisionV1::Failed { + slot, + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }, + non_winner => non_winner, + }) + .collect(); + projection.bids.clear(); + json = serde_json::to_vec(&projection).change_context(TrustedServerError::Auction { + message: "Failed to serialize reduced browser auction projection".to_string(), + })?; + ensure!( + json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES, + projection_contract_error("Reduced browser auction projection exceeds 8 MiB") + ); + } + + Ok(CanonicalBrowserAuctionProjectionV1 { + projection, + json, + reduced_for_size, + }) + } + + /// Parse and validate one browser-boot projection before it enters HTML. + /// + /// Browser boot requires full slot coverage, unlike the direct `/auction` + /// serializer that may carry an empty slot vector. The result is the exact + /// canonical JSON produced by the shared production validator. + pub(crate) fn canonicalize_browser_auction_projection_json_v1( + json: &str, + publisher_origin: &str, + ) -> Result> { + ensure!( + json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES, + projection_contract_error("Browser auction projection exceeds 8 MiB") + ); + let projection = + serde_json::from_str::(json).map_err(|_| { + projection_contract_error( + "Browser auction projection violates the version-1 schema", + ) + })?; + let canonical = + canonicalize_browser_auction_projection_v1(projection.clone(), publisher_origin)?; + ensure!( + !canonical.reduced_for_size && canonical.projection == projection, + projection_contract_error("Browser auction projection violates the version-1 contract") + ); + String::from_utf8(canonical.json).map_err(|_| { + projection_contract_error("Browser auction projection serialization is not UTF-8") + }) + } + + #[derive(Serialize)] + struct TrustedServerOpenRtbBidExtV1<'a> { + candidate_id: &'a str, + slot_id: &'a str, + render_source: &'a BidRenderSourceV1, + } + + #[derive(Serialize)] + struct OpenRtbBidExtV1<'a> { + trusted_server: TrustedServerOpenRtbBidExtV1<'a>, + } + + #[derive(Serialize)] + struct TrustedServerOpenRtbBidV1<'a> { + id: &'a str, + impid: &'a str, + price: f64, + #[serde(skip_serializing_if = "Option::is_none")] + adm: Option<&'a str>, + w: u32, + h: u32, + ext: OpenRtbBidExtV1<'a>, + } + + #[derive(Serialize)] + struct TrustedServerSeatBidV1<'a> { + seat: &'a str, + bid: Vec>, + } + + #[derive(Serialize)] + struct TrustedServerResponseExtInnerV1<'a> { + slot_results: &'a AuctionDecisionSetV1, + } + + #[derive(Serialize)] + struct TrustedServerResponseExtV1<'a> { + trusted_server: TrustedServerResponseExtInnerV1<'a>, + } + + #[derive(Serialize)] + struct TrustedServerAuctionResponseWireV1<'a> { + id: &'a str, + seatbid: Vec>, + cur: &'static str, + ext: TrustedServerResponseExtV1<'a>, + } + + /// Serialize the coordinated-cutover exact `/auction` winner wire. + /// + /// This remains a pure contract function until Task 19 switches the endpoint. + pub(crate) fn serialize_trusted_server_auction_response_v1( + canonical: &CanonicalBrowserAuctionProjectionV1, + ) -> Result, Report> { + let seatbid = canonical + .projection + .bids + .iter() + .map(|bid| { + let (width, height) = render_source_dimensions(&bid.render_source); + let wire_id = match &bid.render_source { + BidRenderSourceV1::Aps(_) | BidRenderSourceV1::Adm(_) => bid + .renderer_reservation_id + .as_deref() + .expect("should retain the validated APS/ADM reservation"), + BidRenderSourceV1::PbsCache(source) => source.cache_id.as_str(), + }; + TrustedServerSeatBidV1 { + seat: &bid.provider, + bid: vec![TrustedServerOpenRtbBidV1 { + id: wire_id, + impid: &bid.slot, + price: bid.cpm, + // `render_source` is the sole browser authority. Standard + // `adm` is optional on the exact wire and omitted by the + // producer to avoid duplicating up to 512 KiB per winner. + adm: None, + w: width, + h: height, + ext: OpenRtbBidExtV1 { + trusted_server: TrustedServerOpenRtbBidExtV1 { + candidate_id: &bid.candidate_id, + slot_id: &bid.slot, + render_source: &bid.render_source, + }, + }, + }], + } + }) + .collect(); + let response = TrustedServerAuctionResponseWireV1 { + id: &canonical.projection.auction.auction_id, + seatbid, + cur: "USD", + ext: TrustedServerResponseExtV1 { + trusted_server: TrustedServerResponseExtInnerV1 { + slot_results: &canonical.projection.auction, + }, + }, + }; + serde_json::to_vec(&response).change_context(TrustedServerError::Auction { + message: "Failed to serialize exact trusted-server auction response".to_string(), + }) + } +} + +#[cfg(test)] +use coordinated_cutover_v1::{ + canonicalize_browser_auction_projection_v1, serialize_trusted_server_auction_response_v1, +}; + /// Delivery facts produced while serializing winning bids. #[derive(Debug, Default)] pub(crate) struct AuctionDeliveryReport { @@ -289,20 +786,11 @@ pub(crate) struct AuctionDeliveryReport { /// Winners omitted because they could not be delivered safely. pub dropped_winner_count: usize, /// Machine-readable reasons for omitted winners. - pub dropped_winner_reasons: BTreeMap, -} - -impl AuctionDeliveryReport { - fn record_drop(&mut self, reason: &str) { - self.dropped_winner_count += 1; - *self - .dropped_winner_reasons - .entry(reason.to_string()) - .or_default() += 1; - } + pub dropped_winner_reasons: AuctionDropReasons, } /// Serialized response and the delivery facts used to produce it. +#[cfg(test)] pub(crate) struct OpenRtbResponseConversion { /// HTTP response returned to the auction client. pub response: Response, @@ -317,38 +805,63 @@ pub(crate) struct OpenRtbResponseConversion { /// ([`AuctionConfig::sanitize_creatives`], opt-in, and /// [`AuctionConfig::rewrite_creatives`], default-on); with both disabled the /// creative ships exactly as the bidder returned it, subject to the 1 MiB -/// per-creative cap. Typed renderers are serialized in the response extension -/// instead of entering that pipeline at all. +/// per-creative cap. /// /// [`AuctionConfig::sanitize_creatives`]: crate::auction_config_types::AuctionConfig::sanitize_creatives /// [`AuctionConfig::rewrite_creatives`]: crate::auction_config_types::AuctionConfig::rewrite_creatives /// /// # Errors /// -/// Returns an error if response serialization fails. -/// -/// Winners without a decoded price or a deliverable creative are omitted and -/// recorded in the returned delivery report so other slots can still render. +/// Returns an error if: +/// - A winning bid is missing a price or render source +/// - The response serialization fails pub fn convert_to_openrtb_response( result: &OrchestrationResult, settings: &Settings, auction_request: &AuctionRequest, ec_allowed: bool, ) -> Result, Report> { - Ok( - convert_to_openrtb_response_with_report(result, settings, auction_request, ec_allowed)? - .response, - ) + convert_to_openrtb_response_impl(result, settings, auction_request, ec_allowed) } +#[cfg(test)] pub(crate) fn convert_to_openrtb_response_with_report( result: &OrchestrationResult, settings: &Settings, auction_request: &AuctionRequest, ec_allowed: bool, ) -> Result> { + let (response, delivery) = convert_to_openrtb_response_impl_with_report( + result, + settings, + auction_request, + ec_allowed, + )?; + Ok(OpenRtbResponseConversion { response, delivery }) +} + +fn convert_to_openrtb_response_impl( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + ec_allowed: bool, +) -> Result, Report> { + let (response, _) = convert_to_openrtb_response_impl_with_report( + result, + settings, + auction_request, + ec_allowed, + )?; + Ok(response) +} + +fn convert_to_openrtb_response_impl_with_report( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + ec_allowed: bool, +) -> Result<(Response, AuctionDeliveryReport), Report> { let mut seatbids = Vec::with_capacity(result.winning_bids.len()); - let rewrite_creatives = settings.auction.rewrite_creatives; let mut delivery = AuctionDeliveryReport::default(); for (slot_id, bid) in &result.winning_bids { @@ -359,7 +872,11 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder ); - delivery.record_drop("no_decoded_price"); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::InvalidPrice, + ); continue; }; @@ -370,29 +887,29 @@ pub(crate) fn convert_to_openrtb_response_with_report( let width = to_openrtb_i32(bid.width, "width", &bid_context); let height = to_openrtb_i32(bid.height, "height", &bid_context); - // Ordinary markup goes through the configured creative processing: - // sanitization is opt-in, rewriting is on by default, and with both - // disabled the creative ships exactly as the bidder returned it. A typed - // renderer is serialized separately and never enters that pipeline. - let serialize_renderer = |renderer: &BidRenderer| { - (BidExt { - trusted_server: BidTrustedServerExt { renderer }, - }) - .to_ext() - }; - let (adm, ext) = if let Some(raw_creative) = bid + let creative = bid .creative .as_deref() - .filter(|creative| !creative.trim().is_empty()) - { - if bid.renderer.is_some() { - log::warn!( - "Auction {}: winning bid for slot '{}' from '{}' has both creative markup and a renderer; using creative markup when it remains renderable", - auction_request.id, - slot_id, - bid.bidder - ); - } + .filter(|creative| !creative.trim().is_empty()); + if creative.is_some() && bid.renderer.is_some() { + log::warn!( + "Auction {}: skipping winning bid for slot '{}' from '{}' because it has multiple render sources", + auction_request.id, + slot_id, + bid.bidder + ); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::MultipleRenderSources, + ); + continue; + } + + // Ordinary markup follows the independently configured processing + // path: sanitization is opt-in and rewriting is default-on. A typed + // render source is serialized separately and never enters either pass. + let (adm, ext) = if let Some(raw_creative) = creative { let processed = creative::process_auction_creative(settings, raw_creative); log::debug!( @@ -401,45 +918,43 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder, settings.auction.sanitize_creatives, - rewrite_creatives, + settings.auction.rewrite_creatives, raw_creative.len(), processed.len() ); if processed.trim().is_empty() { - let Some(renderer) = bid.renderer.as_ref() else { - log::warn!( - "Auction {}: skipping winning bid for slot '{}' from '{}' because creative processing rejected its only render source", - auction_request.id, - slot_id, - bid.bidder - ); - delivery.record_drop("creative_processing_rejected"); - continue; - }; - let Some(ext) = serialize_renderer(renderer) else { - log::warn!( - "Auction {}: skipping winning bid for slot '{}' from '{}' because its renderer extension could not be serialized", - auction_request.id, - slot_id, - bid.bidder - ); - delivery.record_drop("renderer_extension_serialization_failed"); - continue; - }; - (None, Some(ext)) - } else { - (Some(processed), None) + log::warn!( + "Auction {}: skipping winning bid for slot '{}' from '{}' because creative processing rejected its only render source", + auction_request.id, + slot_id, + bid.bidder + ); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::CreativeProcessingRejected, + ); + continue; } + + (Some(processed), None) } else if let Some(renderer) = bid.renderer.as_ref() { - let Some(ext) = serialize_renderer(renderer) else { + let Some(ext) = (BidExt { + trusted_server: BidTrustedServerExt { renderer }, + }) + .to_ext() else { log::warn!( "Auction {}: skipping winning bid for slot '{}' from '{}' because its renderer extension could not be serialized", auction_request.id, slot_id, bid.bidder ); - delivery.record_drop("renderer_extension_serialization_failed"); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::RendererExtensionSerializationFailed, + ); continue; }; (None, Some(ext)) @@ -450,7 +965,11 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder ); - delivery.record_drop("no_render_source"); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::NoRenderSource, + ); continue; }; @@ -524,36 +1043,17 @@ pub(crate) fn convert_to_openrtb_response_with_report( message: "Failed to build auction response".to_string(), })?; - // Signal consent status independently of whether EIDs were resolved. - if ec_allowed { - response - .headers_mut() - .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok")); - } - - // Attach EID response headers when consent-gated EIDs are available. - if let Some(ref eids) = auction_request.user.eids { - let (encoded, truncated) = encode_eids_header(eids)?; - let header_val = - HeaderValue::from_str(&encoded).change_context(TrustedServerError::Auction { - message: "Failed to encode EIDs header value".to_string(), - })?; - response.headers_mut().insert(HEADER_X_TS_EIDS, header_val); - if truncated { - response - .headers_mut() - .insert(HEADER_X_TS_EIDS_TRUNCATED, HeaderValue::from_static("true")); - } - } + attach_auction_response_headers(&mut response, auction_request, ec_allowed)?; - Ok(OpenRtbResponseConversion { response, delivery }) + Ok((response, delivery)) } #[cfg(test)] mod tests { use super::*; use crate::auction::types::{ - ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, BidStatus, + ApsRendererV1, ApsTagType, AuctionDecisionSetV1, AuctionResponse, Bid, BidRenderSourceV1, + BidStatus, }; use crate::openrtb::{Eid, Uid}; use crate::platform::test_support::noop_services; @@ -609,6 +1109,11 @@ mod tests { provider_responses: Vec::new(), mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 10, metadata: HashMap::new(), } @@ -617,6 +1122,9 @@ mod tests { fn make_bid(slot_id: &str, bidder: &str, price: Option) -> Bid { Bid { slot_id: slot_id.to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price, currency: "USD".to_string(), creative: Some("
Ad
".to_string()), @@ -657,6 +1165,11 @@ mod tests { }], mediator_response: None, winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 50, metadata: HashMap::new(), } @@ -1159,7 +1672,8 @@ mod tests { #[test] fn convert_to_openrtb_response_serializes_winning_bid_and_orchestrator_ext() { - let settings = make_settings(); + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); @@ -1199,18 +1713,7 @@ mod tests { assert_eq!(bid["id"], json!("appnexus-div-gpt-top")); assert_eq!(bid["impid"], json!("div-gpt-top")); assert_eq!(bid["price"], json!(2.75)); - // Rewriting is on by default, and a body-less fragment still receives - // the creative runtime (prepended), so the markup is carried rather - // than returned verbatim. - let adm = bid["adm"].as_str().expect("should serialize adm"); - assert!( - adm.contains("
Ad
"), - "should carry the creative: {adm}" - ); - assert!( - adm.contains("/static/tsjs=tsjs-unified.min.js"), - "should inject the creative runtime into a body-less fragment: {adm}" - ); + assert_eq!(bid["adm"], json!("
Ad
")); assert_eq!(bid["crid"], json!("appnexus-creative")); assert_eq!(bid["w"], json!(300)); assert_eq!(bid["h"], json!(250)); @@ -1288,7 +1791,6 @@ mod tests { // markup cannot reach the publisher origin — can opt out and deliver the // creative exactly as the bidder returned it. let mut settings = make_settings(); - settings.auction.sanitize_creatives = false; settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let result = make_result(make_complete_creative_bid()); @@ -1298,7 +1800,7 @@ mod tests { .expect("should have a creative fixture"); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) - .expect("should convert creative with sanitization disabled"); + .expect("should convert creative with rewriting disabled"); let adm = response_adm(response); assert_eq!( @@ -1341,7 +1843,7 @@ mod tests { } #[test] - fn sanitize_creatives_defaults_to_disabled() { + fn rewrite_creatives_defaults_to_enabled() { let config = crate::auction_config_types::AuctionConfig::default(); assert!( !config.sanitize_creatives, @@ -1355,8 +1857,6 @@ mod tests { #[test] fn convert_to_openrtb_response_can_skip_rewriting_while_sanitizing() { - // The two controls are independent: sanitization can stay on while URL - // rewriting is off. let mut settings = make_settings(); settings.auction.rewrite_creatives = false; settings.auction.sanitize_creatives = true; @@ -1432,6 +1932,11 @@ mod tests { }], mediator_response: None, winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 50, metadata: HashMap::new(), }; @@ -1448,26 +1953,19 @@ mod tests { #[test] fn convert_to_openrtb_response_skips_invalid_winners_without_dropping_valid_slots() { - // Sanitization is opt-in, so enable it here: script-only markup is what - // makes the `rejected` and `renderer` fixtures below reach the - // processing-rejected path. Left at the default they would survive - // processing as ordinary (script-bearing) creatives. let mut settings = make_settings(); - settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let mut missing = make_bid("missing", "invalid", Some(3.0)); missing.creative = None; let mut whitespace = make_bid("whitespace", "invalid", Some(2.9)); whitespace.creative = Some(" \n\t ".to_string()); - let mut rejected = make_bid("rejected", "invalid", Some(2.8)); - rejected.creative = Some("".to_string()); - let unpriced = make_bid("unpriced", "invalid", None); let ordinary = make_bid("ordinary", "appnexus", Some(2.75)); let mut renderer = make_bid("renderer", "aps", Some(2.5)); - renderer.creative = Some("".to_string()); + renderer.creative = Some(" ".to_string()); renderer.bid_id = Some("upstream-renderer-bid".to_string()); renderer.creative_id = None; - renderer.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + renderer.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "upstream-renderer-bid".to_string(), @@ -1484,37 +1982,21 @@ mod tests { winning_bids: HashMap::from([ (missing.slot_id.clone(), missing), (whitespace.slot_id.clone(), whitespace), - (rejected.slot_id.clone(), rejected), - (unpriced.slot_id.clone(), unpriced), (ordinary.slot_id.clone(), ordinary), (renderer.slot_id.clone(), renderer), ]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 50, metadata: HashMap::new(), }; - let conversion = - convert_to_openrtb_response_with_report(&result, &settings, &auction_request, false) - .expect("should omit invalid winners and preserve valid slots"); - assert_eq!( - conversion.delivery.delivered_winner_slots, - HashSet::from(["ordinary".to_string(), "renderer".to_string()]), - "should report only serialized winners as delivered" - ); - assert_eq!(conversion.delivery.dropped_winner_count, 4); - assert_eq!( - conversion.delivery.dropped_winner_reasons["no_render_source"], - 2 - ); - assert_eq!( - conversion.delivery.dropped_winner_reasons["no_decoded_price"], - 1 - ); - assert_eq!( - conversion.delivery.dropped_winner_reasons["creative_processing_rejected"], - 1 - ); - let json = response_json(conversion.response); + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should omit invalid winners and preserve valid slots"); + let json = response_json(response); let bids: Vec<&JsonValue> = json["seatbid"] .as_array() .expect("should include valid seatbids") @@ -1523,30 +2005,16 @@ mod tests { .collect(); assert_eq!(bids.len(), 2, "should omit only invalid winners"); - assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 4); + assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 2); assert_eq!( json["ext"]["orchestrator"]["dropped_winner_reasons"]["no_render_source"], 2 ); - assert_eq!( - json["ext"]["orchestrator"]["dropped_winner_reasons"]["no_decoded_price"], - 1 - ); - assert_eq!( - json["ext"]["orchestrator"]["dropped_winner_reasons"]["creative_processing_rejected"], - 1 - ); let ordinary = bids .iter() .find(|bid| bid["impid"] == "ordinary") .expect("should preserve ordinary winner"); - assert!( - ordinary["adm"] - .as_str() - .is_some_and(|adm| adm.contains("
Ad
")), - "should preserve ordinary creative markup: {}", - ordinary["adm"] - ); + assert_eq!(ordinary["adm"], "
Ad
"); let renderer = bids .iter() .find(|bid| bid["impid"] == "renderer") @@ -1561,11 +2029,38 @@ mod tests { } #[test] - fn convert_to_openrtb_response_prefers_creative_when_both_render_sources_exist() { - let settings = make_settings(); + fn convert_to_openrtb_response_drops_creative_rejected_by_processing() { + let mut settings = make_settings(); + settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = false; + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = Some("".to_string()); + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should omit a creative rejected by configured processing"); + let json = response_json(response); + + assert!( + json["seatbid"].as_array().is_none_or(Vec::is_empty), + "should not serialize an empty adm" + ); + assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 1); + assert_eq!( + json["ext"]["orchestrator"]["dropped_winner_reasons"]["creative_processing_rejected"], + 1, + "should report the exact processing rejection" + ); + } + + #[test] + fn convert_to_openrtb_response_rejects_multiple_render_sources() { + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let mut bid = make_bid("div-gpt-top", "aps", Some(2.75)); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "fictional-bid".to_string(), @@ -1579,20 +2074,16 @@ mod tests { let result = make_result(bid); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) - .expect("should prefer ordinary creative markup"); + .expect("should reject an ambiguous render source"); let json = response_json(response); - let bid = &json["seatbid"][0]["bid"][0]; - - // Rewriting is on by default and a body-less fragment still receives the - // creative runtime, so the markup is carried rather than returned verbatim. - let adm = bid["adm"].as_str().expect("should serialize adm"); assert!( - adm.contains("
Ad
"), - "should carry the creative markup: {adm}" + json["seatbid"].as_array().is_none_or(Vec::is_empty), + "should not serialize an ambiguous winner" ); - assert!( - bid.get("ext").is_none(), - "should omit renderer extension when creative markup wins precedence" + assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 1); + assert_eq!( + json["ext"]["orchestrator"]["dropped_winner_reasons"]["multiple_render_sources"], 1, + "should report the exact ambiguous-source reason" ); } @@ -1605,7 +2096,7 @@ mod tests { bid.bid_id = Some("fictional-bid".to_string()); bid.ad_id = Some("fictional-ad".to_string()); bid.creative_id = Some("fictional-creative".to_string()); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "fictional-bid".to_string(), @@ -1672,6 +2163,11 @@ mod tests { provider_responses: vec![], mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 50, metadata: HashMap::new(), }; @@ -1694,7 +2190,8 @@ mod tests { #[test] fn convert_to_openrtb_response_serializes_multiple_winning_bids() { - let settings = make_settings(); + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let top_bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); let mut sidebar_bid = make_bid("div-gpt-sidebar", "rubicon", Some(1.25)); @@ -1712,6 +2209,11 @@ mod tests { (top_bid.slot_id.clone(), top_bid), (sidebar_bid.slot_id.clone(), sidebar_bid), ]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results: Vec::new(), + }, total_time_ms: 50, metadata: HashMap::new(), }; @@ -1746,12 +2248,10 @@ mod tests { "should preserve top slot impid" ); assert_eq!(top_bid["price"], json!(2.75), "should preserve top price"); - assert!( - top_bid["adm"] - .as_str() - .is_some_and(|adm| adm.contains("
Ad
")), - "should preserve top creative: {}", - top_bid["adm"] + assert_eq!( + top_bid["adm"], + json!("
Ad
"), + "should preserve top creative" ); let sidebar_seatbid = seatbids @@ -1779,12 +2279,10 @@ mod tests { json!(1.25), "should preserve sidebar price" ); - assert!( - sidebar_bid["adm"] - .as_str() - .is_some_and(|adm| adm.contains("
Sidebar
")), - "should preserve sidebar creative: {}", - sidebar_bid["adm"] + assert_eq!( + sidebar_bid["adm"], + json!("
Sidebar
"), + "should preserve sidebar creative" ); assert_eq!( json["ext"]["orchestrator"]["total_bids"], @@ -1823,9 +2321,15 @@ mod tests { assert!(conversion.delivery.delivered_winner_slots.is_empty()); assert_eq!(conversion.delivery.dropped_winner_count, 1); assert_eq!( - conversion.delivery.dropped_winner_reasons["no_decoded_price"], 1, + conversion.delivery.dropped_winner_reasons[&AuctionDropReason::InvalidPrice], + 1, "should report the omitted malformed winner" ); + assert_eq!( + conversion.response.status(), + StatusCode::OK, + "should still return a successful partial auction response" + ); } #[test] @@ -1850,10 +2354,16 @@ mod tests { #[cfg(test)] mod convert_tests { use super::*; + use crate::auction::types::{ + AdmRenderSourceV1, AuctionDecisionSetV1, BidRenderSourceV1, BrowserAuctionBidV1, + BrowserAuctionProjectionV1, MAX_BROWSER_AUCTION_PROJECTION_BYTES, SlotAuctionDecisionV1, + }; use crate::consent::ConsentContext; use crate::platform::test_support::noop_services; use crate::test_support::tests::crate_test_settings_str; use http::Method; + use serde_json::json; + use std::collections::BTreeMap; fn make_settings() -> Settings { Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") @@ -2026,4 +2536,273 @@ mod convert_tests { "3-element banner size should return an error" ); } + + fn projection_candidate_id(index: usize) -> String { + format!("{index:012x}") + } + + fn projection_reservation_id(index: usize) -> String { + format!("r1_{index:022x}") + } + + fn projection_adm_bid(index: usize, slot: &str, adm: String) -> BrowserAuctionBidV1 { + BrowserAuctionBidV1 { + candidate_id: projection_candidate_id(index), + slot: slot.to_string(), + provider: "prebid".to_string(), + upstream_bid_id: format!("upstream-{index}"), + cpm: index as f64, + currency: "USD".to_string(), + targeting: BTreeMap::from([ + ("z_key".to_string(), "last".to_string()), + ("a_key".to_string(), "first".to_string()), + ]), + renderer_reservation_id: Some(projection_reservation_id(index)), + render_source: BidRenderSourceV1::Adm(AdmRenderSourceV1 { + version: 1, + adm, + width: 300, + height: 250, + }), + } + } + + fn projection_with_adm_lengths(lengths: &[usize]) -> BrowserAuctionProjectionV1 { + let results = lengths + .iter() + .enumerate() + .map(|(index, _)| SlotAuctionDecisionV1::Winner { + slot: format!("slot-{index}"), + candidate_id: projection_candidate_id(index), + }) + .collect(); + let bids = lengths + .iter() + .enumerate() + .map(|(index, length)| { + projection_adm_bid(index, &format!("slot-{index}"), "x".repeat(*length)) + }) + .rev() + .collect(); + BrowserAuctionProjectionV1 { + version: 1, + auction: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-1".to_string(), + results, + }, + slots: Vec::new(), + bids, + } + } + + #[test] + fn canonical_projection_orders_bids_and_targeting_by_contract() { + let input = projection_with_adm_lengths(&[1, 1]); + let mut permuted = input.clone(); + permuted.bids.reverse(); + + let canonical = + canonicalize_browser_auction_projection_v1(input, "https://publisher.example") + .expect("valid projection should canonicalize"); + let canonical_permuted = + canonicalize_browser_auction_projection_v1(permuted, "https://publisher.example") + .expect("response-order permutation should canonicalize"); + + assert!(!canonical.reduced_for_size); + assert_eq!(canonical.json, canonical_permuted.json); + assert_eq!(canonical.projection.bids[0].slot, "slot-0"); + assert_eq!(canonical.projection.bids[1].slot, "slot-1"); + let json = String::from_utf8(canonical.json).expect("canonical JSON should be UTF-8"); + assert!( + json.find("\"a_key\"") < json.find("\"z_key\""), + "targeting keys should be lexically sorted" + ); + assert!( + json.starts_with("{\"version\":1,\"auction\":{\"version\":1,\"auctionId\":"), + "top-level and decision-set fields should retain schema order: {json}" + ); + } + + #[test] + fn pbs_cache_wire_is_the_exact_thin_deny_unknown_carrier() { + let value = serde_json::json!({ + "type": "pbs_cache", + "version": 1, + "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570", + "cacheHost": "cache.example:8443", + "cachePath": "/pbc/v1/cache/opaque%2Fpath", + "width": 0, + "height": u32::MAX + }); + let source: BidRenderSourceV1 = serde_json::from_value(value.clone()) + .expect("the final tagged union should admit the thin pbs_cache carrier"); + assert_eq!( + serde_json::to_value(source).expect("cache carrier should serialize"), + value + ); + + let mut unknown = value; + unknown["fetchUrl"] = serde_json::Value::String( + "https://cache.example/pbc/v1/cache?uuid=not-authoritative".to_string(), + ); + assert!(serde_json::from_value::(unknown).is_err()); + } + + #[test] + fn invalid_selected_winner_becomes_winner_not_renderable() { + let mut input = projection_with_adm_lengths(&[1]); + input.bids[0].renderer_reservation_id = Some("not-a-reservation".to_string()); + + let canonical = + canonicalize_browser_auction_projection_v1(input, "https://publisher.example") + .expect("selected projection failure should remain an explicit slot result"); + + assert!(canonical.projection.bids.is_empty()); + assert_eq!( + canonical.projection.auction.results, + vec![SlotAuctionDecisionV1::Failed { + slot: "slot-0".to_string(), + reason: crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + }] + ); + } + + #[test] + fn canonical_projection_enforces_exact_eight_mib_all_winner_reduction() { + let mut lengths = vec![512 * 1024; 15]; + lengths.push(1); + let baseline = projection_with_adm_lengths(&lengths); + let baseline_len = serde_json::to_vec(&baseline) + .expect("typed baseline should serialize") + .len(); + let exact_tail = 1 + MAX_BROWSER_AUCTION_PROJECTION_BYTES - baseline_len; + assert!( + exact_tail <= 512 * 1024, + "tail ADM should remain individually valid" + ); + + for (delta, should_reduce) in [(-1_isize, false), (0, false), (1, true)] { + lengths[15] = exact_tail + .checked_add_signed(delta) + .expect("positive exact tail"); + let input = projection_with_adm_lengths(&lengths); + let canonical = + canonicalize_browser_auction_projection_v1(input, "https://publisher.example") + .expect("boundary projection should canonicalize or reduce"); + assert_eq!(canonical.reduced_for_size, should_reduce, "delta {delta}"); + assert!(canonical.json.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES); + if should_reduce { + assert!(canonical.projection.bids.is_empty()); + assert!(canonical.projection.auction.results.iter().all(|result| matches!( + result, + SlotAuctionDecisionV1::Failed { + reason: crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + .. + } + ))); + let wire: JsonValue = serde_json::from_slice( + &serialize_trusted_server_auction_response_v1(&canonical) + .expect("reduced exact response should serialize"), + ) + .expect("reduced exact response should be JSON"); + assert_eq!(wire["seatbid"], json!([])); + } else { + assert_eq!( + canonical.json.len(), + MAX_BROWSER_AUCTION_PROJECTION_BYTES + .checked_add_signed(delta) + .expect("boundary size should remain positive") + ); + if delta == 0 { + let wire = serialize_trusted_server_auction_response_v1(&canonical) + .expect("exact-boundary response should serialize"); + assert!( + wire.len() <= MAX_BROWSER_AUCTION_PROJECTION_BYTES, + "exact response should not exceed the admitted projection cap" + ); + } + } + } + } + + #[test] + fn exact_openrtb_serializer_uses_reservation_and_trusted_server_join_only() { + let canonical = canonicalize_browser_auction_projection_v1( + projection_with_adm_lengths(&[7]), + "https://publisher.example", + ) + .expect("projection should canonicalize"); + + let json: JsonValue = serde_json::from_slice( + &serialize_trusted_server_auction_response_v1(&canonical) + .expect("exact response should serialize"), + ) + .expect("exact response should be JSON"); + + let bid = &json["seatbid"][0]["bid"][0]; + assert_eq!(bid["id"], projection_reservation_id(0)); + assert_eq!(bid["impid"], "slot-0"); + assert!( + bid.get("adm").is_none(), + "tagged render_source should be the sole browser authority" + ); + assert_eq!(json["cur"], "USD"); + assert_eq!( + bid["ext"]["trusted_server"], + json!({ + "candidate_id": projection_candidate_id(0), + "slot_id": "slot-0", + "render_source": { + "type": "adm", + "version": 1, + "adm": "xxxxxxx", + "width": 300, + "height": 250, + } + }) + ); + assert_eq!( + json["ext"]["trusted_server"]["slot_results"], + serde_json::to_value(&canonical.projection.auction) + .expect("decision set should serialize") + ); + } + + #[test] + fn exact_openrtb_serializer_carries_identity_generation_failure_without_a_bid() { + let canonical = canonicalize_browser_auction_projection_v1( + BrowserAuctionProjectionV1 { + version: 1, + auction: AuctionDecisionSetV1 { + version: 1, + auction_id: "auction-identity-failure".to_string(), + results: vec![SlotAuctionDecisionV1::Failed { + slot: "slot-0".to_string(), + reason: crate::auction::types::AuctionSlotFailureReason::IdentityGenerationFailed, + }], + }, + slots: Vec::new(), + bids: Vec::new(), + }, + "https://publisher.example", + ) + .expect("identity failure decision should canonicalize"); + + let json: JsonValue = serde_json::from_slice( + &serialize_trusted_server_auction_response_v1(&canonical) + .expect("identity failure response should serialize"), + ) + .expect("identity failure response should be JSON"); + + assert_eq!(json["seatbid"], json!([])); + assert_eq!( + json["ext"]["trusted_server"]["slot_results"]["results"][0], + json!({ + "slot": "slot-0", + "outcome": "failed", + "reason": "identity_generation_failed", + }) + ); + } } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 1552ee4fd..cd2354507 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -12,9 +12,25 @@ use crate::error::TrustedServerError; use crate::platform::{PlatformPendingRequest, RuntimeServices}; use super::config::AuctionConfig; -use super::provider::{AuctionProvider, ProviderParseState, ProviderRequestOutcome}; +use super::provider::{ + AuctionProvider, ProviderParseState, ProviderRequestOutcome, ProviderSlotDisposition, + ProviderSlotOutcome, +}; use super::telemetry::AbandonedProviderCall; -use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; +use super::types::{ + AuctionContext, AuctionDecisionSetV1, AuctionDropReason, AuctionIdentityGenerator, + AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, BidStatus, + SlotAuctionDecisionV1, SystemAuctionIdentityGenerator, mint_response_unique_base64url_identity, +}; + +const CANDIDATE_ID_BYTES: usize = 9; +const CANDIDATE_ID_COLLISION_RETRIES: usize = 8; +const MAX_UPSTREAM_BID_ID_BYTES: usize = 64; + +struct NormalizedProviderResponses { + outcomes: Vec, + candidates: HashMap, +} /// In-flight auction requests dispatched to SSP backends. /// @@ -43,23 +59,6 @@ struct ProviderLaunchState { parse_state: Option, } -/// Outcome of attempting to dispatch split-phase auction provider requests. -pub enum DispatchAuctionOutcome { - /// No provider request was started and no provider failure was observed. - NotStarted, - /// No provider request could be launched, but launch failures were observed. - DispatchFailed { - /// Original auction request. - request: AuctionRequest, - /// Provider launch-failure responses. - provider_responses: Vec, - /// Elapsed dispatch time. - elapsed_ms: u64, - }, - /// One or more providers produced an immediate response or started a request. - Dispatched(DispatchedAuction), -} - impl DispatchedAuction { /// Consume the dispatch token without collecting provider responses. #[must_use] @@ -169,6 +168,23 @@ fn provider_timeout_response(provider_name: &str, response_time_ms: u64) -> Auct .with_metadata("message", serde_json::json!("Provider request timed out")) } +fn canonical_provider_response( + expected_provider: &str, + response: AuctionResponse, +) -> AuctionResponse { + if response.provider == expected_provider { + response + } else { + log::warn!( + "Provider '{}' returned response identity '{}'; rejecting mismatched response", + expected_provider, + response.provider + ); + AuctionResponse::error(expected_provider, response.response_time_ms) + .with_drop_reason(AuctionDropReason::InvalidProviderResponse) + } +} + /// Compute the remaining time budget from a deadline. /// /// Returns the number of milliseconds left before `timeout_ms` is exceeded, @@ -192,6 +208,7 @@ fn snapshot_context_request(request: &Request) -> Request { pub struct AuctionOrchestrator { config: AuctionConfig, providers: HashMap>, + identity_generator: Arc, } impl AuctionOrchestrator { @@ -201,6 +218,19 @@ impl AuctionOrchestrator { Self { config, providers: HashMap::new(), + identity_generator: Arc::new(SystemAuctionIdentityGenerator), + } + } + + #[cfg(test)] + fn with_identity_generator( + config: AuctionConfig, + identity_generator: Arc, + ) -> Self { + Self { + config, + providers: HashMap::new(), + identity_generator, } } @@ -264,6 +294,366 @@ impl AuctionOrchestrator { Ok(()) } + fn provider_is_eligible_for_slot( + &self, + provider_name: &str, + slot: &super::types::AdSlot, + ) -> bool { + self.providers.get(provider_name).is_some_and(|provider| { + provider.is_enabled() + && slot + .formats + .iter() + .any(|format| provider.supports_media_type(&format.media_type)) + }) + } + + fn eligible_slot_ids(&self, provider_name: &str, request: &AuctionRequest) -> HashSet { + request + .slots + .iter() + .filter(|slot| self.provider_is_eligible_for_slot(provider_name, slot)) + .map(|slot| slot.id.clone()) + .collect() + } + + fn valid_upstream_bid_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_UPSTREAM_BID_ID_BYTES + && !value.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) + } + + fn mint_candidate_id(&self, issued: &mut HashSet) -> Option { + let candidate_id = mint_response_unique_base64url_identity( + self.identity_generator.as_ref(), + issued, + "", + CANDIDATE_ID_BYTES, + CANDIDATE_ID_COLLISION_RETRIES, + )?; + debug_assert_eq!(candidate_id.len(), 12); + Some(candidate_id) + } + + fn response_failure_reason(response: &AuctionResponse) -> Option { + if response.status == BidStatus::Error || response.status == BidStatus::Pending { + return match response + .metadata + .get("error_type") + .and_then(serde_json::Value::as_str) + { + Some(ERROR_TYPE_TIMEOUT) => Some(AuctionSlotFailureReason::ProviderTimeout), + Some(ERROR_TYPE_PARSE_RESPONSE) => { + Some(AuctionSlotFailureReason::InvalidProviderResponse) + } + _ => { + let invalid = response + .metadata + .get("drop_reasons") + .and_then(serde_json::Value::as_object) + .is_some_and(|reasons| reasons.contains_key("invalid_provider_response")); + Some(if invalid { + AuctionSlotFailureReason::InvalidProviderResponse + } else { + AuctionSlotFailureReason::ProviderError + }) + } + }; + } + + None + } + + fn normalize_provider_responses( + &self, + request: &AuctionRequest, + responses: &mut [AuctionResponse], + ) -> NormalizedProviderResponses { + let requested_slots: HashMap<&str, &super::types::AdSlot> = request + .slots + .iter() + .map(|slot| (slot.id.as_str(), slot)) + .collect(); + let mut issued_candidate_ids = HashSet::new(); + let mut candidates = HashMap::new(); + let mut outcomes = Vec::new(); + + for response in responses { + let eligible_slots = self.eligible_slot_ids(&response.provider, request); + let response_failure = Self::response_failure_reason(response); + let mut upstream_counts = HashMap::::new(); + for bid in &response.bids { + if let Some(upstream_id) = bid.bid_id.as_deref() + && Self::valid_upstream_bid_id(upstream_id) + { + *upstream_counts.entry(upstream_id.to_string()).or_default() += 1; + } + } + + let mut invalid_slots = response + .metadata + .get("invalid_slots") + .and_then(serde_json::Value::as_object) + .map(|slots| { + slots + .iter() + .filter_map(|(slot, reason)| { + (reason.as_str() == Some("invalid_provider_response")).then_some(( + slot.clone(), + AuctionSlotFailureReason::InvalidProviderResponse, + )) + }) + .collect::>() + }) + .unwrap_or_default(); + let mut global_invalid = response + .metadata + .get("global_invalid_provider_response") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let mut accepted = Vec::new(); + for mut bid in core::mem::take(&mut response.bids) { + let requested_slot = requested_slots.get(bid.slot_id.as_str()).copied(); + let slot_is_eligible = eligible_slots.contains(&bid.slot_id); + let dimensions_match = requested_slot.is_some_and(|slot| { + slot.formats.iter().any(|format| { + format.width == bid.width + && format.height == bid.height + && self + .providers + .get(&response.provider) + .is_some_and(|provider| { + provider.supports_media_type(&format.media_type) + }) + }) + }); + let upstream_id = bid.bid_id.as_deref(); + let upstream_is_valid = upstream_id.is_some_and(Self::valid_upstream_bid_id); + let upstream_is_unique = upstream_id.is_some_and(|upstream_id| { + upstream_counts.get(upstream_id).copied() == Some(1) + }); + let bid_is_valid = response.status == BidStatus::Success + && slot_is_eligible + && dimensions_match + && upstream_is_valid + && upstream_is_unique + && bid.currency == "USD" + && bid + .price + .is_some_and(|price| price.is_finite() && price >= 0.0); + + if !bid_is_valid { + if requested_slot.is_some() { + invalid_slots + .entry(bid.slot_id.clone()) + .or_insert(AuctionSlotFailureReason::InvalidProviderResponse); + } else { + global_invalid = true; + } + continue; + } + + let Some(candidate_id) = self.mint_candidate_id(&mut issued_candidate_ids) else { + invalid_slots + .insert(bid.slot_id.clone(), AuctionSlotFailureReason::InternalError); + continue; + }; + bid.candidate_id = Some(candidate_id.clone()); + bid.candidate_provider = Some(response.provider.clone()); + bid.renderer_reservation_id = None; + candidates.insert(candidate_id, bid.clone()); + accepted.push(bid); + } + let internally_failed_slots: HashSet<&str> = invalid_slots + .iter() + .filter_map(|(slot, reason)| { + (*reason == AuctionSlotFailureReason::InternalError).then_some(slot.as_str()) + }) + .collect(); + if !internally_failed_slots.is_empty() { + accepted.retain(|bid| !internally_failed_slots.contains(bid.slot_id.as_str())); + candidates.retain(|_, bid| { + bid.candidate_provider.as_deref() != Some(response.provider.as_str()) + || !internally_failed_slots.contains(bid.slot_id.as_str()) + }); + } + response.bids = accepted; + + for slot in &request.slots { + if !eligible_slots.contains(&slot.id) { + continue; + } + let slot_candidates: Vec = response + .bids + .iter() + .filter(|bid| bid.slot_id == slot.id) + .cloned() + .collect(); + let disposition = if !slot_candidates.is_empty() { + ProviderSlotDisposition::Candidates(slot_candidates) + } else if let Some(reason) = invalid_slots.get(&slot.id).copied() { + ProviderSlotDisposition::Failed(reason) + } else if global_invalid { + ProviderSlotDisposition::Failed( + AuctionSlotFailureReason::InvalidProviderResponse, + ) + } else if let Some(reason) = response_failure { + ProviderSlotDisposition::Failed(reason) + } else { + ProviderSlotDisposition::NoBid + }; + outcomes.push(ProviderSlotOutcome { + provider: response.provider.clone(), + slot: slot.id.clone(), + disposition, + }); + } + } + + NormalizedProviderResponses { + outcomes, + candidates, + } + } + + fn build_decision_set( + &self, + request: &AuctionRequest, + outcomes: &[ProviderSlotOutcome], + winning_bids: &HashMap, + mediation_failed: bool, + ) -> AuctionDecisionSetV1 { + let results = request + .slots + .iter() + .map(|slot| { + if let Some(winner) = winning_bids.get(&slot.id) { + return winner.candidate_id.as_ref().map_or_else( + || SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }, + |candidate_id| SlotAuctionDecisionV1::Winner { + slot: slot.id.clone(), + candidate_id: candidate_id.clone(), + }, + ); + } + + let eligible_provider_count = self + .config + .provider_names() + .iter() + .filter(|provider| self.provider_is_eligible_for_slot(provider, slot)) + .count(); + if eligible_provider_count == 0 { + return SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason: AuctionSlotFailureReason::SlotNotEligible, + }; + } + + let mut failures: Vec = outcomes + .iter() + .filter(|outcome| outcome.slot == slot.id) + .filter_map(|outcome| match outcome.disposition { + ProviderSlotDisposition::Failed(reason) => Some(reason), + ProviderSlotDisposition::Candidates(_) | ProviderSlotDisposition::NoBid => { + None + } + }) + .collect(); + if mediation_failed { + failures.push(AuctionSlotFailureReason::MediationFailed); + } + failures.sort_by_key(|reason| reason.priority()); + failures.first().copied().map_or_else( + || SlotAuctionDecisionV1::NoBid { + slot: slot.id.clone(), + }, + |reason| SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason, + }, + ) + }) + .collect(); + + AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results, + } + } + + fn resolve_mediator_candidates( + mediator_response: AuctionResponse, + candidates: &HashMap, + ) -> Result { + if mediator_response.status == BidStatus::Error + || mediator_response.status == BidStatus::Pending + { + return Err(()); + } + + let mut seen = HashSet::new(); + let mut seen_slots = HashSet::new(); + let mut resolved = Vec::with_capacity(mediator_response.bids.len()); + for selection in &mediator_response.bids { + let Some(candidate_id) = selection.candidate_id.as_deref() else { + return Err(()); + }; + if !seen.insert(candidate_id.to_string()) { + return Err(()); + } + let Some(source) = candidates.get(candidate_id) else { + return Err(()); + }; + let Some(selected_price) = selection + .price + .filter(|price| price.is_finite() && *price >= 0.0) + else { + return Err(()); + }; + let source_authority_matches = selection.slot_id == source.slot_id + && selection.candidate_provider == source.candidate_provider + && selection.currency == source.currency + && selection.creative == source.creative + && selection.adomain == source.adomain + && selection.bidder == source.bidder + && selection.width == source.width + && selection.height == source.height + && selection.nurl == source.nurl + && selection.burl == source.burl + && selection.bid_id == source.bid_id + && selection.ad_id == source.ad_id + && selection.creative_id == source.creative_id + && selection.renderer == source.renderer + && selection.cache_id == source.cache_id + && selection.cache_host == source.cache_host + && selection.cache_path == source.cache_path; + if !seen_slots.insert(source.slot_id.as_str()) || !source_authority_matches { + return Err(()); + } + + let mut restored = source.clone(); + restored.price = Some(selected_price); + resolved.push(restored); + } + + Ok(AuctionResponse { + provider: mediator_response.provider, + status: if resolved.is_empty() { + BidStatus::NoBid + } else { + BidStatus::Success + }, + bids: resolved, + response_time_ms: mediator_response.response_time_ms, + metadata: mediator_response.metadata, + }) + } + /// Execute an auction using the auto-detected strategy. /// /// Strategy is determined by mediator configuration: @@ -281,6 +671,20 @@ impl AuctionOrchestrator { ) -> Result> { let start_time = Instant::now(); + if !self.config.enabled { + return Ok(OrchestrationResult { + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1::failed( + request, + AuctionSlotFailureReason::AuctionDisabled, + ), + total_time_ms: 0, + metadata: HashMap::new(), + }); + } + // Auto-detect strategy based on mediator configuration let (strategy_name, result) = if self.config.has_mediator() { ( @@ -317,119 +721,125 @@ impl AuctionOrchestrator { context: &AuctionContext<'_>, ) -> Result> { let mediation_start = Instant::now(); - let provider_responses = self.run_providers_parallel(request, context).await?; + let mut provider_responses = self.run_providers_parallel(request, context).await?; + let normalized = self.normalize_provider_responses(request, &mut provider_responses); let floor_prices = self.floor_prices_by_slot(request); - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { - let mediator = self.get_provider(mediator_name)?; - - log::info!( - "Sending {} provider responses to mediator: {}", - provider_responses.len(), - mediator.provider_name() - ); - - // Give the mediator only the remaining time from the auction - // deadline, not the full timeout — the bidding phase already - // consumed part of it. Canonicalize the transport timeout so the - // backend name remains stable across equivalent budget values. - let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); - let mediator_timeout = context - .services - .backend() - .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); - - if mediator_timeout == 0 { - log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); - let winning = self.select_winning_bids(&provider_responses, &floor_prices); - return Ok(OrchestrationResult { - provider_responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: 0, - metadata: HashMap::new(), - }); - } - - let mediator_context = AuctionContext { - settings: context.settings, - request: context.request, - timeout_ms: mediator_timeout, - provider_responses: Some(&provider_responses), - services: context.services, - }; - - let start_time = Instant::now(); - let mediator_resp = match mediator - .request_bids(request, &mediator_context) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} failed to launch", mediator.provider_name()), - })? { - ProviderRequestOutcome::Immediate(response) => response, - ProviderRequestOutcome::Pending { - request: pending, - parse_state, - } => { - let platform_resp = mediator_context - .services - .http_client() - .wait(pending) - .await - .change_context(TrustedServerError::Auction { - message: format!( - "Mediator {} request failed", + let mut mediation_failed = false; + let mut mediator_response = None; + let mut winning_bids = None; + + if let Some(mediator_name) = &self.config.mediator { + if let Some(mediator) = self.providers.get(mediator_name) { + log::info!( + "Sending {} provider responses to mediator: {}", + provider_responses.len(), + mediator.provider_name() + ); + let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); + if remaining_ms == 0 { + log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); + mediation_failed = true; + } else { + let mediator_context = AuctionContext { + settings: context.settings, + request: context.request, + timeout_ms: context + .services + .backend() + .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()), + provider_responses: Some(&provider_responses), + services: context.services, + }; + let start_time = Instant::now(); + let raw_response = match mediator.request_bids(request, &mediator_context).await + { + Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), + Ok(ProviderRequestOutcome::Pending { + request: pending, + parse_state, + }) => match mediator_context.services.http_client().wait(pending).await { + Ok(platform_response) => mediator + .parse_response_with_context_and_state( + platform_response, + start_time.elapsed().as_millis() as u64, + request, + &mediator_context, + parse_state.as_deref(), + ) + .await + .inspect_err(|error| { + log::warn!( + "Mediator '{}' parse failed: {error:?}", + mediator.provider_name() + ); + }) + .ok(), + Err(error) => { + log::warn!( + "Mediator '{}' request failed: {error:?}", + mediator.provider_name() + ); + None + } + }, + Err(error) => { + log::warn!( + "Mediator '{}' failed to launch: {error:?}", mediator.provider_name() - ), - })?; - - mediator - .parse_response_with_context_and_state( - platform_resp, - start_time.elapsed().as_millis() as u64, - request, - &mediator_context, - parse_state.as_deref(), - ) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} parse failed", mediator.provider_name()), - })? - } - }; + ); + None + } + }; - // Extract only mediator bids with comparable numeric prices. - let winning = mediator_resp - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without a price - skipping", - mediator.provider_name(), - bid.slot_id - ); - None + if let Some(raw_response) = raw_response { + mediator_response = Some(raw_response.clone()); + match Self::resolve_mediator_candidates( + raw_response, + &normalized.candidates, + ) { + Ok(resolved) => { + let selected = resolved + .bids + .iter() + .map(|bid| (bid.slot_id.clone(), bid.clone())) + .collect(); + winning_bids = + Some(self.apply_floor_prices(selected, &floor_prices)); + mediator_response = Some(resolved); + } + Err(()) => { + log::warn!( + "Mediator '{}' returned invalid candidate provenance", + mediator.provider_name() + ); + mediation_failed = true; + } + } } else { - Some((bid.slot_id.clone(), bid.clone())) + mediation_failed = true; } - }) - .collect(); + } + } else { + log::warn!("Mediator '{}' not registered", mediator_name); + mediation_failed = true; + } + } - ( - Some(mediator_resp), - self.apply_floor_prices(winning, &floor_prices), - ) - } else { - // No mediator - select best bid per slot from bidder responses - let winning = self.select_winning_bids(&provider_responses, &floor_prices); - (None, winning) - }; + let winning_bids = winning_bids + .unwrap_or_else(|| self.select_winning_bids(&provider_responses, &floor_prices)); + let decision_set = self.build_decision_set( + request, + &normalized.outcomes, + &winning_bids, + mediation_failed, + ); Ok(OrchestrationResult { provider_responses, mediator_response, winning_bids, + decision_set, total_time_ms: 0, // Will be set by caller metadata: HashMap::new(), }) @@ -441,14 +851,18 @@ impl AuctionOrchestrator { request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - let provider_responses = self.run_providers_parallel(request, context).await?; + let mut provider_responses = self.run_providers_parallel(request, context).await?; + let normalized = self.normalize_provider_responses(request, &mut provider_responses); let floor_prices = self.floor_prices_by_slot(request); let winning_bids = self.select_winning_bids(&provider_responses, &floor_prices); + let decision_set = + self.build_decision_set(request, &normalized.outcomes, &winning_bids, false); Ok(OrchestrationResult { provider_responses, mediator_response: None, winning_bids, + decision_set, total_time_ms: 0, metadata: HashMap::new(), }) @@ -466,9 +880,7 @@ impl AuctionOrchestrator { let provider_names = self.config.provider_names(); if provider_names.is_empty() { - return Err(Report::new(TrustedServerError::Auction { - message: "No providers configured".to_string(), - })); + return Ok(Vec::new()); } // Reject multi-provider fan-out before any request launches when the @@ -477,14 +889,14 @@ impl AuctionOrchestrator { // blow the auction budget before a later `select` could reject it. if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout() { - return Err(Report::new(TrustedServerError::Auction { - message: format!( - "{} auction providers configured, but this platform's HTTP \ - client executes requests sequentially — configure a single \ - provider, or use an adapter with concurrent fan-out support", - provider_names.len(), - ), - })); + log::warn!( + "{} auction providers configured, but this platform's HTTP client executes requests sequentially", + provider_names.len(), + ); + return Ok(provider_names + .iter() + .map(|provider_name| provider_launch_failed_response(provider_name, 0)) + .collect()); } log::info!( @@ -500,8 +912,6 @@ impl AuctionOrchestrator { let mut backend_to_provider: HashMap = HashMap::new(); let mut pending_requests: Vec = Vec::new(); let mut responses = Vec::new(); - let mut immediate_response_count = 0usize; - for provider_name in provider_names { let provider = match self.providers.get(provider_name) { Some(p) => p, @@ -532,20 +942,23 @@ impl AuctionOrchestrator { // budget skips every provider, including one that might respond immediately. if effective_timeout == 0 { log::warn!("Auction timeout exhausted before launching provider request; skipping"); + responses.push(provider_timeout_response(provider.provider_name(), 0)); continue; } - // Immediate providers have no backend name and must remain eligible - // to return a synchronous result. Pending providers are still - // guarded before dispatch when their name can be predicted. - let predicted_backend_name = provider.backend_name(context.services, effective_timeout); - if let Some(backend_name) = predicted_backend_name.as_ref() - && backend_to_provider.contains_key(backend_name) + // Pre-launch guard: `request_bids` fires the outbound send, and + // discarding the returned pending handle afterwards does not retract + // it. If another provider this auction already claimed the predicted + // backend name, skip *before* dispatching so a duplicate never hits + // the wire. The post-launch check below stays as a defense for a + // provider that resolves to an unexpected name. + if let Some(predicted) = provider.backend_name(context.services, effective_timeout) + && backend_to_provider.contains_key(&predicted) { log::warn!( "Provider '{}' predicted backend name '{}' already belongs to another provider; skipping launch", provider.provider_name(), - backend_name, + predicted, ); responses.push(provider_launch_failed_response(provider.provider_name(), 0)); continue; @@ -572,14 +985,13 @@ impl AuctionOrchestrator { parse_state, }) => { let request_backend_name = pending.backend_name().map(str::to_string).or_else(|| { - if let Some(backend_name) = predicted_backend_name.as_ref() { + provider.backend_name(context.services, effective_timeout).inspect(|name| { log::warn!( "Provider '{}' pending request returned no backend name; using predicted name '{}'", provider.provider_name(), - backend_name, + name, ); - } - predicted_backend_name.clone() + }) }); let Some(request_backend_name) = request_backend_name else { log::warn!( @@ -592,6 +1004,21 @@ impl AuctionOrchestrator { )); continue; }; + // Post-launch defense: a resolved backend name already + // claimed by another provider would misattribute that + // provider's response, so fail this launch attributably + // instead of overwriting the correlation entry. + if backend_to_provider.contains_key(&request_backend_name) { + log::warn!( + "Provider '{}' pending request has no backend name; response cannot be correlated", + provider.provider_name() + ); + responses.push(provider_launch_failed_response( + provider.provider_name(), + start_time.elapsed().as_millis() as u64, + )); + continue; + }; if backend_to_provider.contains_key(&request_backend_name) { log::warn!( "Provider '{}' resolved backend name '{}' already belongs to another provider; skipping launch", @@ -621,12 +1048,14 @@ impl AuctionOrchestrator { ); } Ok(ProviderRequestOutcome::Immediate(response)) => { - immediate_response_count += 1; log::debug!( "Provider '{}' completed without an upstream request", provider.provider_name() ); - responses.push(response); + responses.push(canonical_provider_response( + provider.provider_name(), + response, + )); } Err(e) => { let response_time_ms = start_time.elapsed().as_millis() as u64; @@ -644,18 +1073,7 @@ impl AuctionOrchestrator { } if pending_requests.is_empty() { - // An immediate response (for example, an APS-only Prebid no-bid) is - // a completed provider outcome. Launch failures alone remain a - // terminal auction error rather than being converted to a 200 no-bid. - if immediate_response_count > 0 { - return Ok(responses); - } - return Err(Report::new(TrustedServerError::Auction { - message: format!( - "All {} configured provider(s) skipped or failed to launch", - provider_names.len() - ), - })); + return Ok(responses); } let deadline = Duration::from_millis(u64::from(context.timeout_ms)); @@ -672,7 +1090,7 @@ impl AuctionOrchestrator { // some adapters, buffers the selected response body before returning. // Backend first-byte and between-bytes timeouts are capped to the // remaining auction budget in Phase 1. They are transport timers, not - // absolute wall-clock limits, so connection setup and byte-trickling + // absolute wall-clock limits, so connection setup and byte trickling // remain bounded operational risks rather than strict deadline proof. let mut remaining = pending_requests; @@ -729,7 +1147,10 @@ impl AuctionOrchestrator { auction_response.status, auction_response.response_time_ms ); - responses.push(auction_response); + responses.push(canonical_provider_response( + &state.provider_name, + auction_response, + )); } Err(e) => { // lgtm[rust/cleartext-logging] @@ -837,9 +1258,20 @@ impl AuctionOrchestrator { }; let should_replace = match winning_bids.get(&bid.slot_id) { - Some(current_winner) => current_winner - .price - .is_none_or(|current_price| bid_price > current_price), + Some(current_winner) => current_winner.price.is_none_or(|current_price| { + bid_price > current_price + || (bid_price == current_price + && ( + bid.candidate_provider.as_deref().unwrap_or(&bid.bidder), + bid.bid_id.as_deref().unwrap_or_default(), + ) < ( + current_winner + .candidate_provider + .as_deref() + .unwrap_or(¤t_winner.bidder), + current_winner.bid_id.as_deref().unwrap_or_default(), + )) + }), None => true, }; @@ -906,23 +1338,6 @@ impl AuctionOrchestrator { .collect() } - /// Get a provider by name. - fn get_provider( - &self, - name: &str, - ) -> Result<&Arc, Report> { - self.providers.get(name).ok_or_else(|| { - log::warn!( - "Provider '{}' configured but not registered. Available providers: {:?}", - name, - self.providers.keys().collect::>() - ); - Report::new(TrustedServerError::Auction { - message: format!("Provider '{}' not registered", name), - }) - }) - } - /// Dispatch SSP bid requests without blocking WASM. /// /// Calls each enabled provider's [`AuctionProvider::request_bids`] (which @@ -930,28 +1345,31 @@ impl AuctionOrchestrator { /// [`DispatchedAuction`] token. The Fastly host begins the SSP round-trips /// while WASM continues to `pending_origin.wait()`. /// - /// Returns [`DispatchAuctionOutcome::NotStarted`] when no providers are configured or - /// all providers are disabled / over budget. Returns - /// [`DispatchAuctionOutcome::DispatchFailed`] when provider launch attempts - /// happened but none could be started. + /// The token is returned even when no transport starts. Collection then + /// routes zero-budget, launch-failure, disabled, and unconfigured-provider + /// cases through the same exhaustive terminal decision builder as ordinary + /// responses instead of silently dropping their slot outcomes. #[must_use] pub async fn dispatch_auction( &self, request: &AuctionRequest, context: &AuctionContext<'_>, - ) -> DispatchAuctionOutcome { + ) -> DispatchedAuction { let provider_names = self.config.provider_names(); - if provider_names.is_empty() { - return DispatchAuctionOutcome::NotStarted; - } + let auction_start = Instant::now(); + let mut backend_to_provider: HashMap = HashMap::new(); + let mut pending_requests: Vec = Vec::new(); + let mut completed_responses: Vec = Vec::new(); + let mut immediate_response_count = 0usize; // Mirror run_providers_parallel: reject multi-provider fan-out before // any request launches when the platform executes `send_async` eagerly // (e.g. Cloudflare Workers, Spin). Sequential execution would accrue // the sum of provider latencies before the origin fetch and then fail // collection with empty bids. - if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout() - { + let fanout_supported = provider_names.len() <= 1 + || context.services.http_client().supports_concurrent_fanout(); + if !fanout_supported { log::warn!( "{} auction providers configured, but this platform's HTTP client \ executes requests sequentially — skipping initial-page auction \ @@ -959,16 +1377,14 @@ impl AuctionOrchestrator { concurrent fan-out support", provider_names.len(), ); - return DispatchAuctionOutcome::NotStarted; + completed_responses.extend( + provider_names + .iter() + .map(|provider| provider_launch_failed_response(provider, 0)), + ); } - let auction_start = Instant::now(); - let mut backend_to_provider: HashMap = HashMap::new(); - let mut pending_requests: Vec = Vec::new(); - let mut completed_responses: Vec = Vec::new(); - let mut immediate_response_count = 0usize; - - for provider_name in provider_names { + for provider_name in provider_names.iter().filter(|_| fanout_supported) { let provider = match self.providers.get(provider_name) { Some(p) => p, None => { @@ -1001,20 +1417,24 @@ impl AuctionOrchestrator { context.timeout_ms, provider.provider_name() ); + completed_responses.push(provider_timeout_response( + provider.provider_name(), + auction_start.elapsed().as_millis() as u64, + )); continue; } - // Do not require a backend name before dispatch: an immediate - // provider intentionally has none. Guard predicted names when - // available; pending requests without either name fail below. - let predicted_backend_name = provider.backend_name(context.services, effective_timeout); - if let Some(backend_name) = predicted_backend_name.as_ref() - && backend_to_provider.contains_key(backend_name) + // Pre-launch guard: skip before `request_bids` fires the outbound + // send when another provider this auction already claimed the + // predicted backend name (see the parallel path). Dropping the + // pending handle afterwards would not retract the request. + if let Some(predicted) = provider.backend_name(context.services, effective_timeout) + && backend_to_provider.contains_key(&predicted) { log::warn!( "Provider '{}' predicted backend name '{}' already belongs to another provider; skipping dispatch", provider.provider_name(), - backend_name, + predicted, ); completed_responses .push(provider_launch_failed_response(provider.provider_name(), 0)); @@ -1035,16 +1455,10 @@ impl AuctionOrchestrator { request: pending, parse_state, }) => { - let backend_name = pending.backend_name().map(str::to_string).or_else(|| { - if let Some(backend_name) = predicted_backend_name.as_ref() { - log::warn!( - "Provider '{}' pending request returned no backend name; using predicted name '{}'", - provider.provider_name(), - backend_name, - ); - } - predicted_backend_name.clone() - }); + let backend_name = pending + .backend_name() + .map(str::to_string) + .or_else(|| provider.backend_name(context.services, effective_timeout)); let Some(backend_name) = backend_name else { log::warn!( "Provider '{}' pending request has no backend name; response cannot be correlated", @@ -1056,9 +1470,14 @@ impl AuctionOrchestrator { )); continue; }; + // Post-launch defense: a resolved backend name already + // claimed by another provider would misattribute that + // provider's response, so fail this dispatch attributably + // instead of overwriting the correlation entry. if backend_to_provider.contains_key(&backend_name) { log::warn!( - "Provider '{}' resolved backend name '{}' already belongs to another provider; skipping dispatch", + "Provider '{}' resolved to backend name '{}' already claimed by another \ + provider this auction; skipping launch to avoid response misattribution", provider.provider_name(), backend_name, ); @@ -1088,7 +1507,10 @@ impl AuctionOrchestrator { } Ok(ProviderRequestOutcome::Immediate(response)) => { immediate_response_count += 1; - completed_responses.push(response); + completed_responses.push(canonical_provider_response( + provider.provider_name(), + response, + )); } Err(e) => { let response_time_ms = start_time.elapsed().as_millis() as u64; @@ -1105,18 +1527,6 @@ impl AuctionOrchestrator { } } - if pending_requests.is_empty() && immediate_response_count == 0 { - return if completed_responses.is_empty() { - DispatchAuctionOutcome::NotStarted - } else { - DispatchAuctionOutcome::DispatchFailed { - request: request.clone(), - provider_responses: completed_responses, - elapsed_ms: auction_start.elapsed().as_millis() as u64, - } - }; - } - log::info!( "Dispatched {} SSP request(s) with {} immediate response(s) (timeout: {}ms)", pending_requests.len(), @@ -1124,7 +1534,7 @@ impl AuctionOrchestrator { context.timeout_ms ); - DispatchAuctionOutcome::Dispatched(DispatchedAuction { + DispatchedAuction { pending_requests, backend_to_provider, completed_responses, @@ -1133,7 +1543,7 @@ impl AuctionOrchestrator { floor_prices: self.floor_prices_by_slot(request), provider_request_context: Box::new(snapshot_context_request(context.request)), request: request.clone(), - }) + } } /// Collect bid responses from a previously-dispatched auction. @@ -1226,7 +1636,10 @@ impl AuctionOrchestrator { auction_response.bids.len(), auction_response.response_time_ms ); - responses.push(auction_response); + responses.push(canonical_provider_response( + &state.provider_name, + auction_response, + )); } Err(e) => { log::warn!( @@ -1302,50 +1715,25 @@ impl AuctionOrchestrator { )); } backend_to_provider.clear(); - - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { - match self.providers.get(mediator_name.as_str()) { - Some(mediator) => { - // Cap the mediator at whichever is tighter: its own configured - // timeout or the remaining auction budget (A_deadline). Backend - // first-byte and between-bytes timeouts bound normal collection, but - // they are transport timers rather than absolute wall-clock limits: - // connection setup and byte-trickling can still consume more of the - // auction budget. Recomputing the remaining budget here prevents the - // mediator from extending that bounded response hold. - let remaining = remaining_budget_ms(auction_start, timeout_ms); + let normalized = self.normalize_provider_responses(&request, &mut responses); + let mut mediation_failed = false; + let mut mediator_response = None; + let mut mediated_winners = None; + + if let Some(mediator_name) = &self.config.mediator { + if let Some(mediator) = self.providers.get(mediator_name.as_str()) { + let remaining = remaining_budget_ms(auction_start, timeout_ms); + if remaining == 0 { + log::warn!( + "A_deadline exhausted before mediator '{}' — using direct fallback", + mediator.provider_name(), + ); + mediation_failed = true; + } else { let mediator_timeout = services .backend() .canonicalize_transport_timeout_ms(remaining, mediator.timeout_ms()); - if mediator_timeout == 0 { - log::warn!( - "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", - mediator.provider_name(), - responses.len(), - ); - let winning = self.select_winning_bids(&responses, &floor_prices); - return OrchestrationResult { - provider_responses: responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: HashMap::new(), - }; - } let mediator_start = Instant::now(); - log::info!( - "Running mediator '{}' with {}ms budget (A_deadline remaining: {}ms, configured: {}ms)", - mediator.provider_name(), - mediator_timeout, - remaining, - mediator.timeout_ms(), - ); - // The mediator runs on the collect path. See the doc-comment on - // `AuctionContext::request`: the real client request was already - // consumed by `send_async` during dispatch, so we substitute a - // canonical placeholder URL. Any future mediator that needs real - // client headers must snapshot them at dispatch time onto - // `DispatchedAuction` rather than reading `context.request` here. let placeholder = http::Request::builder() .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) .body(edgezero_core::body::Body::empty()) @@ -1357,93 +1745,84 @@ impl AuctionOrchestrator { provider_responses: Some(&responses), services: context.services, }; - let mediator_response = + let raw_response = match mediator.request_bids(&request, &mediator_context).await { Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), Ok(ProviderRequestOutcome::Pending { request: pending, parse_state, - }) => match services.http_client().wait(pending).await.change_context( - TrustedServerError::Auction { - message: format!( - "Mediator {} request failed", - mediator.provider_name() - ), - }, - ) { - Ok(platform_resp) => match mediator + }) => match services.http_client().wait(pending).await { + Ok(platform_response) => mediator .parse_response_with_context_and_state( - platform_resp, + platform_response, mediator_start.elapsed().as_millis() as u64, &request, &mediator_context, parse_state.as_deref(), ) .await - { - Ok(response) => Some(response), - Err(error) => { + .inspect_err(|error| { log::warn!( - "Mediator '{}' parse failed: {:?}", - mediator.provider_name(), - error + "Mediator '{}' parse failed: {error:?}", + mediator.provider_name() ); - None - } - }, + }) + .ok(), Err(error) => { - log::warn!("Mediator request failed: {:?}", error); + log::warn!("Mediator request failed: {error:?}"); None } }, Err(error) => { log::warn!( - "Mediator '{}' failed to dispatch: {:?}", - mediator.provider_name(), - error + "Mediator '{}' failed to dispatch: {error:?}", + mediator.provider_name() ); None } }; - - if let Some(mediator_response) = mediator_response { - let winning = mediator_response - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", - mediator.provider_name(), - bid.slot_id - ); - None - } else { - Some((bid.slot_id.clone(), bid.clone())) - } - }) - .collect(); - let winning = self.apply_floor_prices(winning, &floor_prices); - (Some(mediator_response), winning) + if let Some(raw_response) = raw_response { + mediator_response = Some(raw_response.clone()); + match Self::resolve_mediator_candidates( + raw_response, + &normalized.candidates, + ) { + Ok(resolved) => { + let selected = resolved + .bids + .iter() + .map(|bid| (bid.slot_id.clone(), bid.clone())) + .collect(); + mediated_winners = + Some(self.apply_floor_prices(selected, &floor_prices)); + mediator_response = Some(resolved); + } + Err(()) => mediation_failed = true, + } } else { - (None, self.select_winning_bids(&responses, &floor_prices)) + mediation_failed = true; } } - None => { - // lgtm[rust/cleartext-logging] - // The mediator name is a static config identifier, not a secret. - log::warn!("Mediator '{}' not registered", mediator_name); - (None, self.select_winning_bids(&responses, &floor_prices)) - } + } else { + log::warn!("Mediator '{}' not registered", mediator_name); + mediation_failed = true; } - } else { - (None, self.select_winning_bids(&responses, &floor_prices)) - }; + } + + let winning_bids = + mediated_winners.unwrap_or_else(|| self.select_winning_bids(&responses, &floor_prices)); + let decision_set = self.build_decision_set( + &request, + &normalized.outcomes, + &winning_bids, + mediation_failed, + ); OrchestrationResult { provider_responses: responses, mediator_response, winning_bids, + decision_set, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: HashMap::new(), } @@ -1465,6 +1844,8 @@ pub struct OrchestrationResult { pub mediator_response: Option, /// Winning bids per slot pub winning_bids: HashMap, + /// Exact ordered decision for every requested slot. + pub decision_set: AuctionDecisionSetV1, /// Total orchestration time in milliseconds pub total_time_ms: u64, /// Metadata about the auction @@ -1501,12 +1882,14 @@ mod tests { use web_time::Instant; use crate::auction::config::AuctionConfig; - use crate::auction::orchestrator::DispatchAuctionOutcome; - use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; + use crate::auction::provider::{ + AuctionProvider, ProviderRequestOutcome, ProviderSlotDisposition, + }; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, - AuctionResponse, Bid, BidRenderer, BidStatus, MediaType, PublisherInfo, UserInfo, + AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionDropReason, + AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, BidRenderSourceV1, + BidStatus, MediaType, PublisherInfo, SlotAuctionDecisionV1, UserInfo, }; use crate::error::TrustedServerError; use crate::platform::test_support::{ @@ -1520,9 +1903,10 @@ mod tests { use crate::test_support::tests::crate_test_settings_str; use error_stack::{Report, ResultExt}; use std::collections::{HashMap, HashSet}; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; - use super::AuctionOrchestrator; + use super::{AuctionIdentityGenerator, AuctionOrchestrator}; // --------------------------------------------------------------------------- // Minimal test double for AuctionProvider @@ -1531,6 +1915,46 @@ mod tests { struct StubAuctionProvider { name: &'static str, backend: &'static str, + configured_timeout_ms: u32, + predicted_timeouts: Option>>>, + request_timeouts: Option>>>, + } + + impl StubAuctionProvider { + fn new(name: &'static str, backend: &'static str) -> Self { + Self { + name, + backend, + configured_timeout_ms: 125, + predicted_timeouts: None, + request_timeouts: None, + } + } + + fn recording( + name: &'static str, + backend: &'static str, + configured_timeout_ms: u32, + predicted_timeouts: Arc>>, + request_timeouts: Arc>>, + ) -> Self { + Self { + name, + backend, + configured_timeout_ms, + predicted_timeouts: Some(predicted_timeouts), + request_timeouts: Some(request_timeouts), + } + } + + fn record(slot: &Option>>>, timeout_ms: u32) { + if let Some(observed) = slot { + observed + .lock() + .expect("should lock observed timeouts") + .push(timeout_ms); + } + } } #[async_trait::async_trait(?Send)] @@ -1544,6 +1968,7 @@ mod tests { _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { + Self::record(&self.request_timeouts, context.timeout_ms); let req = PlatformHttpRequest::new( http::Request::builder() .method("POST") @@ -1594,82 +2019,18 @@ mod tests { .with_metadata("context_timeout_ms", serde_json::json!(context.timeout_ms))) } - fn timeout_ms(&self) -> u32 { - 125 - } - - fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { - Some(self.backend.to_string()) - } - } - - struct RecordingTimeoutProvider { - name: &'static str, - backend: &'static str, - configured_timeout_ms: u32, - predicted: Arc>>, - requested: Arc>>, - } - - #[async_trait::async_trait(?Send)] - impl AuctionProvider for RecordingTimeoutProvider { - fn provider_name(&self) -> &'static str { - self.name - } - - async fn request_bids( - &self, - _request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - self.requested - .lock() - .expect("should lock requested timeouts") - .push(context.timeout_ms); - let request = PlatformHttpRequest::new( - http::Request::builder() - .method("POST") - .uri("https://example.com/bid") - .body(edgezero_core::body::Body::empty()) - .expect("should build recording request"), - self.backend, - ); - context - .services - .http_client() - .send_async(request) - .await - .change_context(TrustedServerError::Auction { - message: "recording launch failed".to_string(), - }) - .map(ProviderRequestOutcome::pending) - } - - async fn parse_response( - &self, - _response: PlatformResponse, - response_time_ms: u64, - ) -> Result> { - Ok(AuctionResponse::success( - self.name, - vec![], - response_time_ms, - )) - } - fn timeout_ms(&self) -> u32 { self.configured_timeout_ms } fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option { - self.predicted - .lock() - .expect("should lock predicted timeouts") - .push(timeout_ms); + Self::record(&self.predicted_timeouts, timeout_ms); Some(self.backend.to_string()) } } + /// Provider whose `backend_name` prediction deliberately differs from the + /// backend name its `request_bids` puts on the wire. struct DivergentBackendProvider { name: &'static str, predicted: &'static str, @@ -1687,7 +2048,7 @@ mod tests { _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - let request = PlatformHttpRequest::new( + let req = PlatformHttpRequest::new( http::Request::builder() .method("POST") .uri("https://example.com/bid") @@ -1698,7 +2059,7 @@ mod tests { context .services .http_client() - .send_async(request) + .send_async(req) .await .change_context(TrustedServerError::Auction { message: "divergent launch failed".to_string(), @@ -1759,14 +2120,14 @@ mod tests { configured_timeout_ms: u32, predicted: &Arc>>, requested: &Arc>>, - ) -> RecordingTimeoutProvider { - RecordingTimeoutProvider { + ) -> StubAuctionProvider { + StubAuctionProvider::recording( name, backend, configured_timeout_ms, - predicted: Arc::clone(predicted), - requested: Arc::clone(requested), - } + Arc::clone(predicted), + Arc::clone(requested), + ) } /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring @@ -1776,7 +2137,7 @@ mod tests { fn auction_bid(bidder: &str, price: f64) -> Bid { let renderer = (bidder == "aps").then(|| { - BidRenderer::Aps(ApsRendererV1 { + BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "aps-selected-bid".to_string(), @@ -1790,6 +2151,9 @@ mod tests { }); Bid { slot_id: "slot-1".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(price), currency: "USD".to_string(), creative: renderer @@ -1810,28 +2174,131 @@ mod tests { cache_path: None, metadata: HashMap::new(), } - } + } + + struct CounterIdentityGenerator { + draws: AtomicUsize, + } + + impl CounterIdentityGenerator { + fn new() -> Self { + Self { + draws: AtomicUsize::new(0), + } + } + } + + impl AuctionIdentityGenerator for CounterIdentityGenerator { + fn fill(&self, destination: &mut [u8]) -> Result<(), ()> { + destination.fill(0); + let draw = self.draws.fetch_add(1, Ordering::SeqCst) + 1; + let last = destination.last_mut().ok_or(())?; + *last = u8::try_from(draw).map_err(|_| ())?; + Ok(()) + } + } + + struct FixedIdentityGenerator { + draws: AtomicUsize, + } + + impl FixedIdentityGenerator { + fn new() -> Self { + Self { + draws: AtomicUsize::new(0), + } + } + } + + impl AuctionIdentityGenerator for FixedIdentityGenerator { + fn fill(&self, destination: &mut [u8]) -> Result<(), ()> { + self.draws.fetch_add(1, Ordering::SeqCst); + destination.fill(0); + Ok(()) + } + } + + fn mediated_bid(nurl: Option) -> Bid { + Bid { + slot_id: "header-banner".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, + price: Some(2.5), + currency: "USD".to_string(), + creative: Some("
ad
".to_string()), + adomain: None, + bidder: "mediator".to_string(), + width: 728, + height: 90, + nurl: nurl.clone(), + burl: nurl, + bid_id: None, + ad_id: Some("creative-123".to_string()), + creative_id: None, + renderer: None, + cache_id: Some("cache-abc".to_string()), + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } + } + + struct SourceBidProvider { + nurl: &'static str, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for SourceBidProvider { + fn provider_name(&self) -> &'static str { + "bidder" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let request = PlatformHttpRequest::new( + http::Request::builder() + .method("POST") + .uri("https://example.com/bid") + .body(edgezero_core::body::Body::empty()) + .expect("should build source bid request"), + "bidder-backend", + ); + context + .services + .http_client() + .send_async(request) + .await + .change_context(TrustedServerError::Auction { + message: "source bidder launch failed".to_string(), + }) + .map(ProviderRequestOutcome::pending) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + let mut bid = mediated_bid(Some(self.nurl.to_string())); + bid.price = Some(1.0); + bid.bid_id = Some("source-bid-id".to_string()); + Ok(AuctionResponse::success( + self.provider_name(), + vec![bid], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } - fn mediated_bid(nurl: Option) -> Bid { - Bid { - slot_id: "header-banner".to_string(), - price: Some(2.5), - currency: "USD".to_string(), - creative: Some("
ad
".to_string()), - adomain: None, - bidder: "mediator".to_string(), - width: 728, - height: 90, - nurl: nurl.clone(), - burl: nurl, - bid_id: None, - ad_id: Some("creative-123".to_string()), - creative_id: None, - renderer: None, - cache_id: Some("cache-abc".to_string()), - cache_host: None, - cache_path: None, - metadata: HashMap::new(), + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("bidder-backend".to_string()) } } @@ -1883,12 +2350,18 @@ mod tests { _response: PlatformResponse, response_time_ms: u64, _request: &AuctionRequest, - _context: &AuctionContext<'_>, + context: &AuctionContext<'_>, ) -> Result> { - // Context-aware path: restores nurl/ad_id from the collected SSP bids. + let mut selection = context + .provider_responses + .and_then(|responses| responses.first()) + .and_then(|response| response.bids.first()) + .cloned() + .expect("should provide one source candidate to mediator"); + selection.price = Some(2.5); Ok(AuctionResponse::success( "mediator", - vec![mediated_bid(Some("https://nurl.example/win".to_string()))], + vec![selection], response_time_ms, )) } @@ -1913,13 +2386,18 @@ mod tests { async fn request_bids( &self, _request: &AuctionRequest, - _context: &AuctionContext<'_>, + context: &AuctionContext<'_>, ) -> Result> { + let mut selection = context + .provider_responses + .and_then(|responses| responses.first()) + .and_then(|response| response.bids.first()) + .cloned() + .expect("should provide one source candidate to immediate mediator"); + selection.price = Some(2.5); Ok(ProviderRequestOutcome::Immediate(AuctionResponse::success( self.provider_name(), - vec![mediated_bid(Some( - "https://nurl.example/immediate".to_string(), - ))], + vec![selection], 0, ))) } @@ -1958,9 +2436,8 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", + orchestrator.register_provider(Arc::new(SourceBidProvider { + nurl: "https://nurl.example/win", })); orchestrator.register_provider(Arc::new(CacheRestoringMediator)); @@ -2014,9 +2491,8 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", + orchestrator.register_provider(Arc::new(SourceBidProvider { + nurl: "https://nurl.example/immediate", })); orchestrator.register_provider(Arc::new(ImmediateMediator)); let request = create_test_auction_request(); @@ -2031,11 +2507,7 @@ mod tests { }; let result = if split { - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("bidder request should dispatch"); - }; + let dispatched = orchestrator.dispatch_auction(&request, &context).await; orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await @@ -2105,6 +2577,374 @@ mod tests { } } + fn one_slot_request() -> AuctionRequest { + let mut request = create_test_auction_request(); + request.slots = vec![AdSlot { + id: "slot-1".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }]; + request + } + + fn enabled_config(providers: &[&str]) -> AuctionConfig { + AuctionConfig { + enabled: true, + providers: providers + .iter() + .map(|provider| (*provider).to_string()) + .collect(), + ..AuctionConfig::default() + } + } + + #[test] + fn normalized_provider_outcomes_cover_every_dispatched_slot() { + let generator = Arc::new(CounterIdentityGenerator::new()); + let mut orchestrator = + AuctionOrchestrator::with_identity_generator(enabled_config(&["alpha"]), generator); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let request = one_slot_request(); + let mut candidate = auction_bid("aps", 2.0); + candidate.slot_id = "slot-1".to_string(); + let mut responses = vec![AuctionResponse::success("alpha", vec![candidate], 10)]; + + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + + assert_eq!(normalized.outcomes.len(), 1); + assert_eq!(normalized.outcomes[0].provider, "alpha"); + assert_eq!(normalized.outcomes[0].slot, "slot-1"); + assert!(matches!( + &normalized.outcomes[0].disposition, + ProviderSlotDisposition::Candidates(candidates) + if candidates.len() == 1 + && candidates[0].candidate_id.as_deref().is_some_and(|id| id.len() == 12) + )); + + let mut no_bid = vec![AuctionResponse::no_bid("alpha", 10)]; + let normalized = orchestrator.normalize_provider_responses(&request, &mut no_bid); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::NoBid + )); + + let mut timeout = vec![super::provider_timeout_response("alpha", 10)]; + let normalized = orchestrator.normalize_provider_responses(&request, &mut timeout); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Failed(AuctionSlotFailureReason::ProviderTimeout) + )); + + let mut attributable_invalid = vec![AuctionResponse::no_bid("alpha", 10)]; + attributable_invalid[0].metadata.insert( + "invalid_slots".to_string(), + serde_json::json!({"slot-1": "invalid_provider_response"}), + ); + let normalized = + orchestrator.normalize_provider_responses(&request, &mut attributable_invalid); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InvalidProviderResponse) + )); + } + + #[test] + fn provider_failure_classes_map_to_closed_slot_reasons() { + let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha"])); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let request = one_slot_request(); + + for (error_type, expected) in [ + ( + super::ERROR_TYPE_LAUNCH_FAILED, + AuctionSlotFailureReason::ProviderError, + ), + ( + super::ERROR_TYPE_TRANSPORT, + AuctionSlotFailureReason::ProviderError, + ), + ( + super::ERROR_TYPE_HTTP_STATUS, + AuctionSlotFailureReason::ProviderError, + ), + ( + super::ERROR_TYPE_PARSE_RESPONSE, + AuctionSlotFailureReason::InvalidProviderResponse, + ), + ] { + let error = Report::new(TrustedServerError::Auction { + message: "provider failed".to_string(), + }); + let mut responses = vec![super::provider_error_response( + "alpha", 1, error_type, &error, + )]; + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Failed(reason) if reason == expected + )); + } + } + + #[test] + fn candidate_collision_exhaustion_fails_only_the_affected_slot() { + let generator = Arc::new(FixedIdentityGenerator::new()); + let mut orchestrator = AuctionOrchestrator::with_identity_generator( + enabled_config(&["alpha"]), + generator.clone(), + ); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let mut request = one_slot_request(); + request.slots.push(AdSlot { + id: "slot-2".to_string(), + formats: request.slots[0].formats.clone(), + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }); + let mut first = auction_bid("aps", 2.0); + first.slot_id = "slot-1".to_string(); + first.bid_id = Some("upstream-1".to_string()); + let mut second = auction_bid("aps", 1.0); + second.slot_id = "slot-2".to_string(); + second.bid_id = Some("upstream-2".to_string()); + let mut responses = vec![AuctionResponse::success("alpha", vec![first, second], 10)]; + + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + + assert_eq!(generator.draws.load(Ordering::SeqCst), 10); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Candidates(_) + )); + assert!(matches!( + normalized.outcomes[1].disposition, + ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InternalError) + )); + } + + #[test] + fn candidate_collision_exhaustion_discards_earlier_sibling_for_same_slot() { + let generator = Arc::new(FixedIdentityGenerator::new()); + let mut orchestrator = AuctionOrchestrator::with_identity_generator( + enabled_config(&["alpha"]), + generator.clone(), + ); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let request = one_slot_request(); + let mut first = auction_bid("aps", 2.0); + first.bid_id = Some("upstream-1".to_string()); + let mut second = auction_bid("aps", 1.0); + second.bid_id = Some("upstream-2".to_string()); + let mut responses = vec![AuctionResponse::success("alpha", vec![first, second], 10)]; + + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + + assert_eq!(generator.draws.load(Ordering::SeqCst), 10); + assert!(responses[0].bids.is_empty()); + assert!(normalized.candidates.is_empty()); + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Failed(AuctionSlotFailureReason::InternalError) + )); + } + + #[test] + fn per_bid_drop_does_not_poison_an_unrelated_missing_slot() { + let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha"])); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + let mut request = one_slot_request(); + request.slots.push(AdSlot { + id: "slot-2".to_string(), + formats: request.slots[0].formats.clone(), + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }); + let mut valid = auction_bid("aps", 2.0); + valid.bid_id = Some("upstream-1".to_string()); + let mut response = AuctionResponse::success("alpha", vec![valid], 10); + response = response.with_drop_reason(AuctionDropReason::InvalidDimensions); + let mut responses = vec![response]; + + let normalized = orchestrator.normalize_provider_responses(&request, &mut responses); + + assert!(matches!( + normalized.outcomes[0].disposition, + ProviderSlotDisposition::Candidates(_) + )); + assert!(matches!( + normalized.outcomes[1].disposition, + ProviderSlotDisposition::NoBid + )); + } + + #[test] + fn final_decisions_are_request_ordered_and_use_closed_failure_priority() { + let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"])); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("zeta", "zeta"))); + let request = one_slot_request(); + let outcomes = vec![ + crate::auction::provider::ProviderSlotOutcome { + provider: "alpha".to_string(), + slot: "slot-1".to_string(), + disposition: ProviderSlotDisposition::Failed( + AuctionSlotFailureReason::ProviderTimeout, + ), + }, + crate::auction::provider::ProviderSlotOutcome { + provider: "zeta".to_string(), + slot: "slot-1".to_string(), + disposition: ProviderSlotDisposition::Failed( + AuctionSlotFailureReason::InvalidProviderResponse, + ), + }, + ]; + + let decisions = orchestrator.build_decision_set(&request, &outcomes, &HashMap::new(), true); + + assert_eq!(decisions.results.len(), 1); + assert!(matches!( + &decisions.results[0], + SlotAuctionDecisionV1::Failed { slot, reason } + if slot == "slot-1" && *reason == AuctionSlotFailureReason::MediationFailed + )); + assert_eq!( + serde_json::to_string(&decisions).expect("decision set should serialize"), + r#"{"version":1,"auctionId":"test-auction-123","results":[{"slot":"slot-1","outcome":"failed","reason":"mediation_failed"}]}"# + ); + assert_eq!( + serde_json::to_string(&SlotAuctionDecisionV1::Failed { + slot: "slot-1".to_string(), + reason: AuctionSlotFailureReason::IdentityGenerationFailed, + }) + .expect("direct identity-generation failure should serialize"), + r#"{"slot":"slot-1","outcome":"failed","reason":"identity_generation_failed"}"# + ); + } + + #[test] + fn deliverable_winner_beats_a_sibling_provider_failure() { + let mut orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"])); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("alpha", "alpha"))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new("zeta", "zeta"))); + let request = one_slot_request(); + let mut winner = auction_bid("alpha-seat", 2.0); + winner.candidate_id = Some("AAAAAAAAAAAA".to_string()); + winner.candidate_provider = Some("alpha".to_string()); + winner.bid_id = Some("upstream-alpha".to_string()); + let outcomes = vec![crate::auction::provider::ProviderSlotOutcome { + provider: "zeta".to_string(), + slot: "slot-1".to_string(), + disposition: ProviderSlotDisposition::Failed(AuctionSlotFailureReason::ProviderTimeout), + }]; + + let decisions = orchestrator.build_decision_set( + &request, + &outcomes, + &HashMap::from([("slot-1".to_string(), winner)]), + true, + ); + + assert_eq!( + decisions.results, + vec![SlotAuctionDecisionV1::Winner { + slot: "slot-1".to_string(), + candidate_id: "AAAAAAAAAAAA".to_string(), + }] + ); + } + + #[test] + fn direct_ties_ignore_arrival_and_candidate_ids() { + let orchestrator = AuctionOrchestrator::new(enabled_config(&["alpha", "zeta"])); + let mut alpha = auction_bid("seat-a", 2.0); + alpha.candidate_provider = Some("alpha".to_string()); + alpha.candidate_id = Some("zzzzzzzzzzzz".to_string()); + alpha.bid_id = Some("upstream-z".to_string()); + let mut zeta = auction_bid("seat-z", 2.0); + zeta.candidate_provider = Some("zeta".to_string()); + zeta.candidate_id = Some("AAAAAAAAAAAA".to_string()); + zeta.bid_id = Some("upstream-a".to_string()); + let left = AuctionResponse::success("alpha", vec![alpha], 1); + let right = AuctionResponse::success("zeta", vec![zeta], 1); + + for responses in [vec![left.clone(), right.clone()], vec![right, left]] { + let winners = orchestrator.select_winning_bids(&responses, &HashMap::new()); + assert_eq!( + winners["slot-1"].candidate_provider.as_deref(), + Some("alpha") + ); + } + } + + #[test] + fn mediator_can_select_only_known_candidate_provenance() { + let mut source = auction_bid("aps", 1.0); + source.candidate_id = Some("AAAAAAAAAAAA".to_string()); + source.candidate_provider = Some("aps".to_string()); + source.nurl = Some("https://source.example/win".to_string()); + let candidates = HashMap::from([("AAAAAAAAAAAA".to_string(), source.clone())]); + let mut selection = source.clone(); + selection.price = Some(9.0); + + let resolved = AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![selection], 2), + &candidates, + ) + .expect("known candidate should resolve"); + assert_eq!(resolved.bids[0].price, Some(9.0)); + assert_eq!(resolved.bids[0].width, source.width); + assert_eq!(resolved.bids[0].height, source.height); + assert_eq!(resolved.bids[0].renderer, source.renderer); + assert_eq!(resolved.bids[0].nurl, source.nurl); + + let mut substituted = source.clone(); + substituted.price = Some(9.0); + substituted.width = 1; + assert!( + AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![substituted], 2), + &candidates, + ) + .is_err(), + "mediator source-field substitutions should fail provenance validation" + ); + + let mut second_source = source.clone(); + second_source.candidate_id = Some("BBBBBBBBBBBB".to_string()); + second_source.bid_id = Some("upstream-2".to_string()); + let same_slot_candidates = HashMap::from([ + ("AAAAAAAAAAAA".to_string(), source.clone()), + ("BBBBBBBBBBBB".to_string(), second_source.clone()), + ]); + assert!( + AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![source.clone(), second_source], 2), + &same_slot_candidates, + ) + .is_err(), + "a mediator may select at most one candidate for a slot" + ); + + let mut unknown = source; + unknown.candidate_id = Some("BBBBBBBBBBBB".to_string()); + assert!( + AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![unknown], 2), + &candidates, + ) + .is_err() + ); + } + fn create_test_settings() -> crate::settings::Settings { let settings_str = crate_test_settings_str(); crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") @@ -2216,6 +3056,17 @@ mod tests { assert_eq!(result.provider_responses.len(), 1); assert_eq!(result.provider_responses[0].status, BidStatus::NoBid); assert!(result.winning_bids.is_empty()); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::NoBid { + slot: "header-banner".to_string(), + }, + SlotAuctionDecisionV1::NoBid { + slot: "sidebar".to_string(), + }, + ] + ); } #[tokio::test] @@ -2233,12 +3084,9 @@ mod tests { let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); - let DispatchAuctionOutcome::Dispatched(dispatched) = orchestrator + let dispatched = orchestrator .dispatch_auction(&create_test_auction_request(), &context) - .await - else { - panic!("enabled immediate provider should dispatch"); - }; + .await; let result = orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; @@ -2246,6 +3094,17 @@ mod tests { assert_eq!(result.provider_responses.len(), 1); assert_eq!(result.provider_responses[0].status, BidStatus::NoBid); assert!(result.winning_bids.is_empty()); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::NoBid { + slot: "header-banner".to_string(), + }, + SlotAuctionDecisionV1::NoBid { + slot: "sidebar".to_string(), + }, + ] + ); } #[tokio::test] @@ -2259,10 +3118,10 @@ mod tests { }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(ImmediateNoBidProvider)); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "pending", - backend: "pending-backend", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "pending", + "pending-backend", + ))); let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); @@ -2272,11 +3131,7 @@ mod tests { let request = create_test_auction_request(); let result = if split { - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("mixed immediate/pending auction should dispatch"); - }; + let dispatched = orchestrator.dispatch_auction(&request, &context).await; orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await @@ -2406,6 +3261,9 @@ mod tests { "slot-1".to_string(), Bid { slot_id: "slot-1".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(0.50), currency: "USD".to_string(), creative: Some("
Ad
".to_string()), @@ -2429,6 +3287,9 @@ mod tests { "slot-2".to_string(), Bid { slot_id: "slot-2".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(2.00), currency: "USD".to_string(), creative: Some("
Ad
".to_string()), @@ -2499,14 +3360,25 @@ mod tests { let result = orchestrator.run_auction(&request, &context).await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(format!("{}", err).contains("No providers configured")); + let result = result.expect("should return one decision per requested slot"); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::Failed { + slot: "header-banner".to_string(), + reason: AuctionSlotFailureReason::SlotNotEligible, + }, + SlotAuctionDecisionV1::Failed { + slot: "sidebar".to_string(), + reason: AuctionSlotFailureReason::SlotNotEligible, + }, + ] + ); }); } #[test] - fn provider_launch_failures_error_when_no_requests_launch() { + fn provider_launch_failures_are_explicit_when_no_requests_launch() { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, @@ -2526,16 +3398,20 @@ mod tests { .expect("should build request"); let context = create_test_auction_context(&settings, &req, 2000); - let error = orchestrator - .run_auction(&request, &context) - .await - .expect_err("should fail when every provider launch fails"); - - assert!( - error - .to_string() - .contains("All 1 configured provider(s) skipped or failed to launch"), - "should explain that no configured provider request launched" + let result = orchestrator.run_auction(&request, &context).await; + let result = result.expect("should preserve launch failures as slot decisions"); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::Failed { + slot: "header-banner".to_string(), + reason: AuctionSlotFailureReason::ProviderError, + }, + SlotAuctionDecisionV1::Failed { + slot: "sidebar".to_string(), + reason: AuctionSlotFailureReason::ProviderError, + }, + ] ); }); } @@ -2579,14 +3455,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "shared-backend", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "shared-backend", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "shared-backend", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "shared-backend", + ))); let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); @@ -2596,11 +3472,7 @@ mod tests { let request = create_test_auction_request(); let result = if split { - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("should dispatch the first provider"); - }; + let dispatched = orchestrator.dispatch_auction(&request, &context).await; orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await @@ -2721,13 +3593,17 @@ mod tests { #[test] fn zero_canonical_timeout_skips_parallel_launch() { futures::executor::block_on(async { + // A platform that canonicalizes to zero signals "budget exhausted"; + // the orchestrator must skip the launch and retain an attributable + // timeout decision for every eligible requested slot. + let stub = Arc::new(StubHttpClient::new()); let calls = Arc::new(Mutex::new(Vec::new())); let services = build_services_with_backend_and_http_client( Arc::new(CanonicalTimeoutBackend { canonical_ms: 0, calls, }), - Arc::new(StubHttpClient::new()), + stub, ); let predicted = Arc::new(Mutex::new(Vec::new())); let requested = Arc::new(Mutex::new(Vec::new())); @@ -2747,14 +3623,80 @@ mod tests { let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); + let request = create_test_auction_request(); let result = orchestrator - .run_auction(&create_test_auction_request(), &context) + .run_auction(&request, &context) + .await + .expect("should preserve an exhausted budget as slot decisions"); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::Failed { + slot: "header-banner".to_string(), + reason: AuctionSlotFailureReason::ProviderTimeout, + }, + SlotAuctionDecisionV1::Failed { + slot: "sidebar".to_string(), + reason: AuctionSlotFailureReason::ProviderTimeout, + }, + ] + ); + }); + } + + #[test] + fn zero_canonical_timeout_is_attributable_in_split_dispatch() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + let calls = Arc::new(Mutex::new(Vec::new())); + let services = build_services_with_backend_and_http_client( + Arc::new(CanonicalTimeoutBackend { + canonical_ms: 0, + calls, + }), + stub, + ); + let predicted = Arc::new(Mutex::new(Vec::new())); + let requested = Arc::new(Mutex::new(Vec::new())); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + timeout_ms: 2000, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(recording_provider( + "bidder", + "bidder-backend", + 1000, + &predicted, + &requested, + ))); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = immediate_test_context(&settings, &downstream, &services); + let request = create_test_auction_request(); + + let dispatched = orchestrator.dispatch_auction(&request, &context).await; + let result = orchestrator + .collect_dispatched_auction(dispatched, &services, &context) .await; - assert!(result.is_err(), "zero budget should skip every provider"); assert!(predicted.lock().expect("should lock predicted").is_empty()); assert!(requested.lock().expect("should lock requested").is_empty()); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::Failed { + slot: "header-banner".to_string(), + reason: AuctionSlotFailureReason::ProviderTimeout, + }, + SlotAuctionDecisionV1::Failed { + slot: "sidebar".to_string(), + reason: AuctionSlotFailureReason::ProviderTimeout, + }, + ] + ); }); } @@ -2781,10 +3723,10 @@ mod tests { timeout_ms: 2000, ..Default::default() }); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "bidder", + "bidder-backend", + ))); orchestrator.register_provider(Arc::new(recording_provider( "mediator", "mediator-backend", @@ -2850,11 +3792,7 @@ mod tests { let context = immediate_test_context(&settings, &downstream, &services); let request = create_test_auction_request(); - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("should dispatch bidder request"); - }; + let dispatched = orchestrator.dispatch_auction(&request, &context).await; orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; @@ -2902,18 +3840,21 @@ mod tests { let context = immediate_test_context(&settings, &downstream, &services); let request = create_test_auction_request(); - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("should dispatch provider"); - }; + let dispatched = orchestrator.dispatch_auction(&request, &context).await; let result = orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; - assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-a" && response.status == BidStatus::Success - })); + let provider_a = result + .provider_responses + .iter() + .find(|response| response.provider == "provider-a") + .expect("should have provider-a response"); + assert_eq!( + provider_a.status, + BidStatus::Success, + "response should correlate by the resolved backend name, not the prediction" + ); }); } @@ -2945,21 +3886,23 @@ mod tests { let context = immediate_test_context(&settings, &downstream, &services); let request = create_test_auction_request(); - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("should dispatch first provider"); - }; + let dispatched = orchestrator.dispatch_auction(&request, &context).await; let result = orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; - assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-a" && response.status == BidStatus::Success - })); - assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-b" && response.status == BidStatus::Error - })); + let provider_a = result + .provider_responses + .iter() + .find(|response| response.provider == "provider-a") + .expect("should have provider-a response"); + let provider_b = result + .provider_responses + .iter() + .find(|response| response.provider == "provider-b") + .expect("should have provider-b response"); + assert_eq!(provider_a.status, BidStatus::Success); + assert_eq!(provider_b.status, BidStatus::Error); }); } @@ -2987,14 +3930,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -3062,10 +4005,9 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); + let mut provider = StubAuctionProvider::new("provider-a", "backend-a"); + provider.configured_timeout_ms = 125; + orchestrator.register_provider(Arc::new(provider)); let request = create_test_auction_request(); let settings = create_test_settings(); let downstream = http::Request::builder() @@ -3080,13 +4022,9 @@ mod tests { provider_responses: None, services: &services, }; - let dispatched = match orchestrator + let dispatched = orchestrator .dispatch_auction(&request, &dispatch_context) - .await - { - DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, - _ => panic!("should dispatch provider request"), - }; + .await; let placeholder = http::Request::builder() .uri("https://placeholder.invalid/") .body(edgezero_core::body::Body::empty()) @@ -3140,14 +4078,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -3167,11 +4105,21 @@ mod tests { // Act let result = orchestrator.run_auction(&request, &context).await; - // Assert: rejected before any provider request launches. - let err = result.expect_err("should reject multi-provider fan-out"); - assert!( - format!("{err}").contains("sequentially"), - "should explain the sequential-execution limitation" + // Assert: every affected slot gets an explicit provider failure + // without launching either provider request. + let result = result.expect("should preserve sequential-platform failures"); + assert_eq!( + result.decision_set.results, + vec![ + SlotAuctionDecisionV1::Failed { + slot: "header-banner".to_string(), + reason: AuctionSlotFailureReason::ProviderError, + }, + SlotAuctionDecisionV1::Failed { + slot: "sidebar".to_string(), + reason: AuctionSlotFailureReason::ProviderError, + }, + ] ); assert!( stub_for_assertion.recorded_backend_names().is_empty(), @@ -3205,14 +4153,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -3232,15 +4180,27 @@ mod tests { // Act let dispatched = orchestrator.dispatch_auction(&request, &context).await; - // Assert: no dispatch and no provider request launched. - assert!( - matches!(dispatched, DispatchAuctionOutcome::NotStarted), - "should skip initial-page dispatch on sequential platforms" - ); + // Assert: no network request launches, but every configured provider + // remains attributable through the normal terminal decision path. assert!( stub_for_assertion.recorded_backend_names().is_empty(), "should not launch any provider request on a sequential platform" ); + let result = orchestrator + .collect_dispatched_auction(dispatched, services, &context) + .await; + assert!(result.winning_bids.is_empty()); + assert!(result.provider_responses.iter().all(|response| { + response.status == BidStatus::Error + && response.metadata["error_type"] == "launch_failed" + })); + assert!(result.decision_set.results.iter().all(|decision| matches!( + decision, + SlotAuctionDecisionV1::Failed { + reason: AuctionSlotFailureReason::ProviderError, + .. + } + ))); }); } @@ -3290,6 +4250,9 @@ mod tests { "slot-1".to_string(), Bid { slot_id: "slot-1".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: None, currency: "USD".to_string(), creative: Some("
Ad
".to_string()), @@ -3334,6 +4297,9 @@ mod tests { "atf".to_string(), Bid { slot_id: "atf".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(0.30), // decoded APS price — below $0.50 floor currency: "USD".to_string(), creative: Some("
APS Ad
".to_string()), @@ -3373,6 +4339,9 @@ mod tests { "atf".to_string(), Bid { slot_id: "atf".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(0.75), // decoded APS price — above floor currency: "USD".to_string(), creative: Some("
APS Ad
".to_string()), diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 766bd7a08..a3c6f012f 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -8,7 +8,31 @@ use error_stack::Report; use crate::error::TrustedServerError; use crate::platform::{PlatformPendingRequest, PlatformResponse, RuntimeServices}; -use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; +use super::types::{ + AuctionContext, AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, +}; + +/// Exactly one normalized outcome for a slot dispatched to one provider. +#[derive(Debug, Clone)] +pub struct ProviderSlotOutcome { + /// Provider integration that received the slot. + pub provider: String, + /// Exact dispatched slot identifier. + pub slot: String, + /// Candidate, successful no-bid, or typed failure. + pub disposition: ProviderSlotDisposition, +} + +/// Closed normalized provider result for one dispatched slot. +#[derive(Debug, Clone)] +pub enum ProviderSlotDisposition { + /// One or more independently validated candidates returned for the slot. + Candidates(Vec), + /// Provider completed successfully without a candidate for this slot. + NoBid, + /// Provider failed for this slot. + Failed(AuctionSlotFailureReason), +} /// Provider-local state carried from request dispatch to response parsing. pub type ProviderParseState = Box; diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index b3e049eaf..1ba73a655 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -933,7 +933,7 @@ mod tests { use serde_json::json; - use crate::auction::types::{AdFormat, AdSlot, PublisherInfo, UserInfo}; + use crate::auction::types::{AdFormat, AdSlot, AuctionDecisionSetV1, PublisherInfo, UserInfo}; use super::*; @@ -969,6 +969,9 @@ mod tests { fn bid(slot_id: &str, bidder: &str, ad_id: Option<&str>, price: Option) -> Bid { Bid { slot_id: slot_id.to_owned(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price, currency: "USD".to_owned(), creative: None, @@ -1049,6 +1052,11 @@ mod tests { provider_responses: vec![provider_success, provider_no_bid, provider_error], mediator_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), winning)]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 99, metadata: HashMap::new(), }; @@ -1112,6 +1120,11 @@ mod tests { provider_responses: vec![provider_success.clone()], mediator_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), provider_success.bids[0].clone())]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 42, metadata: HashMap::new(), }; @@ -1153,6 +1166,11 @@ mod tests { provider_responses: vec![provider_http_error], mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 12, metadata: HashMap::new(), }; @@ -1191,6 +1209,11 @@ mod tests { provider_responses: vec![provider_success], mediator_response: Some(mediator_response), winning_bids: HashMap::from([("slot-1".to_owned(), mediator_bid)]), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 80, metadata: HashMap::new(), }; @@ -1231,6 +1254,11 @@ mod tests { provider_responses: Vec::new(), mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1 { + version: 1, + auction_id: request.id.clone(), + results: Vec::new(), + }, total_time_ms: 1, metadata: HashMap::new(), }; diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index f61334787..271cac8ca 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -1,9 +1,15 @@ //! Core types for auction requests and responses. +use base64::{ + Engine as _, + engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD}, +}; use edgezero_core::body::Body as EdgeBody; use http::Request; +use rand::{RngCore as _, rngs::OsRng}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use url::Url; use crate::auction::context::ContextValue; use crate::geo::GeoInfo; @@ -14,6 +20,42 @@ fn is_zero(value: &usize) -> bool { *value == 0 } +/// Injectable CSPRNG boundary for server-minted response-local identities. +pub(crate) trait AuctionIdentityGenerator: Send + Sync { + /// Fill the complete destination or report that secure randomness is unavailable. + fn fill(&self, destination: &mut [u8]) -> Result<(), ()>; +} + +/// Production CSPRNG for server-minted auction identities. +pub(crate) struct SystemAuctionIdentityGenerator; + +impl AuctionIdentityGenerator for SystemAuctionIdentityGenerator { + fn fill(&self, destination: &mut [u8]) -> Result<(), ()> { + OsRng.try_fill_bytes(destination).map_err(|_| ()) + } +} + +/// Mint one response-unique unpadded base64url identity. +pub(crate) fn mint_response_unique_base64url_identity( + generator: &dyn AuctionIdentityGenerator, + issued: &mut HashSet, + prefix: &str, + random_byte_count: usize, + collision_retries: usize, +) -> Option { + for _ in 0..=collision_retries { + let mut bytes = vec![0_u8; random_byte_count]; + if generator.fill(&mut bytes).is_err() { + return None; + } + let identity = format!("{prefix}{}", URL_SAFE_NO_PAD.encode(bytes)); + if issued.insert(identity.clone()) { + return Some(identity); + } + } + None +} + /// Represents a unified auction request across all providers. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuctionRequest { @@ -154,6 +196,353 @@ pub struct AuctionContext<'a> { pub services: &'a RuntimeServices, } +/// Closed, local reason set for rejecting provider bids or undeliverable winners. +/// +/// These values are serialized only into existing auction debug/diagnostic +/// surfaces. They are not a persistence or external-event taxonomy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionDropReason { + /// Configured processing rejected an ordinary creative's only render source. + CreativeProcessingRejected, + /// Optional creative ID is present with an invalid type or value. + InvalidCreativeId, + /// Optional creative ID exceeds its UTF-8 byte bound. + CreativeIdTooLarge, + /// A positive integral dimension exceeds the supported range. + DimensionsOutOfRange, + /// An otherwise valid upstream bid ID is repeated in one provider response. + DuplicateUpstreamBidId, + /// A response contains no seat bids. + #[serde(rename = "empty_seatbid")] + EmptySeatBid, + /// A seat bid contains no usable bid array. + #[serde(rename = "empty_seatbid_bids")] + EmptySeatBidBids, + /// A creative URL is malformed, unsafe, or self-origin. + InvalidCreativeUrl, + /// A dimension is missing, malformed, nonpositive, or not requested. + InvalidDimensions, + /// A price is missing, malformed, nonfinite, or negative. + InvalidPrice, + /// The provider response violates the response-level contract. + InvalidProviderResponse, + /// The APS tag type is missing or unsupported. + InvalidTagType, + /// An upstream bid ID contains a forbidden control value or has the wrong type. + InvalidUpstreamBidId, + /// A valid sibling was preferred by deterministic per-slot reduction. + LostToHigherBid, + /// A provider bid is not an object. + MalformedBid, + /// APS creative metadata does not contain `creativeurl`. + MissingCreativeUrl, + /// Provider parsing was invoked without its request-local context. + MissingRequestContext, + /// A required upstream bid ID is absent or empty. + MissingUpstreamBidId, + /// A winner carries more than one render source. + MultipleRenderSources, + /// A winner has no render source. + NoRenderSource, + /// A typed renderer extension could not be serialized. + RendererExtensionSerializationFailed, + /// A validated renderer projection exceeds its bound. + RenderPayloadTooLarge, + /// APS script rendering is disabled by configuration. + ScriptRenderingDisabled, + /// A provider bid references an impression that was not dispatched. + UnknownImpression, + /// A provider bid declares a non-banner media type. + UnsupportedMediaType, + /// An upstream bid ID exceeds 64 UTF-8 bytes. + UpstreamBidIdTooLarge, +} + +impl AuctionDropReason { + /// Return the exact existing debug/projection literal. + /// + /// This hand-written mapping also drives [`Ord`] so serialized-map output stays + /// alphabetically stable even when declaration order changes. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::CreativeProcessingRejected => "creative_processing_rejected", + Self::InvalidCreativeId => "invalid_creative_id", + Self::CreativeIdTooLarge => "creative_id_too_large", + Self::DimensionsOutOfRange => "dimensions_out_of_range", + Self::DuplicateUpstreamBidId => "duplicate_upstream_bid_id", + Self::EmptySeatBid => "empty_seatbid", + Self::EmptySeatBidBids => "empty_seatbid_bids", + Self::InvalidCreativeUrl => "invalid_creative_url", + Self::InvalidDimensions => "invalid_dimensions", + Self::InvalidPrice => "invalid_price", + Self::InvalidProviderResponse => "invalid_provider_response", + Self::InvalidTagType => "invalid_tag_type", + Self::InvalidUpstreamBidId => "invalid_upstream_bid_id", + Self::LostToHigherBid => "lost_to_higher_bid", + Self::MalformedBid => "malformed_bid", + Self::MissingCreativeUrl => "missing_creative_url", + Self::MissingRequestContext => "missing_request_context", + Self::MissingUpstreamBidId => "missing_upstream_bid_id", + Self::MultipleRenderSources => "multiple_render_sources", + Self::NoRenderSource => "no_render_source", + Self::RendererExtensionSerializationFailed => "renderer_extension_serialization_failed", + Self::RenderPayloadTooLarge => "render_payload_too_large", + Self::ScriptRenderingDisabled => "script_rendering_disabled", + Self::UnknownImpression => "unknown_impression", + Self::UnsupportedMediaType => "unsupported_media_type", + Self::UpstreamBidIdTooLarge => "upstream_bid_id_too_large", + } + } +} + +impl Ord for AuctionDropReason { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.as_str().cmp(other.as_str()) + } +} + +impl PartialOrd for AuctionDropReason { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Typed counts projected into the existing `drop_reasons` debug object. +pub type AuctionDropReasons = BTreeMap; + +/// Increment one typed local drop reason. +pub(crate) fn record_auction_drop(reasons: &mut AuctionDropReasons, reason: AuctionDropReason) { + *reasons.entry(reason).or_default() += 1; +} + +/// Closed failure set for one requested slot's server-auction decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionSlotFailureReason { + /// The auction orchestrator is disabled. + AuctionDisabled, + /// Request consent does not permit a server-side auction. + ConsentDenied, + /// No enabled configured provider can bid on the slot. + SlotNotEligible, + /// A dispatched provider exceeded its deadline. + ProviderTimeout, + /// A provider could not launch or complete its transport/HTTP exchange. + ProviderError, + /// A provider response failed structural, currency, identity, or bid validation. + InvalidProviderResponse, + /// The configured mediator failed or returned invalid provenance. + MediationFailed, + /// A selected candidate cannot be represented by the exact browser contract. + WinnerNotRenderable, + /// A unique renderer reservation could not be minted. + IdentityGenerationFailed, + /// An internal invariant or candidate-identity operation failed. + InternalError, +} + +impl AuctionSlotFailureReason { + /// Return the exact wire literal. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AuctionDisabled => "auction_disabled", + Self::ConsentDenied => "consent_denied", + Self::SlotNotEligible => "slot_not_eligible", + Self::ProviderTimeout => "provider_timeout", + Self::ProviderError => "provider_error", + Self::InvalidProviderResponse => "invalid_provider_response", + Self::MediationFailed => "mediation_failed", + Self::WinnerNotRenderable => "winner_not_renderable", + Self::IdentityGenerationFailed => "identity_generation_failed", + Self::InternalError => "internal_error", + } + } + + /// Closed multi-provider aggregation priority; lower values win. + #[must_use] + pub const fn priority(self) -> u8 { + match self { + Self::InternalError => 0, + Self::MediationFailed => 1, + Self::InvalidProviderResponse => 2, + Self::ProviderError => 3, + Self::ProviderTimeout => 4, + Self::ConsentDenied => 5, + Self::AuctionDisabled => 6, + Self::SlotNotEligible => 7, + Self::WinnerNotRenderable | Self::IdentityGenerationFailed => u8::MAX, + } + } +} + +/// Exactly one final server-auction decision for a requested slot. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde( + tag = "outcome", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum SlotAuctionDecisionV1 { + /// A candidate won and joins exactly one projected bid. + Winner { + /// Exact request slot identifier. + slot: String, + /// Opaque response-local candidate identifier. + candidate_id: String, + }, + /// Every dispatched provider completed successfully without a candidate. + NoBid { + /// Exact request slot identifier. + slot: String, + }, + /// The slot failed with one closed reason. + Failed { + /// Exact request slot identifier. + slot: String, + /// Exact failure reason. + reason: AuctionSlotFailureReason, + }, +} + +impl SlotAuctionDecisionV1 { + /// Return the exact slot identifier shared by every variant. + #[must_use] + pub fn slot(&self) -> &str { + match self { + Self::Winner { slot, .. } | Self::NoBid { slot } | Self::Failed { slot, .. } => slot, + } + } +} + +impl Serialize for SlotAuctionDecisionV1 { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + match self { + Self::Winner { slot, candidate_id } => { + let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 3)?; + state.serialize_field("slot", slot)?; + state.serialize_field("outcome", "winner")?; + state.serialize_field("candidateId", candidate_id)?; + state.end() + } + Self::NoBid { slot } => { + let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 2)?; + state.serialize_field("slot", slot)?; + state.serialize_field("outcome", "no_bid")?; + state.end() + } + Self::Failed { slot, reason } => { + let mut state = serializer.serialize_struct("SlotAuctionDecisionV1", 3)?; + state.serialize_field("slot", slot)?; + state.serialize_field("outcome", "failed")?; + state.serialize_field("reason", reason)?; + state.end() + } + } + } +} + +/// Ordered version-1 decision set for one server auction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuctionDecisionSetV1 { + /// Contract version. + pub version: u8, + /// Exact auction identifier. + pub auction_id: String, + /// Exactly one decision per requested slot, in request order. + pub results: Vec, +} + +impl AuctionDecisionSetV1 { + /// Construct an ordered decision set for a request-wide gate. + #[must_use] + pub fn failed(request: &AuctionRequest, reason: AuctionSlotFailureReason) -> Self { + Self { + version: 1, + auction_id: request.id.clone(), + results: request + .slots + .iter() + .map(|slot| SlotAuctionDecisionV1::Failed { + slot: slot.id.clone(), + reason, + }) + .collect(), + } + } +} + +/// Maximum canonical UTF-8 size of the browser auction projection. +pub const MAX_BROWSER_AUCTION_PROJECTION_BYTES: usize = 8 * 1024 * 1024; +/// Maximum number of requested results or projected winner bids. +pub const MAX_BROWSER_AUCTION_RESULTS: usize = 256; +/// Maximum number of publisher targeting entries on one projected bid. +pub const MAX_BROWSER_AUCTION_TARGETING_ENTRIES: usize = 32; + +/// One exact browser-facing winner projection. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BrowserAuctionBidV1 { + /// Response-local mediator candidate identity. + pub candidate_id: String, + /// Exact requested server slot identity. + pub slot: String, + /// Canonical provider integration name. + pub provider: String, + /// Exact provider-native upstream bid identity. + pub upstream_bid_id: String, + /// Selected finite, nonnegative CPM. + pub cpm: f64, + /// Exact auction currency; version 1 admits only `USD`. + pub currency: String, + /// Lexically ordered publisher targeting, excluding runtime-owned `hb_adid`. + pub targeting: BTreeMap, + /// Server-minted renderer capability identity for APS/ADM only. + #[serde(skip_serializing_if = "Option::is_none")] + pub renderer_reservation_id: Option, + /// Sole tagged render authority for the winner. + pub render_source: BidRenderSourceV1, +} + +/// Exact GAM placement metadata required to publish one server-projected slot. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BrowserAuctionSlotV1 { + /// Exact server slot identity joined to one auction decision. + pub slot: String, + /// Fully rendered GAM ad-unit path for this navigation. + pub gam_unit_path: String, + /// Stable configured DOM id/prefix for responsive resolution. + pub div_id: String, + /// Accepted banner dimensions in configured order. + pub formats: Vec<[u32; 2]>, + /// Static publisher targeting applied before winner targeting. + pub targeting: BTreeMap, +} + +/// Complete browser-facing version-1 auction projection. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserAuctionProjectionV1 { + /// Contract version. + pub version: u8, + /// Ordered decision set for every requested slot. + pub auction: AuctionDecisionSetV1, + /// Ordered GAM placement definitions; empty only for direct `/auction` serialization. + pub slots: Vec, + /// Winner bids in matching decision order. + pub bids: Vec, +} + /// URL used by the orchestrator when invoking a mediator from the collect /// path. Providers can `debug_assert` against this value to catch a mediator /// that has accidentally started depending on `context.request` carrying real @@ -187,7 +576,7 @@ pub enum ApsTagType { /// Version 1 APS renderer descriptor shared with browser clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ApsRendererV1 { /// Renderer contract version. pub version: u8, @@ -210,22 +599,308 @@ pub struct ApsRendererV1 { pub height: u32, } -/// Typed browser renderer capability carried by a bid. +/// Version 1 inline ADM render source shared with browser clients. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AdmRenderSourceV1 { + /// Render-source contract version. + pub version: u8, + /// Exact creative markup. + pub adm: String, + /// Creative width. + pub width: u32, + /// Creative height. + pub height: u32, +} + +/// Thin version 1 carrier for the current GPT-owned PBS Cache behavior. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BaselinePbsCacheSourceV1 { + /// Render-source contract version. + pub version: u8, + /// Exact native PBS Cache identity. + pub cache_id: String, + /// Exact current-main `hb_cache_host` value. + pub cache_host: String, + /// Exact current-main `hb_cache_path` value. + pub cache_path: String, + /// Winning width transported without cache-specific validation. + pub width: u32, + /// Winning height transported without cache-specific validation. + pub height: u32, +} + +/// Typed browser render source carried by a bid. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum BidRenderer { +#[serde(tag = "type", rename_all = "snake_case")] +pub enum BidRenderSourceV1 { /// APS renderer version 1. Aps(ApsRendererV1), + /// Inline ADM version 1. + Adm(AdmRenderSourceV1), + /// Current-main GPT-owned PBS Cache carrier. + PbsCache(BaselinePbsCacheSourceV1), } -impl BidRenderer { +impl BidRenderSourceV1 { /// Return the APS renderer descriptor when this is an APS renderer. #[must_use] pub fn as_aps(&self) -> Option<&ApsRendererV1> { match self { Self::Aps(renderer) => Some(renderer), + Self::Adm(_) | Self::PbsCache(_) => None, + } + } +} + +/// Smallest accepted renderer dimension in CSS pixels. +pub const RENDER_DIMENSION_MIN: u64 = 1; +/// Largest accepted renderer dimension in CSS pixels. +pub const RENDER_DIMENSION_MAX: u64 = 4096; + +const MAX_APS_ACCOUNT_ID_BYTES: usize = 1024; +const MAX_APS_BID_ID_BYTES: usize = 64; +const MAX_APS_CREATIVE_ID_BYTES: usize = 1024; +const MAX_APS_CREATIVE_URL_BYTES: usize = 4096; +const MAX_APS_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; +const MAX_APS_RENDER_ENVELOPE_BASE64_BYTES: usize = 4 * MAX_APS_RENDER_ENVELOPE_BYTES.div_ceil(3); + +/// Cross-language APS descriptor validation result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApsRendererValidationResult { + /// Descriptor and decoded envelope are valid and agree. + Accepted, + /// Descriptor or decoded envelope is malformed. + DescriptorInvalid, + /// A dimension has the wrong type or is nonfinite, fractional, zero, or negative. + InvalidDimensions, + /// An otherwise integral positive dimension is outside the supported range. + DimensionsOutOfRange, +} + +impl ApsRendererValidationResult { + /// Return the exact browser failure/result literal. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::DescriptorInvalid => "descriptor_invalid", + Self::InvalidDimensions => "invalid_dimensions", + Self::DimensionsOutOfRange => "dimensions_out_of_range", + } + } +} + +fn has_exact_json_keys(value: &serde_json::Value, expected: &[&str]) -> bool { + value.as_object().is_some_and(|object| { + object.len() == expected.len() && expected.iter().all(|key| object.contains_key(*key)) + }) +} + +fn classify_render_dimension(value: &serde_json::Value) -> ApsRendererValidationResult { + let Some(number) = value.as_f64() else { + return ApsRendererValidationResult::InvalidDimensions; + }; + if !number.is_finite() || number.fract() != 0.0 || number <= 0.0 { + return ApsRendererValidationResult::InvalidDimensions; + } + if number < RENDER_DIMENSION_MIN as f64 || number > RENDER_DIMENSION_MAX as f64 { + return ApsRendererValidationResult::DimensionsOutOfRange; + } + ApsRendererValidationResult::Accepted +} + +fn valid_aps_creative_url(value: &str, publisher_origin: &str) -> bool { + if value.len() > MAX_APS_CREATIVE_URL_BYTES { + return false; + } + let Ok(url) = Url::parse(value) else { + return false; + }; + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.origin().ascii_serialization() != publisher_origin +} + +/// Classify a raw APS renderer descriptor using the cross-language version-1 contract. +#[must_use] +pub fn classify_aps_renderer_v1( + value: &serde_json::Value, + publisher_origin: &str, +) -> ApsRendererValidationResult { + const REQUIRED_KEYS: &[&str] = &[ + "aaxResponse", + "accountId", + "bidId", + "creativeUrl", + "height", + "tagType", + "type", + "version", + "width", + ]; + const KEYS_WITH_CREATIVE_ID: &[&str] = &[ + "aaxResponse", + "accountId", + "bidId", + "creativeId", + "creativeUrl", + "height", + "tagType", + "type", + "version", + "width", + ]; + + if !has_exact_json_keys(value, REQUIRED_KEYS) + && !has_exact_json_keys(value, KEYS_WITH_CREATIVE_ID) + { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let Some(descriptor) = value.as_object() else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if descriptor.get("type").and_then(serde_json::Value::as_str) != Some("aps") + || descriptor + .get("version") + .and_then(serde_json::Value::as_f64) + .is_none_or(|version| version != 1.0) + { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let Some(account_id) = descriptor + .get("accountId") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + let Some(bid_id) = descriptor.get("bidId").and_then(serde_json::Value::as_str) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if account_id.is_empty() + || account_id.len() > MAX_APS_ACCOUNT_ID_BYTES + || bid_id.is_empty() + || bid_id.len() > MAX_APS_BID_ID_BYTES + || bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) + { + return ApsRendererValidationResult::DescriptorInvalid; + } + if let Some(creative_id) = descriptor.get("creativeId") { + let Some(creative_id) = creative_id.as_str() else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if creative_id.is_empty() || creative_id.len() > MAX_APS_CREATIVE_ID_BYTES { + return ApsRendererValidationResult::DescriptorInvalid; } } + let Some(tag_type) = descriptor + .get("tagType") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if tag_type != "iframe" && tag_type != "script" { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let width_result = + classify_render_dimension(descriptor.get("width").unwrap_or(&serde_json::Value::Null)); + if width_result != ApsRendererValidationResult::Accepted { + return width_result; + } + let height_result = + classify_render_dimension(descriptor.get("height").unwrap_or(&serde_json::Value::Null)); + if height_result != ApsRendererValidationResult::Accepted { + return height_result; + } + + let Some(creative_url) = descriptor + .get("creativeUrl") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + let Some(aax_response) = descriptor + .get("aaxResponse") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if !valid_aps_creative_url(creative_url, publisher_origin) + || aax_response.is_empty() + || aax_response.len() > MAX_APS_RENDER_ENVELOPE_BASE64_BYTES + { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Ok(decoded_bytes) = BASE64_STANDARD.decode(aax_response) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if decoded_bytes.len() > MAX_APS_RENDER_ENVELOPE_BYTES + || BASE64_STANDARD.encode(&decoded_bytes) != aax_response + { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Ok(decoded_utf8) = core::str::from_utf8(&decoded_bytes) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + let Ok(decoded) = serde_json::from_str::(decoded_utf8) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if !has_exact_json_keys(&decoded, &["seatbid"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Some(seats) = decoded.get("seatbid").and_then(serde_json::Value::as_array) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if seats.len() != 1 || !has_exact_json_keys(&seats[0], &["bid"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Some(bids) = seats[0].get("bid").and_then(serde_json::Value::as_array) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if bids.len() != 1 || !has_exact_json_keys(&bids[0], &["ext", "h", "id", "price", "w"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + let bid = &bids[0]; + let Some(ext) = bid.get("ext") else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if !has_exact_json_keys(ext, &["creativeurl", "tagtype"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let bid_width_result = + classify_render_dimension(bid.get("w").unwrap_or(&serde_json::Value::Null)); + if bid_width_result != ApsRendererValidationResult::Accepted { + return bid_width_result; + } + let bid_height_result = + classify_render_dimension(bid.get("h").unwrap_or(&serde_json::Value::Null)); + if bid_height_result != ApsRendererValidationResult::Accepted { + return bid_height_result; + } + let price_is_valid = bid + .get("price") + .and_then(serde_json::Value::as_f64) + .is_some_and(|price| price.is_finite() && price >= 0.0); + if bid.get("id").and_then(serde_json::Value::as_str) != Some(bid_id) + || bid.get("w").and_then(serde_json::Value::as_f64) + != descriptor.get("width").and_then(serde_json::Value::as_f64) + || bid.get("h").and_then(serde_json::Value::as_f64) + != descriptor.get("height").and_then(serde_json::Value::as_f64) + || ext.get("creativeurl").and_then(serde_json::Value::as_str) != Some(creative_url) + || ext.get("tagtype").and_then(serde_json::Value::as_str) != Some(tag_type) + || !price_is_valid + { + return ApsRendererValidationResult::DescriptorInvalid; + } + + ApsRendererValidationResult::Accepted } /// Individual bid from a provider. @@ -233,13 +908,22 @@ impl BidRenderer { pub struct Bid { /// Slot this bid is for pub slot_id: String, + /// Server-minted opaque identifier used only for this auction response. + #[serde(skip)] + pub candidate_id: Option, + /// Provider integration name paired with the upstream bid ID for provenance. + #[serde(skip)] + pub candidate_provider: Option, + /// Server-minted renderer capability identifier (populated during projection). + #[serde(skip)] + pub renderer_reservation_id: Option, /// Bid price in CPM. pub price: Option, /// Currency code (e.g., "USD") pub currency: String, /// Creative markup (HTML/VAST). /// - /// `None` when the bid uses a typed [`BidRenderer`] instead. + /// `None` when the bid uses a typed [`BidRenderSourceV1`] instead. pub creative: Option, /// Advertiser domain pub adomain: Option>, @@ -267,7 +951,7 @@ pub struct Bid { pub creative_id: Option, /// Typed browser renderer capability. #[serde(skip_serializing_if = "Option::is_none")] - pub renderer: Option, + pub renderer: Option, /// Prebid Cache UUID for this bid. /// /// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response. @@ -288,6 +972,28 @@ pub struct Bid { pub metadata: HashMap, } +/// Length of the hex-encoded creative trace hash. +const ADM_TRACE_HASH_LEN: usize = 16; + +/// Compute the trace hash for delivered creative markup. +#[must_use] +pub fn adm_trace_hash(adm: &str) -> String { + use sha2::{Digest as _, Sha256}; + + let digest = Sha256::digest(adm.as_bytes()); + let mut hex = hex::encode(digest); + hex.truncate(ADM_TRACE_HASH_LEN); + hex +} + +impl Bid { + /// Trace hash of this bid's creative markup, when present. + #[must_use] + pub fn creative_trace_hash(&self) -> Option { + self.creative.as_deref().map(adm_trace_hash) + } +} + /// Per-provider summary included in the auction response. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderSummary { @@ -338,7 +1044,7 @@ pub struct OrchestratorExt { pub dropped_winner_count: usize, /// Machine-readable reasons for omitted winners. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub dropped_winner_reasons: BTreeMap, + pub dropped_winner_reasons: AuctionDropReasons, } /// Status of bid response. @@ -394,6 +1100,30 @@ impl AuctionResponse { self.metadata.insert(key.into(), value); self } + + /// Project typed local drop reasons into the existing provider metadata surface. + #[must_use] + pub fn with_drop_reasons(mut self, reasons: &AuctionDropReasons) -> Self { + if !reasons.is_empty() { + let values = reasons + .iter() + .map(|(reason, count)| { + (reason.as_str().to_string(), serde_json::Value::from(*count)) + }) + .collect(); + self.metadata.insert( + "drop_reasons".to_string(), + serde_json::Value::Object(values), + ); + } + self + } + + /// Project one typed local drop reason into provider metadata. + #[must_use] + pub fn with_drop_reason(self, reason: AuctionDropReason) -> Self { + self.with_drop_reasons(&BTreeMap::from([(reason, 1)])) + } } #[cfg(test)] @@ -401,9 +1131,59 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn typed_drop_reasons_use_exact_literals_in_provider_summary_metadata() { + let reasons = [ + AuctionDropReason::CreativeProcessingRejected, + AuctionDropReason::InvalidCreativeId, + AuctionDropReason::CreativeIdTooLarge, + AuctionDropReason::DimensionsOutOfRange, + AuctionDropReason::DuplicateUpstreamBidId, + AuctionDropReason::EmptySeatBid, + AuctionDropReason::EmptySeatBidBids, + AuctionDropReason::InvalidCreativeUrl, + AuctionDropReason::InvalidDimensions, + AuctionDropReason::InvalidPrice, + AuctionDropReason::InvalidProviderResponse, + AuctionDropReason::InvalidTagType, + AuctionDropReason::InvalidUpstreamBidId, + AuctionDropReason::LostToHigherBid, + AuctionDropReason::MalformedBid, + AuctionDropReason::MissingCreativeUrl, + AuctionDropReason::MissingRequestContext, + AuctionDropReason::MissingUpstreamBidId, + AuctionDropReason::MultipleRenderSources, + AuctionDropReason::NoRenderSource, + AuctionDropReason::RendererExtensionSerializationFailed, + AuctionDropReason::RenderPayloadTooLarge, + AuctionDropReason::ScriptRenderingDisabled, + AuctionDropReason::UnknownImpression, + AuctionDropReason::UnsupportedMediaType, + AuctionDropReason::UpstreamBidIdTooLarge, + ]; + for reason in reasons { + assert_eq!( + serde_json::to_value(reason).expect("drop reason should serialize"), + json!(reason.as_str()), + "serde and diagnostic literal should agree for {reason:?}" + ); + } + + let response = AuctionResponse::no_bid("aps", 12) + .with_drop_reason(AuctionDropReason::InvalidProviderResponse); + let summary = ProviderSummary::from(&response); + assert_eq!( + summary.metadata["drop_reasons"]["invalid_provider_response"], 1, + "publisher provider-summary projection should retain the typed reason" + ); + } + fn make_bid(bidder: &str) -> Bid { Bid { slot_id: "slot-1".to_owned(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.0), currency: "USD".to_owned(), creative: None, @@ -536,6 +1316,9 @@ mod tests { fn bid_with_cache_fields_round_trips_through_json() { let bid = Bid { slot_id: "atf".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.50), currency: "USD".to_string(), creative: None, @@ -575,7 +1358,7 @@ mod tests { #[test] fn aps_renderer_serializes_to_versioned_camel_case_contract() { - let renderer = BidRenderer::Aps(ApsRendererV1 { + let renderer = BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account-id".to_string(), bid_id: "fictional-bid-id".to_string(), @@ -609,7 +1392,7 @@ mod tests { #[test] fn aps_renderer_omits_absent_creative_id() { - let renderer = BidRenderer::Aps(ApsRendererV1 { + let renderer = BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account-id".to_string(), bid_id: "fictional-bid-id".to_string(), @@ -638,10 +1421,40 @@ mod tests { ); } + #[test] + fn slot_failure_priority_matches_the_closed_contract() { + let ordered = [ + AuctionSlotFailureReason::InternalError, + AuctionSlotFailureReason::MediationFailed, + AuctionSlotFailureReason::InvalidProviderResponse, + AuctionSlotFailureReason::ProviderError, + AuctionSlotFailureReason::ProviderTimeout, + AuctionSlotFailureReason::ConsentDenied, + AuctionSlotFailureReason::AuctionDisabled, + AuctionSlotFailureReason::SlotNotEligible, + ]; + + assert_eq!( + ordered.map(AuctionSlotFailureReason::priority), + [0, 1, 2, 3, 4, 5, 6, 7] + ); + assert_eq!( + AuctionSlotFailureReason::WinnerNotRenderable.priority(), + u8::MAX + ); + assert_eq!( + AuctionSlotFailureReason::IdentityGenerationFailed.priority(), + u8::MAX + ); + } + #[test] fn bid_has_ad_id_field() { let bid = Bid { slot_id: "s".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(1.0), currency: "USD".to_string(), creative: None, diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index 8e70aa020..a58cf5561 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -269,9 +269,8 @@ mod tests { /// handler covers is the operator's decision, and silently carving holes in /// it would be worse than a documented constraint. Operators must scope /// handler patterns to the paths they mean (`^/_ts/admin`) — see the - /// configuration guide. The tsjs client's `/__ts/page-bids` fallback keeps - /// affected deployments serving SPA ads until they do, but it disappears - /// with the alias in IABTechLab/trusted-server#970. + /// configuration guide. A broad pattern will block the canonical page-bids + /// endpoint; the hard-cutover client does not retry a compatibility alias. #[test] fn broad_handler_regex_also_covers_browser_facing_endpoints() { let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "^/_ts""#); diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index e1152b1e7..828311f12 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -5,6 +5,7 @@ pub const COOKIE_TS_EC: &str = "ts-ec"; /// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. pub const COOKIE_TS_EIDS: &str = "ts-eids"; pub const COOKIE_TS_TESTER: &str = "ts-tester"; +pub const COOKIE_TS_TRACE: &str = "ts-trace"; pub const COOKIE_SHAREDID: &str = "sharedId"; pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id"); diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs index a4d641bdf..232cb2781 100644 --- a/crates/trusted-server-core/src/creative.rs +++ b/crates/trusted-server-core/src/creative.rs @@ -574,8 +574,8 @@ fn process_auction_creative_with_rewriter( /// - 1x1 `` pixels → `/first-party/proxy?tsurl=<base-url><params>&tstoken=<sig>` /// - Non-pixel absolute images → `/first-party/proxy?tsurl=<base-url><params>&tstoken=<sig>` /// - `', - ...(debugBid ? { debug_bid: { slot_id: 'atf_sidebar_ad' } } : {}), - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - // A pre-existing GAM iframe; the bypass, if it runs, rewrites its src. - const slotEl = document.getElementById('div-atf-sidebar')!; - const gamIframe = document.createElement('iframe'); - gamIframe.src = 'about:blank'; - slotEl.appendChild(gamIframe); - - expect(capturedListener).toBeDefined(); - capturedListener!({ isEmpty: false, slot: mockSlot }); - return gamIframe; - } - - it('does not run the GAM-replace bypass without debug_bid (production)', async () => { - const gamIframe = await fireSlotRenderWithAdm(false); - // No debug_bid ⇒ testing bypass is off; the render bridge handles the creative - // and GAM stays in the loop, so the GAM iframe src is untouched. - expect(gamIframe.src).toBe('about:blank'); - }); - - it('runs the GAM-replace bypass when debug_bid is present (testing)', async () => { - const gamIframe = await fireSlotRenderWithAdm(true); - // debug_bid present ⇒ inject_adm_for_testing on ⇒ direct GAM replace fires, - // rewriting the iframe to the creative URL from the adm. - expect(gamIframe.src).toBe('https://cdn.example/creative.html'); - }); - - it('does not fire win/billing beacons from slotRenderEnded targeting alone', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['abc']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(capturedListener).toBeDefined(); - capturedListener!({ isEmpty: false, slot: mockSlot }); - - expect(beaconSpy).not.toHaveBeenCalled(); - - // GPT slot targeting is request state, not proof that the TS creative - // rendered. A repeated non-empty render must still not bill from this path. - capturedListener!({ isEmpty: false, slot: mockSlot }); - expect(beaconSpy).not.toHaveBeenCalled(); - - beaconSpy.mockRestore(); - }); - - it('does not fire beacons for an APS-style bid that carries no hb_adid', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.50', - hb_bidder: 'aps', - nurl: 'https://aps/win', - burl: 'https://aps/bill', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(capturedListener).toBeDefined(); - - // Without an hb_adid to confirm the rendered creative is ours, a non-empty - // render is not proof of a TS win: the slot could have been filled by other - // GAM demand. The beacon must not fire, so we never over-report billing. - capturedListener!({ isEmpty: false, slot: mockSlot }); - expect(beaconSpy).not.toHaveBeenCalled(); - - beaconSpy.mockRestore(); - }); - - it('does not fire nurl/burl when bid did not win GAM line item', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlotNoMatch = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['OTHER_BID_ID']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlotNoMatch]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlotNoMatch), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }); - - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('does not fire beacons for slotRenderEnded on slots not owned by TS', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['abc']), - }; - const arenaSlot = { - getSlotElementId: () => 'arena-owned-div', - getTargeting: () => [], - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc' }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - capturedListener!({ isEmpty: false, slot: arenaSlot }); - - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('does not call native apstag for a Trusted Server APS renderer winner', async () => { - const setDisplayBidsSpy = vi.fn(); - (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.50', - hb_bidder: 'aps', - hb_adid: envelope.seatbid[0].bid[0].id, - renderer: apsRenderer(), - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(setDisplayBidsSpy).not.toHaveBeenCalled(); - expect((window as TestWindow).apstag).toEqual({ setDisplayBids: setDisplayBidsSpy }); - - delete (window as TestWindow).apstag; - }); - - it('does not call apstag.setDisplayBids when hb_bidder is not aps', async () => { - const setDisplayBidsSpy = vi.fn(); - (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo' }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(setDisplayBidsSpy).not.toHaveBeenCalled(); - - delete (window as TestWindow).apstag; - }); - - it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { - const emptyTestSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([emptyTestSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - }), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(mockPubads.refresh).toHaveBeenCalled(); - }); - - it.each([ - { implementation: 'runtime', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 }, - { implementation: 'runtime', activeIndexes: [], selectedIndex: null }, - { - implementation: 'runtime', - candidateIndexes: [2], - activeIndexes: [], - selectedIndex: null, - }, - { - implementation: 'runtime', - activeIndexes: [], - elementLayoutIndexes: [1], - visibleContainerIndexes: [1], - selectedIndex: 1, - }, - { implementation: 'runtime', activeIndexes: [0, 2], selectedIndex: null }, - { - implementation: 'runtime', - activeIndexes: [2, 3], - hiddenElementIndexes: [2], - selectedIndex: 3, - }, - { - implementation: 'runtime', - activeIndexes: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [2], - selectedIndex: 2, - }, - { - implementation: 'runtime', - activeIndexes: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [1, 2], - containerWidthIndexes: [2], - selectedIndex: 2, - }, - { implementation: 'runtime', activeIndexes: [2], divId: '', selectedIndex: null }, - { implementation: 'bootstrap', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 }, - { implementation: 'bootstrap', activeIndexes: [], selectedIndex: null }, - { - implementation: 'bootstrap', - candidateIndexes: [2], - activeIndexes: [], - selectedIndex: null, - }, - { - implementation: 'bootstrap', - activeIndexes: [], - elementLayoutIndexes: [1], - visibleContainerIndexes: [1], - selectedIndex: 1, - }, - { implementation: 'bootstrap', activeIndexes: [0, 2], selectedIndex: null }, - { - implementation: 'bootstrap', - activeIndexes: [2, 3], - hiddenElementIndexes: [2], - selectedIndex: 3, - }, - { - implementation: 'bootstrap', - activeIndexes: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [2], - selectedIndex: 2, - }, - { - implementation: 'bootstrap', - activeIndexes: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [1, 2], - containerWidthIndexes: [2], - selectedIndex: 2, - }, - { implementation: 'bootstrap', activeIndexes: [2], divId: '', selectedIndex: null }, - ] as const)( - '$implementation resolves responsive matches $activeIndexes to $selectedIndex', - async (testCase) => { - const { implementation, activeIndexes, selectedIndex } = testCase; - const hiddenElementIndexes = - 'hiddenElementIndexes' in testCase ? testCase.hiddenElementIndexes : []; - const elementLayoutIndexes = - 'elementLayoutIndexes' in testCase ? testCase.elementLayoutIndexes : []; - const visibleContainerIndexes = - 'visibleContainerIndexes' in testCase ? testCase.visibleContainerIndexes : activeIndexes; - const containerWidthIndexes = - 'containerWidthIndexes' in testCase ? testCase.containerWidthIndexes : activeIndexes; - const containerHeightIndexes = - 'containerHeightIndexes' in testCase ? testCase.containerHeightIndexes : activeIndexes; - const elementWidthIndexes = - 'elementWidthIndexes' in testCase ? testCase.elementWidthIndexes : elementLayoutIndexes; - const elementHeightIndexes = - 'elementHeightIndexes' in testCase ? testCase.elementHeightIndexes : elementLayoutIndexes; - const candidateIndexes = - 'candidateIndexes' in testCase ? testCase.candidateIndexes : [0, 1, 2, 3]; - const divId = 'divId' in testCase ? testCase.divId : 'ad-responsive-'; - const publisherOwned = 'publisherOwned' in testCase && testCase.publisherOwned; - const elements = ['a', 'b', 'c', 'd'].map((suffix, index) => - appendResponsiveSlotElement( - (candidateIndexes as readonly number[]).includes(index) - ? `ad-responsive-${suffix}` - : `unrelated-responsive-${suffix}`, - { - containerVisible: (visibleContainerIndexes as readonly number[]).includes(index), - containerWidth: (containerWidthIndexes as readonly number[]).includes(index) ? 320 : 0, - containerHeight: (containerHeightIndexes as readonly number[]).includes(index) - ? 100 - : 0, - elementHidden: (hiddenElementIndexes as readonly number[]).includes(index), - elementWidth: (elementWidthIndexes as readonly number[]).includes(index) ? 300 : 0, - elementHeight: (elementHeightIndexes as readonly number[]).includes(index) ? 250 : 0, - } - ) - ); - const selectedElement = selectedIndex === null ? undefined : elements[selectedIndex]; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(selectedElement?.id ?? elements[0]!.id), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue(publisherOwned ? [mockSlot] : []), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const defineSlot = vi.fn().mockReturnValue(mockSlot); - const nativeDisplay = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'responsive_slot', - gam_unit_path: '/123/responsive', - div_id: divId, - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - if (implementation === 'runtime') { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - } else { - runGptBootstrap(); - } - (window as TestWindow).tsjs!.adInit!(); - - if (selectedElement) { - if (publisherOwned) { - expect(defineSlot).not.toHaveBeenCalled(); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - } else { - expect(defineSlot).toHaveBeenCalledWith( - '/123/responsive', - [[300, 250]], - selectedElement.id - ); - expect(nativeDisplay).toHaveBeenCalledWith(selectedElement.id); - } - expect((window as TestWindow).tsjs!.divToSlotId).toEqual({ - [selectedElement.id]: 'responsive_slot', - }); - } else { - expect(defineSlot).not.toHaveBeenCalled(); - expect((window as TestWindow).tsjs!.divToSlotId).toEqual({}); - } - } - ); - - it.each(['runtime', 'bootstrap'] as const)( - '$implementation reports an ambiguous prefix once during adInit', - async (implementation) => { - const elements = ['a', 'b', 'c', 'd'].map((suffix) => - appendResponsiveSlotElement(`ad-warning-${suffix}`, { containerVisible: true }) - ); - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elements[0]!.id), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - const bootstrapWarn = vi.fn(); - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'warning_slot', - gam_unit_path: '/123/warning', - div_id: 'ad-warning-', - formats: [[300, 250]], - targeting: {}, - }, - { - id: 'warning_slot_duplicate', - gam_unit_path: '/123/warning', - div_id: 'ad-warning-', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - ...(implementation === 'bootstrap' ? { log: { warn: bootstrapWarn } } : {}), - }; - - const runtimeWarn = vi.spyOn(console, 'warn'); - if (implementation === 'runtime') { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - } else { - runGptBootstrap(); - } - (window as TestWindow).tsjs!.adInit!(); - - if (implementation === 'runtime') { - const warningCall = runtimeWarn.mock.calls.find((call) => - call.includes('GPT slot prefix did not resolve to one active element') - ); - expect(runtimeWarn).toHaveBeenCalledTimes(1); - expect(warningCall).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - divId: 'ad-warning-', - prefixMatchCount: 4, - activeMatchCount: 0, - }), - ]) - ); - } else { - expect(bootstrapWarn).toHaveBeenCalledTimes(1); - expect(bootstrapWarn).toHaveBeenCalledWith( - 'GPT slot prefix did not resolve to one active element', - { - divId: 'ad-warning-', - prefixMatchCount: 4, - activeMatchCount: 0, - } - ); - } - runtimeWarn.mockRestore(); - } - ); - - it.each(['runtime', 'bootstrap'] as const)( - '$implementation trusts checkVisibility when resolving a visible slot', - async (implementation) => { - const element = appendResponsiveSlotElement('ad-native-slot', { - checkVisibility: true, - }); - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(element.id), - getTargeting: vi.fn().mockReturnValue([]), - }; - const defineSlot = vi.fn().mockReturnValue(mockSlot); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'native_visibility_slot', - gam_unit_path: '/123/native-visibility', - div_id: 'ad-native-', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - if (implementation === 'runtime') { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - } else { - runGptBootstrap(); - } - (window as TestWindow).tsjs!.adInit!(); - - expect(defineSlot).toHaveBeenCalledWith('/123/native-visibility', [[300, 250]], element.id); - } - ); - - it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => { - const dynamicDiv = document.createElement('div'); - dynamicDiv.id = "ad'prefix-real"; - document.body.appendChild(dynamicDiv); - - const dynamicSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue("ad'prefix-real"), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([dynamicSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(dynamicSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'dynamic_slot', - gam_unit_path: '/123/dynamic', - div_id: "ad'prefix-", - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(mockPubads.refresh).toHaveBeenCalledWith([dynamicSlot]); - }); -}); - -describe('parseCachedBid', () => { - async function parseCachedBid(body: string) { - const mod = await import('../../../src/integrations/gpt/index'); - return mod.parseCachedBid(body); - } - - it('decodes adm, dimensions, and price from a PBS Cache bid object', async () => { - const bid = await parseCachedBid( - JSON.stringify({ adm: '
cached
', w: 300, h: 250, price: 1.23 }) - ); - expect(bid).toEqual({ adm: '
cached
', width: 300, height: 250, price: 1.23 }); - }); - - it('accepts width/height as an alternate dimension spelling', async () => { - const bid = await parseCachedBid( - JSON.stringify({ adm: '
cached
', width: 728, height: 90 }) - ); - expect(bid?.width).toBe(728); - expect(bid?.height).toBe(90); - }); - - it('treats zero dimensions as absent so the caller falls back', async () => { - const bid = await parseCachedBid(JSON.stringify({ adm: '
cached
', w: 0, h: 0 })); - expect(bid?.width).toBeUndefined(); - expect(bid?.height).toBeUndefined(); - }); - - it('treats a non-JSON body as raw creative markup with no metadata', async () => { - const bid = await parseCachedBid('
raw
'); - expect(bid).toEqual({ adm: '
raw
' }); - }); - - it('returns undefined when the JSON payload carries no usable adm', async () => { - expect(await parseCachedBid(JSON.stringify({ w: 300, h: 250 }))).toBeUndefined(); - expect(await parseCachedBid(' ')).toBeUndefined(); - }); -}); - -describe('installTsRenderBridge', () => { - let fetchStub: ReturnType; - - beforeEach(() => { - vi.resetModules(); - // Remove ALL accumulated 'message' handlers from previous test module imports - // to prevent stale bridge listeners from intercepting our test event. - for (const handler of allMessageHandlers) { - window.removeEventListener('message', handler); - } - allMessageHandlers.length = 0; - - fetchStub = vi.fn(); - vi.stubGlobal('fetch', fetchStub); - if (typeof navigator.sendBeacon !== 'function') { - Object.defineProperty(navigator, 'sendBeacon', { - value: vi.fn().mockReturnValue(true), - writable: true, - configurable: true, - }); - } - - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'test-cache-uuid', - hb_bidder: 'kargo', - hb_pb: '1.50', - hb_cache_host: 'openads.example.com', - hb_cache_path: '/cache', - nurl: 'https://ssp.example/win', - burl: 'https://ssp.example/bill', - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header' }, - }; - }); - - afterEach(() => { - vi.unstubAllGlobals(); - document.getElementById('div-header')?.remove(); - delete (window as TestWindow).tsjs; - }); - - function createTrustedSlotIframe(divId = 'div-header'): Window { - const slot = document.createElement('div'); - slot.id = divId; - const iframe = document.createElement('iframe'); - slot.appendChild(iframe); - document.body.appendChild(slot); - return iframe.contentWindow!; - } - - async function captureBridgeListener(): Promise<(e: MessageEvent) => unknown> { - let bridgeListener: ((e: MessageEvent) => unknown) | undefined; - const origAdd = window.addEventListener.bind(window); - const addSpy = vi - .spyOn(window, 'addEventListener') - .mockImplementation( - (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { - if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; - origAdd( - type, - handler as EventListener, - opts as boolean | AddEventListenerOptions | undefined - ); - } - ); - await import('../../../src/integrations/gpt/index'); - addSpy.mockRestore(); - - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - return bridgeListener!; - } - - it('records an inline creative request and response with the same opaque attempt ID', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(41); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - tsjs.bids.homepage_header.adm = '
Creative
'; - delete tsjs.bids.homepage_header.nurl; - delete tsjs.bids.homepage_header.burl; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const postMessage = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(41); - expect(postMessage.mock.invocationCallOrder[0]).toBeLessThan( - recordTrustedServerCreativeResponse.mock.invocationCallOrder[0] - ); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - }); - - it('records no creative evidence for an ad ID the requesting slot does not own', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(42); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'someone-elses-ad-id' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(recordTrustedServerCreativeRequest).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - }); - - it.each([ - ['missing cache coordinates', {}], - ['incomplete cache coordinates', { hb_cache_host: 'cache.example.com' }], - ] as const)( - 'records missing_render_source for an exact-owned request with %s', - async (_description, cacheFields) => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(45); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - delete tsjs.bids.homepage_header.hb_cache_host; - delete tsjs.bids.homepage_header.hb_cache_path; - Object.assign(tsjs.bids.homepage_header, cacheFields); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(45, 'missing_render_source'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - expect(fetchStub).not.toHaveBeenCalled(); - } - ); - - it('records no failure when diagnostics declined to open a creative attempt', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(undefined); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - delete tsjs.bids.homepage_header.hb_cache_host; - delete tsjs.bids.homepage_header.hb_cache_path; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - - // Without an attempt ID there is nothing to attribute the failure to, and - // the missing-source fallback must still run untouched. - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('records response_post_failed when posting inline markup throws', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(46); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - tsjs.bids.homepage_header.adm = '
Creative
'; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [ - { - postMessage: vi.fn(() => { - throw new Error('port closed'); - }), - }, - ], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(stopImmediatePropagation).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(46, 'response_post_failed'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('serves a server APS renderer once and rejects a repeated request', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - hb_pb: '1.23', - renderer, - // These must not be used even if unexpected legacy fields coexist. - nurl: 'https://notify.example/win', - burl: 'https://notify.example/bill', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }; - - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (message: string) => portMessages.push(message) }; - const event = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent; - - bridgeListener(event); - bridgeListener(event); - - expect(stopSpy).toHaveBeenCalledTimes(2); - expect(fetchStub).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - // Server-rendered APS capabilities are one-shot per slot and ad ID. A - // repeated Universal Creative request is claimed but receives no payload. - expect(portMessages).toHaveLength(1); - const response = JSON.parse(portMessages[0]) as Record; - expect(Object.keys(response).sort()).toEqual( - [ - 'adId', - 'apsRenderer', - 'height', - 'message', - 'renderer', - 'rendererUrl', - 'rendererVersion', - 'width', - ].sort() - ); - expect(response).toEqual({ - message: 'Prebid Response', - adId: renderer.bidId, - renderer: expect.stringContaining('window.render=function'), - rendererVersion: 4, - rendererUrl: new URL('/integrations/aps/renderer', window.location.origin).href, - apsRenderer: renderer, - width: 300, - height: 250, - }); - expect(String(response.renderer)).not.toContain(renderer.accountId); - expect(String(response.renderer)).not.toContain(renderer.aaxResponse); - - // Universal Creative's dynamic-renderer path evaluates the returned static - // source and calls window.render(response, helper, targetWindow). Consume - // the exact bridge response through that deployed protocol shape. - const dynamicWindow = window as unknown as { - render?: (data: Record, helper: unknown, target: Window) => Promise; - }; - window.eval(String(response.renderer)); - try { - const rendered = dynamicWindow.render!(response, undefined, window); - const outerFrame = document.querySelector( - 'iframe[src*="/integrations/aps/renderer#tsaps="]' - )!; - expect(outerFrame).not.toBeNull(); - expect(outerFrame.getAttribute('sandbox')).not.toContain('allow-same-origin'); - - const rendererPost = vi.spyOn(outerFrame.contentWindow!, 'postMessage'); - outerFrame.dispatchEvent(new Event('load')); - const sent = rendererPost.mock.calls[0][0] as { nonce: string }; - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: outerFrame.contentWindow, - }) - ); - await expect(rendered).resolves.toBeUndefined(); - outerFrame.remove(); - } finally { - delete dynamicWindow.render; - } - beaconSpy.mockRestore(); - }); - - it('serves a registered Prebid APS renderer when its generated ad ID differs from the APS bid ID', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'prebid-generated-ad-id'; - const markUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const event = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent; - - bridgeListener(event); - const foreignIframe = document.createElement('iframe'); - document.body.appendChild(foreignIframe); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source: foreignIframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).toHaveBeenCalledTimes(2); - expect(portMessages).toHaveLength(1); - expect(markUsed).toHaveBeenCalledTimes(1); - expect(JSON.parse(portMessages[0])).toEqual( - expect.objectContaining({ - message: 'Prebid Response', - adId: prebidAdId, - apsRenderer: renderer, - width: renderer.width, - height: renderer.height, - }) - ); - expect(renderer.bidId).not.toBe(prebidAdId); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); - expect(fetchStub).not.toHaveBeenCalled(); - foreignIframe.remove(); - }); - - it('still serves the APS renderer when markUsed throws', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'throwing-mark-used-ad-id'; - const markUsed = vi.fn(() => { - throw new Error('fictional markUsed failure'); - }); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(portMessages).toHaveLength(1); - expect(JSON.parse(portMessages[0])).toEqual( - expect.objectContaining({ - message: 'Prebid Response', - adId: prebidAdId, - apsRenderer: renderer, - }) - ); - expect(markUsed).toHaveBeenCalledTimes(1); - }); - - it('prunes expired consumed APS renderer IDs', async () => { - vi.useFakeTimers(); - try { - const renderer = apsRenderer(); - const prebidAdId = 'expiring-consumed-ad-id'; - const start = Date.now(); - const firstMarkUsed = vi.fn(); - const secondMarkUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: start, - expiresAt: start + 60_000, - markUsed: firstMarkUsed, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - const portMessages: string[] = []; - const sendRequest = (): void => { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - }; - - sendRequest(); - vi.advanceTimersByTime(60_001); - (window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId] = { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed: secondMarkUsed, - }; - sendRequest(); - - expect(portMessages).toHaveLength(2); - expect(stopImmediatePropagation).toHaveBeenCalledTimes(2); - expect(firstMarkUsed).toHaveBeenCalledTimes(1); - expect(secondMarkUsed).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it('fails closed when consumed APS renderer tombstones reach capacity', async () => { - const renderer = apsRenderer(); - const capacity = 256; - const callbacks = Array.from({ length: capacity + 1 }, () => ({ - markUsed: vi.fn(), - })); - const entries = Object.fromEntries( - callbacks.map((lifecycle, index) => [ - `capacity-ad-${index}`, - { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - ...lifecycle, - }, - ]) - ); - (window as TestWindow).tsjs.apsPrebidRenderers = entries; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - const portMessages: string[] = []; - const sendRequest = (adId: string): void => { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - }; - - for (let index = 0; index < capacity; index += 1) { - sendRequest(`capacity-ad-${index}`); - } - sendRequest(`capacity-ad-${capacity}`); - sendRequest('capacity-ad-0'); - - expect(portMessages).toHaveLength(capacity); - expect(callbacks[capacity].markUsed).not.toHaveBeenCalled(); - expect(entries[`capacity-ad-${capacity}`]).toBeDefined(); - expect(callbacks[0].markUsed).toHaveBeenCalledTimes(1); - expect(stopImmediatePropagation).toHaveBeenCalledTimes(capacity + 2); - }); - - it('does not expose a registered Prebid APS renderer to another slot iframe', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'prebid-generated-ad-id'; - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed: vi.fn(), - }, - }; - - const footer = document.createElement('div'); - footer.id = 'div-footer'; - const foreignIframe = document.createElement('iframe'); - footer.appendChild(foreignIframe); - document.body.appendChild(footer); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source: foreignIframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).toHaveBeenCalledTimes(1); - expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeDefined(); - footer.remove(); - }); - - it('drops an expired Prebid APS renderer without claiming the creative request', async () => { - const prebidAdId = 'expired-prebid-ad-id'; - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer: apsRenderer(), - registeredAt: Date.now() - 61_000, - expiresAt: Date.now() - 1_000, - markUsed: vi.fn(), - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); - }); - - it('claims a TS-owned request before rejecting invalid APS data', async () => { - const renderer = { ...apsRenderer(), aaxResponse: 'invalid' }; - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).toHaveBeenCalledOnce(); - expect(portMessages).toEqual([]); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('accepts an APS request from a dynamic slot root resolved from its configured prefix', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - (window as TestWindow).tsjs.adSlots[0].div_id = 'div-header-'; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe('div-header-dynamic'); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(portMessages).toHaveLength(1); - document.getElementById('div-header-dynamic')?.remove(); - }); - - it('does not let an overlapping slot prefix claim another slot iframe', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - (window as TestWindow).tsjs.adSlots.push({ - id: 'homepage_header_mobile', - formats: [[320, 50]], - gam_unit_path: '/a/b/mobile', - div_id: 'div-header-mobile', - targeting: {}, - }); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe('div-header-mobile'); - const portMessages: string[] = []; - const stopSpy = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - document.getElementById('div-header-mobile')?.remove(); - }); - - it('ignores an APS ad ID requested by another configured slot', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - (window as TestWindow).tsjs.adSlots.push({ - id: 'homepage_footer', - formats: [[300, 250]], - gam_unit_path: '/a/b/footer', - div_id: 'div-footer', - targeting: {}, - }); - const footer = document.createElement('div'); - footer.id = 'div-footer'; - const foreignIframe = document.createElement('iframe'); - footer.appendChild(foreignIframe); - document.body.appendChild(footer); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source: foreignIframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - expect(fetchStub).not.toHaveBeenCalled(); - footer.remove(); - }); - - it('calls stopImmediatePropagation and fetches PBS Cache for a TS bid', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(43); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - const mockAd = '
Test Creative
'; - // PBS Cache (returnCreative=false) returns the cached bid as a JSON object; - // the creative lives under `adm`, not as the raw response body. The bridge - // must parse it and forward `adm`, mirroring the Prebid Universal Creative. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: mockAd, width: 728, height: 90 })), - } as Response); - - // Capture the bridge's 'message' listener at module-init time. - let bridgeListener: ((e: MessageEvent) => unknown) | undefined; - const origAdd = window.addEventListener.bind(window); - const addSpy = vi - .spyOn(window, 'addEventListener') - .mockImplementation( - (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { - if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; - origAdd( - type, - handler as EventListener, - opts as boolean | AddEventListenerOptions | undefined - ); - } - ); - await import('../../../src/integrations/gpt/index'); - addSpy.mockRestore(); // Restore only addEventListener — fetchStub must stay stubbed - - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const postMessage = vi.fn((message: string) => portMessages.push(message)); - const fakePort = { postMessage }; - const source = createTrustedSlotIframe(); - - // Dispatch the fake event — bridge listener fires synchronously, then runs - // fire-and-forget fetch().then() chains asynchronously. - bridgeListener!( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - // Flush microtasks so the fetch mock resolves and .then chains fire. - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).toHaveBeenCalledWith( - 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } - ); - expect(stopSpy).toHaveBeenCalled(); - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.message).toBe('Prebid Response'); - expect(parsed.adId).toBe('test-cache-uuid'); - expect(parsed.ad).toBe(mockAd); - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(43); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - expect(postMessage.mock.invocationCallOrder[0]).toBeLessThan( - recordTrustedServerCreativeResponse.mock.invocationCallOrder[0] - ); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); - expect(beaconSpy).toHaveBeenCalledTimes(2); - - bridgeListener!( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('does not classify a downstream cache-processing throw as cache_fetch_failed', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(54); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
Creative
' })), - } as Response); - const { log } = await import('../../../src/core/log'); - const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => { - throw new Error('success logging unavailable'); - }); - - try { - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const postMessage = vi.fn(); - const dispatch = () => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - dispatch(); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(postMessage).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(54); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - - // A second request must run after the first downstream failure, proving - // the in-flight key was still cleared by the promise's finally handler. - dispatch(); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(fetchStub).toHaveBeenCalledTimes(2); - expect(postMessage).toHaveBeenCalledTimes(2); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - } finally { - debugSpy.mockRestore(); - beaconSpy.mockRestore(); - } - }); - - it.each([ - [ - 'an HTTP non-ok response', - (stub: ReturnType) => - stub.mockResolvedValue({ ok: false, status: 503 } as Response), - ], - [ - 'a response body read rejection', - (stub: ReturnType) => - stub.mockResolvedValue({ - ok: true, - text: () => Promise.reject(new Error('body unavailable')), - } as Response), - ], - [ - 'a network rejection', - (stub: ReturnType) => stub.mockRejectedValue(new Error('network unavailable')), - ], - ] as const)('records cache_fetch_failed once for %s', async (_description, arrangeFetch) => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(47); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - arrangeFetch(fetchStub); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(47, 'cache_fetch_failed'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('records only response_post_failed when posting cached markup throws', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(48); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
Creative
' })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [ - { - postMessage: vi.fn(() => { - throw new Error('port closed'); - }), - }, - ], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(48, 'response_post_failed'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it.each(['request', 'response'] as const)( - 'keeps inline delivery and beacons unchanged when the diagnostics %s writer throws', - async (throwingWriter) => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn(() => { - if (throwingWriter === 'request') throw new Error('diagnostics request failed'); - return 49; - }); - const recordTrustedServerCreativeResponse = vi.fn(() => { - if (throwingWriter === 'response') throw new Error('diagnostics response failed'); - }); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - tsjs.bids.homepage_header.adm = '
Creative
'; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - const postMessage = vi.fn(); - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(stopImmediatePropagation).toHaveBeenCalledTimes(1); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(beaconSpy).toHaveBeenCalledTimes(2); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - if (throwingWriter === 'response') { - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(49); - } else { - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - } - beaconSpy.mockRestore(); - } - ); - - it('does not turn a throwing cache response diagnostic into a cache failure', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(50); - const recordTrustedServerCreativeResponse = vi.fn(() => { - throw new Error('diagnostics response failed'); - }); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
Creative
' })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const postMessage = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(postMessage).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(50); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('preserves missing-source fallback when the failure diagnostic throws', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(51); - const recordTrustedServerCreativeFailure = vi.fn(() => { - throw new Error('diagnostics failure writer failed'); - }); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse: vi.fn(), - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - delete tsjs.bids.homepage_header.hb_cache_host; - delete tsjs.bids.homepage_header.hb_cache_path; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(51, 'missing_render_source'); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('uses the adInit-resolved div when a responsive prefix becomes ambiguous', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve('
Responsive Creative
'), - } as Response); - - const resolvedSlot = document.createElement('div'); - resolvedSlot.id = 'div-responsive-a'; - const iframe = document.createElement('iframe'); - resolvedSlot.appendChild(iframe); - document.body.appendChild(resolvedSlot); - const laterSibling = document.createElement('div'); - laterSibling.id = 'div-responsive-b'; - document.body.appendChild(laterSibling); - - (window as TestWindow).tsjs!.adSlots = [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-responsive-', - targeting: {}, - }, - ]; - (window as TestWindow).tsjs!.divToSlotId = { - 'div-responsive-a': 'homepage_header', - }; - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const stopSpy = vi.fn(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source: iframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).toHaveBeenCalledWith( - 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } - ); - expect(portMessages).toHaveLength(1); - expect(stopSpy).toHaveBeenCalled(); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); - beaconSpy.mockRestore(); - }); - - it('declines to render when the PBS Cache response carries no adm', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(44); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - // A returnCreative=false JSON entry with no `adm` (VAST-only, or malformed). - // The bridge must NOT forward the serialized bid document to PUC. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ width: 728, height: 90 })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - // TS owns the adId so Prebid is still stopped, but with nothing renderable - // the bridge sends no Prebid Response and fires no win/billing beacons. - expect(fetchStub).toHaveBeenCalled(); - expect(stopSpy).toHaveBeenCalled(); - expect(portMessages).toHaveLength(0); - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(44, 'invalid_cache_payload'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('renders a non-JSON PBS Cache body as raw creative markup', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const rawAd = '
Raw Cached Creative
'; - // Backward compatibility: a cache that returns the creative markup directly - // (not a JSON bid object) is still rendered as-is. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(rawAd), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.ad).toBe(rawAd); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('sizes a PBS Cache render from the cached bid dimensions', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - // Cached bid is 300x250 while the slot's first format is 728x90 (from the - // default setup). The response must use the cached dimensions. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
cached
', w: 300, h: 250 })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.width).toBe(300); - expect(parsed.height).toBe(250); - beaconSpy.mockRestore(); - }); - - it('expands ${AUCTION_PRICE} from the cached bid price before responding', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - fetchStub.mockResolvedValue({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - adm: 'go', - price: 2.5, - }) - ), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.ad).toContain('p=2.5'); - expect(parsed.ad).not.toContain('${AUCTION_PRICE}'); - beaconSpy.mockRestore(); - }); - - it('fetches PBS Cache once when two same-adId messages race before the fetch resolves', async () => { - // Concurrent render double-fire guard: two 'Prebid Request' messages for the - // same adId can arrive before the first cache fetch settles. The in-flight - // `renderingAdIds` gate must collapse them to a single fetch — the persistent - // firedBeacons dedup only engages after a fetch resolves, so it cannot stop - // the second fetch on its own. Deferring the fetch keeps both messages in the - // window where only the in-flight gate can prevent the duplicate. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const mockAd = '
Test Creative
'; - let resolveFetch: (value: Response) => void = () => {}; - fetchStub.mockReturnValue( - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); - - const bridgeListener = await captureBridgeListener(); - - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - const dispatch = (): unknown => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - // Both messages dispatched before the deferred fetch resolves. - dispatch(); - dispatch(); - - // The second message hit the in-flight gate — only one fetch launched. - expect(fetchStub).toHaveBeenCalledTimes(1); - - // Resolve the single fetch and flush its .then chain. - resolveFetch({ ok: true, text: () => Promise.resolve(mockAd) } as Response); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).toHaveBeenCalledTimes(1); - expect(portMessages).toHaveLength(1); - // A single render still fires both win and billing beacons exactly once. - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('does not let one slot block a PBS Cache render for another slot sharing an adId', async () => { - // The in-flight guard must be scoped to the requesting slot, not the shared - // adId: two distinct slots sharing one hb_adid must each fetch and render. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - // Deferred fetch that stays pending, so both messages are in flight when we - // assert the launched-fetch count. - fetchStub.mockReturnValue(new Promise(() => {})); - (window as TestWindow).tsjs = { - bids: { - slot_a: { - hb_adid: 'shared-uuid', - hb_bidder: 'ix', - hb_pb: '1.00', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }, - slot_b: { - hb_adid: 'shared-uuid', - hb_bidder: 'ix', - hb_pb: '1.00', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }, - }, - adSlots: [ - { - id: 'slot_a', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a', - div_id: 'div-a', - targeting: {}, - }, - { - id: 'slot_b', - formats: [[300, 250]] as [number, number][], - gam_unit_path: '/a', - div_id: 'div-b', - targeting: {}, - }, - ], - divToSlotId: { 'div-a': 'slot_a', 'div-b': 'slot_b' }, - }; - - const bridgeListener = await captureBridgeListener(); - - const mkIframe = (divId: string): Window => { - const slot = document.createElement('div'); - slot.id = divId; - const iframe = document.createElement('iframe'); - slot.appendChild(iframe); - document.body.appendChild(slot); - return iframe.contentWindow!; - }; - const sourceA = mkIframe('div-a'); - const sourceB = mkIframe('div-b'); - - try { - for (const source of [sourceA, sourceB]) { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'shared-uuid' }), - ports: [{ postMessage: () => {} }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - } - - // Each slot launches its own fetch — the shared adId does not cross-block. - expect(fetchStub).toHaveBeenCalledTimes(2); - } finally { - document.getElementById('div-a')?.remove(); - document.getElementById('div-b')?.remove(); - beaconSpy.mockRestore(); - } - }); - - it('serves inline adm without fetching PBS Cache even when cache coords are present', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const inlineAdm = '
Inline Creative
'; - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'debug-adid', - hb_bidder: 'mocktioneer', - hb_pb: '0.20', - // Production shape: cache coordinates ARE present, but the bridge must - // prefer the local inline adm and skip the PBS Cache fetch. - hb_cache_host: 'cache.example.com', - hb_cache_path: '/pbc/v1/cache', - nurl: 'https://debug.example/win', - burl: 'https://debug.example/bill', - adm: inlineAdm, - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header' }, - }; - - let bridgeListener: ((e: MessageEvent) => unknown) | undefined; - const origAdd = window.addEventListener.bind(window); - const addSpy = vi - .spyOn(window, 'addEventListener') - .mockImplementation( - (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { - if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; - origAdd( - type, - handler as EventListener, - opts as boolean | AddEventListenerOptions | undefined - ); - } - ); - await import('../../../src/integrations/gpt/index'); - addSpy.mockRestore(); - - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener!( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-adid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).not.toHaveBeenCalled(); - expect(stopSpy).toHaveBeenCalled(); - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.message).toBe('Prebid Response'); - expect(parsed.adId).toBe('debug-adid'); - expect(parsed.ad).toBe(inlineAdm); - expect(parsed.width).toBe(728); - expect(parsed.height).toBe(90); - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/bill'); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('sizes the inline response from the winning bid, not the first slot format', async () => { - // Multi-size slot whose winner is the SECOND configured format. Sizing from - // slot.formats[0] would render the 300x250 winner in a 728x90 box. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const winnerAdm = '
Winner 300x250
'; - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'winner-adid', - hb_bidder: 'ix', - hb_pb: '2.00', - w: 300, - h: 250, - adm: winnerAdm, - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [ - [728, 90], - [300, 250], - ] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header' }, - }; - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - try { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'winner-adid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - expect(parsed.width).toBe(300); - expect(parsed.height).toBe(250); - } finally { - beaconSpy.mockRestore(); - } - }); - - it('resolves the requesting slot bid when two slots share one hb_adid', async () => { - // Duplicate hb_adid across slots: PBS Cache is absent, so hb_adid falls back - // to a creative id that a bidder reuses across slots. The bridge must resolve - // the bid by the requesting slot, not the first bid whose hb_adid matches — - // otherwise every slot but the first renders blank. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const headerAdm = '
Header Creative
'; - const inContentAdm = '
In-Content Creative
'; - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'shared-creative-id', - hb_bidder: 'ix', - hb_pb: '0.53', - adm: headerAdm, - }, - homepage_in_content: { - hb_adid: 'shared-creative-id', - hb_bidder: 'ix', - hb_pb: '0.40', - adm: inContentAdm, - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - { - id: 'homepage_in_content', - formats: [[300, 250]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-in-content', - targeting: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header', 'div-in-content': 'homepage_in_content' }, - }; - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - - // Iframe belongs to the SECOND slot, whose bid is not the first hb_adid match. - const slot = document.createElement('div'); - slot.id = 'div-in-content'; - const iframe = document.createElement('iframe'); - slot.appendChild(iframe); - document.body.appendChild(slot); - const source = iframe.contentWindow!; - - try { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'shared-creative-id' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; - // The requesting slot's own creative and dimensions, not the first match's. - expect(parsed.ad).toBe(inContentAdm); - expect(parsed.width).toBe(300); - expect(parsed.height).toBe(250); - } finally { - slot.remove(); - beaconSpy.mockRestore(); - } - }); - - it('falls back to keepalive fetch when sendBeacon is unavailable', async () => { - const originalSendBeacon = navigator.sendBeacon; - Object.defineProperty(navigator, 'sendBeacon', { - value: undefined, - writable: true, - configurable: true, - }); - - try { - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: 'debug-no-beacon', - hb_bidder: 'mocktioneer', - hb_pb: '0.20', - nurl: 'https://debug.example/win', - burl: 'https://debug.example/bill', - adm: '
Debug Creative
', - }; - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-no-beacon' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/win', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/bill', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - } finally { - Object.defineProperty(navigator, 'sendBeacon', { - value: originalSendBeacon, - writable: true, - configurable: true, - }); - } - }); - - it('falls back to keepalive fetch when sendBeacon rejects the payload', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(false); - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: 'debug-rejected-beacon', - hb_bidder: 'mocktioneer', - hb_pb: '0.20', - nurl: 'https://debug.example/win', - burl: 'https://debug.example/bill', - adm: '
Debug Creative
', - }; - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - const event = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-rejected-beacon' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent; - - bridgeListener(event); - - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/bill'); - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/win', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/bill', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - - bridgeListener(event); - expect(fetchStub).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('ignores message when adId does not match any TS bid', async () => { - await import('../../../src/integrations/gpt/index'); - fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); - - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ message: 'Prebid Request', adId: 'unknown-id' }), - ports: [], - }) - ); - - await new Promise((r) => setTimeout(r, 100)); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('ignores matching adId messages from outside configured slot iframes', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(52); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - await import('../../../src/integrations/gpt/index'); - fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); - - const foreignIframe = document.createElement('iframe'); - document.body.appendChild(foreignIframe); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const stopSpy = vi.fn(); - - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort as unknown as MessagePort], - source: foreignIframe.contentWindow, - }) - ); - - await new Promise((r) => setTimeout(r, 50)); - expect(fetchStub).not.toHaveBeenCalled(); - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toHaveLength(0); - expect(recordTrustedServerCreativeRequest).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - foreignIframe.remove(); - }); - - it('ignores a request whose source slot does not own the resolved adId', async () => { - // Two configured slots; slot A's iframe requests slot B's hb_adid. The - // bridge must not return slot B's creative or fire slot B's beacons. - (window as TestWindow).tsjs!.bids!.homepage_footer = { - hb_adid: 'footer-uuid', - hb_bidder: 'kargo', - hb_pb: '2.00', - hb_cache_host: 'openads.example.com', - hb_cache_path: '/cache', - nurl: 'https://ssp.example/footer-win', - burl: 'https://ssp.example/footer-bill', - }; - (window as TestWindow).tsjs!.adSlots!.push({ - id: 'homepage_footer', - formats: [[300, 250]] as [number, number][], - gam_unit_path: '/a/b/footer', - div_id: 'div-footer', - targeting: {}, - }); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(53); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - await import('../../../src/integrations/gpt/index'); - fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); - - // Source iframe lives under slot A (div-header). - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - - window.dispatchEvent( - new MessageEvent('message', { - // adId belongs to slot B (homepage_footer), not slot A's iframe. - data: JSON.stringify({ message: 'Prebid Request', adId: 'footer-uuid' }), - ports: [fakePort as unknown as MessagePort], - source, - }) - ); - - await new Promise((r) => setTimeout(r, 50)); - expect(fetchStub).not.toHaveBeenCalled(); - expect(portMessages).toHaveLength(0); - expect(beaconSpy).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeRequest).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - document.getElementById('div-footer')?.remove(); - }); - - it('ignores non-Prebid messages', async () => { - await import('../../../src/integrations/gpt/index'); - window.dispatchEvent( - new MessageEvent('message', { data: JSON.stringify({ message: 'Other' }) }) - ); - await new Promise((r) => setTimeout(r, 50)); - expect(fetchStub).not.toHaveBeenCalled(); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/diagnostics_facts.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/diagnostics_facts.test.ts new file mode 100644 index 000000000..d092982cd --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/diagnostics_facts.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import type { + GoogletagAdapter, + GoogletagDiagnosticsFact, + GoogletagDiagnosticsObserver, + GoogletagFacade, +} from '../../../src/adapters/googletag'; +import { + activateGptDiagnosticsEventListeners, + activateGptDiagnosticsFactCapture, + createGptDiagnosticsFactBuffer, + projectGptTraceFact, +} from '../../../src/integrations/gpt/diagnostics_facts'; + +function fact(index: number): Readonly { + return Object.freeze({ + kind: 'slotRequested', + observedAtMs: index, + slot: Object.freeze({ + token: Object.freeze(Object.create(null) as object), + elementId: `slot-${index}`, + }), + }); +} + +describe('GPT diagnostics fact transport', () => { + it('projects only the data-safe exact trace identity and preserves event fields', () => { + const opaqueToken = Object.freeze(Object.create(null) as object); + const projected = projectGptTraceFact( + Object.freeze({ + kind: 'slotRenderEnded', + observedAtMs: 12.5, + slot: Object.freeze({ + token: opaqueToken, + traceToken: 'gt1_z', + cycleOrdinal: 7, + elementId: 'fictional-slot', + adUnitPath: '/example/fictional-slot', + }), + isEmpty: false, + responseIdentifier: 'fictional-response', + }) as Readonly + ); + + expect(projected).toEqual({ + kind: 'slotRenderEnded', + observedAtMs: 12.5, + slot: { token: 'gt1_z', cycleOrdinal: 7, elementId: 'fictional-slot' }, + isEmpty: false, + responseIdentifier: 'fictional-response', + }); + expect(Object.isFrozen(projected)).toBe(true); + expect(Object.isFrozen(projected?.slot)).toBe(true); + expect(Reflect.ownKeys(projected?.slot ?? {}).sort()).toEqual([ + 'cycleOrdinal', + 'elementId', + 'token', + ]); + expect(Object.values(projected?.slot ?? {})).not.toContain(opaqueToken); + expect(JSON.stringify(projected)).not.toContain('/example/fictional-slot'); + }); + + it.each([ + ['missing cycle', Object.freeze({ token: Object.freeze({}), traceToken: 'gt1_1' })], + [ + 'zero cycle', + Object.freeze({ token: Object.freeze({}), traceToken: 'gt1_1', cycleOrdinal: 0 }), + ], + [ + 'overflow cycle', + Object.freeze({ + token: Object.freeze({}), + traceToken: 'gt1_1', + cycleOrdinal: 4_294_967_296, + }), + ], + [ + 'noncanonical token', + Object.freeze({ token: Object.freeze({}), traceToken: 'gt1_01', cycleOrdinal: 1 }), + ], + [ + 'overflow token', + Object.freeze({ token: Object.freeze({}), traceToken: 'gt1_10000000', cycleOrdinal: 1 }), + ], + ])('omits %s trace projections without changing the raw fact', (_label, slot) => { + const raw = Object.freeze({ kind: 'slotRequested', observedAtMs: 1, slot }); + + expect(projectGptTraceFact(raw as Readonly)).toBeUndefined(); + expect(raw.slot).toBe(slot); + }); + + it('requires diagnostics observation on every GPT adapter', () => { + expectTypeOf().toMatchTypeOf<{ + observeDiagnostics(observer: GoogletagDiagnosticsObserver): (() => void) | undefined; + }>(); + }); + + it('buffers 512 facts, evicts the oldest, replays in order, then releases the buffer', () => { + const buffer = createGptDiagnosticsFactBuffer(); + for (let index = 0; index < 513; index += 1) expect(buffer.publish(fact(index))).toBe(true); + const received: number[] = []; + + const release = buffer.activate((item) => { + received.push(Number(item.slot.elementId?.slice('slot-'.length))); + }); + + expect(received).toHaveLength(512); + expect(received[0]).toBe(1); + expect(received[511]).toBe(512); + expect(buffer.publish(fact(513))).toBe(true); + expect(received[512]).toBe(513); + release?.(); + expect(buffer.publish(fact(514))).toBe(true); + expect(received).toHaveLength(513); + const replacement = vi.fn(); + expect(buffer.activate(replacement)).toEqual(expect.any(Function)); + expect(replacement).toHaveBeenCalledWith(fact(514)); + buffer.dispose(); + expect(buffer.publish(fact(515))).toBe(false); + }); + + it('isolates consumer throws and admits only one live module consumer', () => { + const errors: unknown[] = []; + const buffer = createGptDiagnosticsFactBuffer({ + onConsumerError: (error) => errors.push(error), + }); + buffer.publish(fact(1)); + const release = buffer.activate(() => { + throw new Error('fictional consumer failure'); + }); + + expect(errors).toHaveLength(1); + expect(buffer.activate(vi.fn())).toBeUndefined(); + expect(buffer.publish(fact(2))).toBe(true); + expect(errors).toHaveLength(2); + release?.(); + expect(buffer.activate(vi.fn())).toEqual(expect.any(Function)); + buffer.dispose(); + }); + + it('adds four diagnostics-only listeners while active and disposes all ownership', async () => { + const subscriptions: Array = []; + const releases: Array> = []; + let observer: GoogletagDiagnosticsObserver | undefined; + const operationDispose = vi.fn(); + const facade = Object.freeze({ + subscribe: ( + eventType: string, + _listener: (event: unknown) => void, + diagnosticsOwner?: boolean + ) => { + subscriptions.push([eventType, diagnosticsOwner]); + const release = vi.fn(); + releases.push(release); + return release; + }, + }) as unknown as Readonly; + const adapter = Object.freeze({ + observeDiagnostics: (candidate: GoogletagDiagnosticsObserver) => { + observer = candidate; + return () => { + observer = undefined; + }; + }, + run: (command: (gpt: Readonly) => Value) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }), + }) as unknown as GoogletagAdapter; + const buffer = createGptDiagnosticsFactBuffer(); + + const dispose = activateGptDiagnosticsFactCapture(adapter, buffer); + await Promise.resolve(); + + expect(observer).toEqual(expect.any(Function)); + expect(subscriptions).toEqual([ + ['slotResponseReceived', true], + ['slotOnload', true], + ['impressionViewable', true], + ['slotVisibilityChanged', true], + ]); + dispose?.(); + dispose?.(); + expect(operationDispose).toHaveBeenCalledOnce(); + expect(releases.every((release) => release.mock.calls.length === 1)).toBe(true); + expect(observer).toBeUndefined(); + }); + + it('rejects capture when another diagnostics observer owns the adapter', () => { + const run = vi.fn(); + const adapter = Object.freeze({ + observeDiagnostics: () => undefined, + run, + }) as unknown as Pick; + + expect( + activateGptDiagnosticsFactCapture(adapter, createGptDiagnosticsFactBuffer()) + ).toBeUndefined(); + expect(run).not.toHaveBeenCalled(); + }); + + it('lets the GPT owner install four diagnostics-only publishers without claiming observation', async () => { + const subscriptions: Array = []; + const releases: Array> = []; + const operationDispose = vi.fn(); + const facade = Object.freeze({ + subscribe: ( + eventType: string, + _listener: (event: unknown) => void, + diagnosticsOwner?: boolean + ) => { + subscriptions.push([eventType, diagnosticsOwner]); + const release = vi.fn(); + releases.push(release); + return release; + }, + }) as unknown as Readonly; + const observeDiagnostics = vi.fn(); + const adapter = Object.freeze({ + observeDiagnostics, + run: (command: (gpt: Readonly) => Value) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }), + }) as unknown as GoogletagAdapter; + + const dispose = activateGptDiagnosticsEventListeners(adapter); + await Promise.resolve(); + + expect(observeDiagnostics).not.toHaveBeenCalled(); + expect(subscriptions).toEqual([ + ['slotResponseReceived', true], + ['slotOnload', true], + ['impressionViewable', true], + ['slotVisibilityChanged', true], + ]); + dispose?.(); + dispose?.(); + expect(operationDispose).toHaveBeenCalledOnce(); + expect(releases.every((release) => release.mock.calls.length === 1)).toBe(true); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts deleted file mode 100644 index d3e1d7099..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import type { TsjsApi } from '../../../src/core/types'; - -/** - * Executable coverage for the edge-injected `gpt_bootstrap.js` — the - * head-inline fallback that keeps initial server-side ads working when the - * main TSJS bundle fails to load. The file ships from - * `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` and is - * evaluated here verbatim, so the degradation path (fallback `adInit` and - * fallback `scheduleInitialAdInit`) is executed, not string-matched. - * - * Vitest runs with the lib directory as cwd (the vitest.config.ts root), so - * the bootstrap is resolved relative to it rather than via import.meta.url, - * which the jsdom environment rewrites to a non-file scheme. - */ -const BOOTSTRAP_SOURCE = readFileSync( - path.resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' -); - -// The command queue the bootstrap pushes into: a real array once GPT has -// loaded, or the bare `push`-only stub GPT installs before then. -type MockCommandQueue = Array<() => void> | { push: (fn: () => void) => unknown }; - -// Minimal googletag surface the bootstrap touches. -interface MockGoogleTag { - cmd: MockCommandQueue; - defineSlot: (adUnitPath: string, sizes: Array<[number, number]>, divId: string) => unknown; - pubads: () => unknown; - enableServices: () => void; - display: (divId: string) => void; -} - -// `tsjs` is declared globally as the full `TsjsApi`; `Omit` drops it from -// `Window` so the fixtures below only have to satisfy the fields they set. -type TestWindow = Omit & { - googletag?: MockGoogleTag; - tsjs?: Partial; -}; - -function runBootstrap(): void { - // Evaluate in the jsdom global scope, exactly as an inline ')).toBeUndefined(); - expect(safeAdmIframeSrc('blob:https://example.com/uuid')).toBeUndefined(); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts new file mode 100644 index 000000000..9a215a8ab --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGptLaterIntegrationRegistration } from '../../../src/integrations/gpt/later'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +type NavigationResult = + | Readonly<{ + status: 'committed'; + navigationGeneration: object; + current: true; + }> + | Readonly<{ + status: 'rejected'; + navigationGeneration: object; + current: boolean; + }>; + +function harness() { + const navigationGeneration = Object.freeze({}); + const navigate = vi.fn<(_path: string) => Promise>(async (_path: string) => + Object.freeze({ status: 'committed', navigationGeneration, current: true }) + ); + const release = vi.fn(); + const activateLaterLifecycle = vi.fn(() => Object.freeze({ navigate, release })); + const preparationDisposers: Array<() => void> = []; + const activationDisposers: Array<() => void> = []; + const interfaces = Object.freeze({ + 'runtime.v1': Object.freeze({ document }), + 'slots.v1': Object.freeze({}), + 'auction.v1': Object.freeze({}), + 'render.v1': Object.freeze({}), + 'trace.v1': Object.freeze({}), + 'gpt.v1': Object.freeze({ activateLaterLifecycle }), + }); + const prepared = createGptLaterIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces, + onDispose: (callback: () => void) => preparationDisposers.push(callback), + signal: new AbortController().signal, + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + return Object.freeze({ + activateLaterLifecycle, + activationContext: Object.freeze({ + afterCommit: vi.fn(), + onDispose: (callback: () => void) => activationDisposers.push(callback), + signal: new AbortController().signal, + } satisfies IntegrationActivationContext), + activationDisposers, + navigate, + navigationGeneration, + prepared, + preparationDisposers, + release, + }); +} + +describe('GPT deferred navigation and reconciliation owner', () => { + afterEach(() => { + vi.useRealTimers(); + window.history.replaceState({}, '', '/'); + }); + + it('leaves critical history, listeners, timers, and reconciliation unchanged before activation', () => { + vi.useFakeTimers(); + const beforePush = window.history.pushState; + const beforeReplace = window.history.replaceState; + const owner = harness(); + + expect(owner.activateLaterLifecycle).not.toHaveBeenCalled(); + expect(window.history.pushState).toBe(beforePush); + expect(window.history.replaceState).toBe(beforeReplace); + expect(vi.getTimerCount()).toBe(0); + + owner.preparationDisposers.reverse().forEach((release) => release()); + expect(owner.activateLaterLifecycle).not.toHaveBeenCalled(); + }); + + it('owns one deferred history listener and coalesced navigation timer across repeated routes', async () => { + vi.useFakeTimers(); + const owner = harness(); + owner.prepared.activate(owner.activationContext); + + expect(owner.activateLaterLifecycle).toHaveBeenCalledOnce(); + expect(owner.navigate).not.toHaveBeenCalled(); + window.history.pushState({}, '', '/first?section=one'); + expect(owner.navigate).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenLastCalledWith('/first?section=one'); + + window.history.pushState({}, '', '/second'); + window.history.replaceState({}, '', '/third?latest=yes'); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenLastCalledWith('/third?latest=yes'); + expect(owner.navigate).toHaveBeenCalledTimes(2); + + window.dispatchEvent(new PopStateEvent('popstate')); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledTimes(2); + + owner.activationDisposers.reverse().forEach((release) => release()); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + + it('restores exact history ownership and cancels a pending navigation on disposal', async () => { + vi.useFakeTimers(); + const beforePush = Object.getOwnPropertyDescriptor(window.history, 'pushState'); + const beforeReplace = Object.getOwnPropertyDescriptor(window.history, 'replaceState'); + const owner = harness(); + owner.prepared.activate(owner.activationContext); + window.history.pushState({}, '', '/pending'); + expect(vi.getTimerCount()).toBe(1); + + owner.activationDisposers.reverse().forEach((release) => release()); + expect(owner.release).toHaveBeenCalledOnce(); + expect(Object.getOwnPropertyDescriptor(window.history, 'pushState')).toEqual(beforePush); + expect(Object.getOwnPropertyDescriptor(window.history, 'replaceState')).toEqual(beforeReplace); + expect(vi.getTimerCount()).toBe(0); + window.dispatchEvent(new PopStateEvent('popstate')); + await vi.runAllTimersAsync(); + expect(owner.navigate).not.toHaveBeenCalled(); + + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + + it('retries the same current route after its page-bids navigation is rejected', async () => { + vi.useFakeTimers(); + const owner = harness(); + owner.navigate.mockResolvedValueOnce( + Object.freeze({ + status: 'rejected' as const, + navigationGeneration: owner.navigationGeneration, + current: true as const, + }) + ); + owner.prepared.activate(owner.activationContext); + + window.history.pushState({}, '', '/retry-current'); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledExactlyOnceWith('/retry-current'); + + window.history.replaceState({}, '', '/retry-current'); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledTimes(2); + expect(owner.navigate).toHaveBeenLastCalledWith('/retry-current'); + + owner.activationDisposers.reverse().forEach((release) => release()); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + + it('retries the same current route after the navigation promise rejects', async () => { + vi.useFakeTimers(); + const owner = harness(); + owner.navigate.mockRejectedValueOnce(new Error('fictional current navigation failure')); + owner.prepared.activate(owner.activationContext); + + window.history.pushState({}, '', '/retry-rejection'); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledExactlyOnceWith('/retry-rejection'); + + window.history.replaceState({}, '', '/retry-rejection'); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledTimes(2); + expect(owner.navigate).toHaveBeenLastCalledWith('/retry-rejection'); + + owner.activationDisposers.reverse().forEach((release) => release()); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + + it('does not let a stale generation failure roll back a newer committed route', async () => { + vi.useFakeTimers(); + const owner = harness(); + const firstGeneration = Object.freeze({}); + const secondGeneration = Object.freeze({}); + let rejectFirst!: ( + result: Readonly<{ + status: 'rejected'; + navigationGeneration: object; + current: false; + }> + ) => void; + let commitSecond!: ( + result: Readonly<{ + status: 'committed'; + navigationGeneration: object; + current: true; + }> + ) => void; + owner.navigate + .mockImplementationOnce( + () => + new Promise((resolve) => { + rejectFirst = resolve; + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + commitSecond = resolve; + }) + ); + owner.prepared.activate(owner.activationContext); + + window.history.pushState({}, '', '/stale-first'); + await vi.advanceTimersByTimeAsync(0); + window.history.pushState({}, '', '/committed-second'); + await vi.advanceTimersByTimeAsync(0); + expect(owner.navigate).toHaveBeenCalledTimes(2); + + commitSecond( + Object.freeze({ + status: 'committed', + navigationGeneration: secondGeneration, + current: true, + }) + ); + await Promise.resolve(); + rejectFirst( + Object.freeze({ + status: 'rejected', + navigationGeneration: firstGeneration, + current: false, + }) + ); + await Promise.resolve(); + + window.history.replaceState({}, '', '/committed-second'); + await vi.runAllTimersAsync(); + expect(owner.navigate).toHaveBeenCalledTimes(2); + + owner.activationDisposers.reverse().forEach((release) => release()); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts new file mode 100644 index 000000000..83f8f1da1 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -0,0 +1,1907 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + adoptInitialGptSlotsFromHandoff, + installPbsCacheBridge, + publishGptWinner, + publishInitialGptProjection, + startGptSlotOperation, + type GptWinnerPublicationInput, + type GptSlotOperationInput, +} from '../../../src/integrations/gpt/module'; +import type { BrowserAuctionProjectionV1 } from '../../../src/core/types'; +import { createLegacyGptRegistrationForTest as createGptIntegrationRegistration } from '../../helpers/legacy_gpt_registration'; +import { createGptIntegrationRegistration as createProductionGptRegistration } from '../../../src/integrations/gpt/module'; +import { createRenderRuntimeIntegrationRegistration } from '../../../src/integrations/render_runtime/module'; +import type { RuntimeCapabilityV1 } from '../../../src/kernel/runtime'; +import { createNoopGoogletagAdapter, type GoogletagFacade } from '../../../src/adapters/googletag'; +import { isGuardInstalled, resetGuardState } from '../../../src/integrations/gpt/script_guard'; +import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, + type IntegrationRegistration, +} from '../../../src/kernel/integration_registry'; +import { createRuntimeSession } from '../../../src/kernel/sessions'; +import { + createCommittedArtifactStore, + createRenderAttempt, + createSlotOperation, + type CommittedRenderArtifact, + type RenderAttempt, +} from '../../../src/services/render'; +import { createReservationService } from '../../../src/services/reservations'; +import type { SlotRequestOutcome } from '../../../src/services/slots'; +import { createTargetingService } from '../../../src/services/targeting'; + +const RELEASE_ID = 'a'.repeat(64); +const RESERVATION_ID = `r1_${'a'.repeat(22)}`; + +function createAttemptHarness() { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(1); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation creation'); + const batch = navigationResult.value.createAuctionBatch('gpt-cycle'); + if (!batch) throw new Error('Expected batch creation'); + const artifacts = createCommittedArtifactStore(); + const reservations = createReservationService({ + prepareRenderSource: (candidate) => + typeof candidate === 'object' && + candidate !== null && + Object.isFrozen(candidate) && + 'type' in candidate && + 'version' in candidate + ? (candidate as Readonly<{ type: 'aps' | 'adm'; version: 1 }>) + : undefined, + }); + const createAttemptWithOwner = (parentAttemptId?: string) => { + const owner = batch.createRenderAttempt('slot-one'); + if (!owner.ok) throw new Error(`Expected attempt owner: ${owner.reason}`); + const created = createRenderAttempt({ + artifacts, + owner: owner.value, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && + candidate !== null && + Object.isFrozen(candidate) && + 'type' in candidate && + 'version' in candidate + ? (candidate as Readonly<{ type: 'aps' | 'adm'; version: 1 }>) + : undefined, + reservations, + ...(parentAttemptId === undefined ? {} : { parentAttemptId }), + }); + if (!created.ok) throw new Error(`Expected render attempt: ${created.reason}`); + return { attempt: created.value, owner: owner.value }; + }; + const primaryCreated = createAttemptWithOwner(); + const primary = primaryCreated.attempt; + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: primary.id, + slot: primary.slot, + navigationGeneration: primary.navigationGeneration, + dispose: vi.fn(), + }) satisfies CommittedRenderArtifact; + return { + artifact, + createAttempt: (parentAttemptId: string): RenderAttempt => + createAttemptWithOwner(parentAttemptId).attempt, + navigation: navigationResult.value, + primary, + primaryOwner: primaryCreated.owner, + reservations, + runtime, + }; +} + +function deferredSlotOutcome() { + let resolve!: (outcome: SlotRequestOutcome) => void; + const result = new Promise((resolveResult) => { + resolve = resolveResult; + }); + const dispose = vi.fn(); + return { + dispose, + request: vi.fn(() => Object.freeze({ status: 'active' as const, result, dispose })), + resolve, + }; +} + +function manifest(ids: readonly string[]) { + return { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'critical' as const })), + }; +} + +function registration( + id: string, + prepare: IntegrationRegistration['prepare'] +): IntegrationRegistration { + return Object.freeze({ abi: 1, id, phase: 'critical', releaseId: RELEASE_ID, prepare }); +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +function pbsCacheProjection( + sources: readonly Readonly<{ + cacheHost: string; + cacheId: string; + cachePath: string; + divId: string; + slot: string; + }>[] +): Readonly { + return Object.freeze({ + version: 1 as const, + auction: Object.freeze({ + version: 1 as const, + auctionId: 'pbs-cache-auction', + results: Object.freeze( + sources.map((source, index) => + Object.freeze({ + slot: source.slot, + outcome: 'winner' as const, + candidateId: `CACHEBID${String(index).padStart(4, '0')}`, + }) + ) + ), + }), + slots: Object.freeze( + sources.map((source) => + Object.freeze({ + slot: source.slot, + gamUnitPath: `/123/${source.slot}`, + divId: source.divId, + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }) + ) + ), + bids: Object.freeze( + sources.map((source, index) => + Object.freeze({ + candidateId: `CACHEBID${String(index).padStart(4, '0')}`, + slot: source.slot, + provider: 'prebid', + upstreamBidId: `upstream-${index}`, + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({}), + renderSource: Object.freeze({ + type: 'pbs_cache' as const, + version: 1 as const, + cacheId: source.cacheId, + cacheHost: source.cacheHost, + cachePath: source.cachePath, + width: 300, + height: 250, + }), + }) + ) + ), + }) as unknown as Readonly; +} + +function pbsCacheSourceFrame(divId: string): HTMLIFrameElement { + const root = document.createElement('div'); + root.id = divId; + root.style.width = '1px'; + root.style.height = '1px'; + const frame = document.createElement('iframe'); + frame.setAttribute('width', '1'); + frame.setAttribute('height', '1'); + frame.style.width = '1px'; + frame.style.height = '1px'; + root.appendChild(frame); + document.body.appendChild(root); + return frame; +} + +function startPbsCacheBridge(projection: Readonly) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(3); + return target; + }, + }), + }); + const navigation = runtime.startInitialNavigation(projection); + if (!navigation.ok) throw new Error('Expected PBS Cache test navigation'); + const observe = vi.fn(() => true); + const release = installPbsCacheBridge( + document, + Object.freeze({ + navigation: navigation.value, + projection, + session: runtime, + }), + () => true, + observe + ); + return { navigation: navigation.value, observe, release, runtime }; +} + +function dispatchPbsCacheRequest( + source: Window, + adId: string, + postMessage: ReturnType +): ReturnType { + const event = new MessageEvent('message', { + data: JSON.stringify({ message: 'Prebid Request', adId }), + ports: [Object.freeze({ postMessage }) as unknown as MessagePort], + source, + }); + const stopped = vi.spyOn(event, 'stopImmediatePropagation'); + window.dispatchEvent(event); + return stopped; +} + +describe('GPT-owned PBS Cache bridge', () => { + const sharedCacheId = 'shared cache/id'; + + afterEach(() => { + vi.unstubAllGlobals(); + document.getElementById('cache-slot-one')?.remove(); + document.getElementById('cache-slot-two')?.remove(); + document.getElementById('foreign-cache-slot')?.remove(); + }); + + it('binds duplicate cache ids to the requesting slot and preserves current-main parse, macro, and resize behavior', async () => { + const projection = pbsCacheProjection([ + { + cacheId: sharedCacheId, + cacheHost: 'first-cache.example', + cachePath: '/first', + divId: 'cache-slot-one', + slot: 'slot-one', + }, + { + cacheId: sharedCacheId, + cacheHost: 'second-cache.example:8443', + cachePath: '/opaque%2Fpath', + divId: 'cache-slot-two', + slot: 'slot-two', + }, + ]); + const first = pbsCacheSourceFrame('cache-slot-one'); + const second = pbsCacheSourceFrame('cache-slot-two'); + const foreign = pbsCacheSourceFrame('foreign-cache-slot'); + const fetchCache = vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + adm: '
cached
', + width: 320, + height: 100, + price: 2.75, + }), + })); + vi.stubGlobal('fetch', fetchCache); + const bridge = startPbsCacheBridge(projection); + const foreignPost = vi.fn(); + const foreignStopped = dispatchPbsCacheRequest( + foreign.contentWindow!, + sharedCacheId, + foreignPost + ); + expect(foreignStopped).not.toHaveBeenCalled(); + expect(fetchCache).not.toHaveBeenCalled(); + + const postMessage = vi.fn(); + const stopped = dispatchPbsCacheRequest(second.contentWindow!, sharedCacheId, postMessage); + await vi.waitFor(() => expect(postMessage).toHaveBeenCalledOnce()); + expect(stopped).toHaveBeenCalledOnce(); + expect(fetchCache).toHaveBeenCalledExactlyOnceWith( + 'https://second-cache.example:8443/opaque%2Fpath?uuid=shared%20cache%2Fid', + { mode: 'cors' } + ); + expect(JSON.parse(String(postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: sharedCacheId, + ad: '
cached
', + renderer: expect.any(String), + width: 320, + height: 100, + }); + expect(second.style.width).toBe('320px'); + expect(second.style.height).toBe('100px'); + expect(first.style.width).toBe('1px'); + expect(bridge.observe).not.toHaveBeenCalled(); + bridge.release(); + bridge.runtime.dispose(); + }); + + it('keeps fetch, payload, and response-post failures typed and suppresses duplicate in-flight work', async () => { + const projection = pbsCacheProjection([ + { + cacheId: sharedCacheId, + cacheHost: 'cache.example', + cachePath: '/pbc/v1/cache', + divId: 'cache-slot-one', + slot: 'slot-one', + }, + ]); + const frame = pbsCacheSourceFrame('cache-slot-one'); + let resolveResponse!: ( + response: Readonly<{ ok: boolean; status: number; text: () => Promise }> + ) => void; + const fetchCache = vi.fn( + () => + new Promise Promise }>>( + (resolve) => { + resolveResponse = resolve; + } + ) + ); + vi.stubGlobal('fetch', fetchCache); + const bridge = startPbsCacheBridge(projection); + const firstPost = vi.fn(); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, firstPost); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, vi.fn()); + expect(fetchCache).toHaveBeenCalledOnce(); + resolveResponse({ ok: true, status: 200, text: async () => '{"not_adm":true}' }); + await vi.waitFor(() => + expect(bridge.observe).toHaveBeenCalledWith( + Object.freeze({ + kind: 'pbs_cache_bridge', + slotId: 'slot-one', + reason: 'invalid_cache_payload', + }) + ) + ); + expect(firstPost).not.toHaveBeenCalled(); + + fetchCache.mockResolvedValueOnce({ ok: false, status: 503, text: async () => '' }); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, vi.fn()); + await vi.waitFor(() => + expect(bridge.observe).toHaveBeenCalledWith( + Object.freeze({ + kind: 'pbs_cache_bridge', + slotId: 'slot-one', + reason: 'cache_fetch_failed', + }) + ) + ); + + fetchCache.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => '
raw cached creative
', + }); + const throwingPost = vi.fn(() => { + throw new Error('closed port'); + }); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, throwingPost); + await vi.waitFor(() => + expect(bridge.observe).toHaveBeenCalledWith( + Object.freeze({ + kind: 'pbs_cache_bridge', + slotId: 'slot-one', + reason: 'response_post_failed', + }) + ) + ); + expect(frame.style.width).toBe('1px'); + bridge.release(); + bridge.runtime.dispose(); + }); + + it('makes late cache completion and post-disposal messages inert', async () => { + const projection = pbsCacheProjection([ + { + cacheId: sharedCacheId, + cacheHost: 'cache.example', + cachePath: '/pbc/v1/cache', + divId: 'cache-slot-one', + slot: 'slot-one', + }, + ]); + const frame = pbsCacheSourceFrame('cache-slot-one'); + let resolveResponse!: ( + response: Readonly<{ ok: boolean; status: number; text: () => Promise }> + ) => void; + const fetchCache = vi.fn( + () => + new Promise Promise }>>( + (resolve) => { + resolveResponse = resolve; + } + ) + ); + vi.stubGlobal('fetch', fetchCache); + const bridge = startPbsCacheBridge(projection); + const postMessage = vi.fn(); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, postMessage); + expect(fetchCache).toHaveBeenCalledOnce(); + expect(bridge.runtime.replaceNavigation()).toMatchObject({ ok: true }); + resolveResponse({ + ok: true, + status: 200, + text: async () => JSON.stringify({ adm: '
late
' }), + }); + await Promise.resolve(); + await Promise.resolve(); + expect(postMessage).not.toHaveBeenCalled(); + expect(bridge.observe).not.toHaveBeenCalled(); + + bridge.release(); + dispatchPbsCacheRequest(frame.contentWindow!, sharedCacheId, vi.fn()); + expect(fetchCache).toHaveBeenCalledOnce(); + bridge.runtime.dispose(); + }); +}); + +describe('transactional GPT integration module', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + resetGuardState(); + delete (window as Window & { googletag?: unknown }).googletag; + document.getElementById('critical-slot')?.remove(); + document.getElementById('spa-winner')?.remove(); + }); + + it('adopts exact first-display GPT identities without issuing a second GPT action', () => { + const physicalSlot = {}; + const frame = {}; + const navigationGeneration = {}; + const adoptGptSlot = vi.fn(() => Object.freeze({ ok: true as const })); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([ + Object.freeze({ + id: 'slot-1', + owner: 'trusted_server', + domId: 'div-1', + gamPath: '/123/slot-1', + formats: Object.freeze([Object.freeze([300, 250])]), + }), + ]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + + expect(adoptInitialGptSlotsFromHandoff(adoption, navigationGeneration, { adoptGptSlot })).toBe( + adoption + ); + expect(adoptGptSlot).toHaveBeenCalledExactlyOnceWith(navigationGeneration, 'slot-1', { + definition: { + adUnitPath: '/123/slot-1', + elementId: 'div-1', + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot: physicalSlot, + }); + }); + + it.each([ + { diagnosticsActive: false, adoptInitialDisplay: false }, + { diagnosticsActive: true, adoptInitialDisplay: false }, + { diagnosticsActive: false, adoptInitialDisplay: true }, + ])( + 'uses only catalog capabilities without replaying adopted display (diagnostics=$diagnosticsActive, adoption=$adoptInitialDisplay)', + async ({ diagnosticsActive, adoptInitialDisplay }) => { + vi.useFakeTimers(); + const NativeMutationObserver = window.MutationObserver; + const activeMutationObservers = new Set(); + class TrackingMutationObserver implements MutationObserver { + readonly inner: MutationObserver; + + constructor(callback: MutationCallback) { + this.inner = new NativeMutationObserver(callback); + } + + disconnect(): void { + activeMutationObservers.delete(this); + this.inner.disconnect(); + } + + observe(target: Node, options?: MutationObserverInit): void { + activeMutationObservers.add(this); + this.inner.observe(target, options); + } + + takeRecords(): MutationRecord[] { + return this.inner.takeRecords(); + } + } + vi.stubGlobal('MutationObserver', TrackingMutationObserver); + const listenerTypes: string[] = []; + const removedTypes: string[] = []; + const targeting = new Map(); + const publisherSlot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) targeting.clear(); + else targeting.delete(key); + return publisherSlot; + }), + getAdUnitPath: () => '/123/spa-winner', + getSlotElementId: () => 'spa-winner', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + targeting.set(key, typeof value === 'string' ? [value] : value); + return publisherSlot; + }), + }; + const definedSlots: object[] = []; + const createDefinedSlot = (adUnitPath: string, elementId: string) => ({ + addService: vi.fn(), + clearTargeting: vi.fn(), + getAdUnitPath: () => adUnitPath, + getSlotElementId: () => elementId, + getTargeting: () => [], + setTargeting: vi.fn(), + }); + const defineSlot = vi.fn((adUnitPath: string, _sizes: unknown, elementId: string) => { + const slot = createDefinedSlot(adUnitPath, elementId); + definedSlots.push(slot); + return slot; + }); + const destroySlots = vi.fn((slots: readonly object[]) => { + for (const slot of slots) { + const index = definedSlots.indexOf(slot); + if (index >= 0) definedSlots.splice(index, 1); + } + return true; + }); + const display = vi.fn(); + const refresh = vi.fn(); + const pubads = { + addEventListener: vi.fn((type: string, _listener: (event: unknown) => void) => { + listenerTypes.push(type); + }), + disableInitialLoad: vi.fn(), + getSlots: vi.fn(() => [publisherSlot, ...definedSlots]), + refresh, + removeEventListener: vi.fn((type: string, _listener: (event: unknown) => void) => { + removedTypes.push(type); + }), + }; + (window as Window & { googletag?: unknown }).googletag = { + apiReady: true, + pubadsReady: true, + cmd: { push: (command: () => void) => (command(), 0) }, + defineSlot, + destroySlots, + display, + getConfig: vi.fn(() => ({ disableInitialLoad: false })), + pubads: () => pubads, + setConfig: vi.fn(), + }; + const providerFacades = new Map>>(); + const protect = vi.fn(() => true); + const bootManifest = Object.freeze({ + version: 1 as const, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' as const }), + Object.freeze({ id: 'gpt', phase: 'critical' as const }), + ]), + }); + const runtime = Object.freeze({ + attachAuctionContextService: () => () => undefined, + boot: () => + Object.freeze({ + auctionProjection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([ + Object.freeze({ + slot: 'critical-slot', + outcome: 'no_bid' as const, + }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'critical-slot', + gamUnitPath: '/123/critical-slot', + divId: 'critical-slot', + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }), + ]), + bids: Object.freeze([]), + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: diagnosticsActive }), + }), + manifest: bootManifest, + }), + document, + enqueue: () => true, + generation: Object.freeze({}), + protectFirstDisplayAttemptBatch: protect, + registerAuctionContext: () => () => undefined, + } satisfies RuntimeCapabilityV1); + const registry = createIntegrationRegistry({ + manifest: bootManifest, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['render_runtime', 'gpt']), + catalog: Object.freeze([ + Object.freeze({ + id: 'render_runtime', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze([ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + ]), + }), + Object.freeze({ + id: 'gpt', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([ + 'runtime.v1', + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + ]), + provides: Object.freeze(['gpt.v1', 'gpt.events.v1', 'pbs_cache.baseline.v1']), + }), + ]), + runtimeCapability: runtime, + getBindings: (id) => + Object.freeze({ + config: id === 'gpt' ? Object.freeze({}) : undefined, + interfaces: Object.freeze({}), + }), + onCapabilityStaged: (key, facade) => { + providerFacades.set(key, facade); + return () => { + if (providerFacades.get(key) === facade) providerFacades.delete(key); + }; + }, + startedAtMs: 0, + now: () => 0, + }); + const criticalElement = document.createElement('div'); + criticalElement.id = 'critical-slot'; + document.body.appendChild(criticalElement); + expect(registry.register(createRenderRuntimeIntegrationRegistration(RELEASE_ID))).toBe(true); + expect(registry.register(createProductionGptRegistration(RELEASE_ID))).toBe(true); + + const installCallbacks: IntegrationInstallCallbacks = { + ...callbacks([]), + ...(adoptInitialDisplay + ? { + coordinateTakeover: ( + prepared: Parameters< + NonNullable + >[0] + ) => { + prepared.activate( + Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([ + Object.freeze({ + id: 'critical-slot', + owner: 'publisher', + domId: 'critical-slot', + gamPath: '/123/critical-slot', + formats: Object.freeze([Object.freeze([300, 250])]), + }), + ]), + cycles: Object.freeze([Object.freeze({ slotId: 'critical-slot' })]), + artifacts: Object.freeze([]), + }), + identities: Object.freeze([publisherSlot]), + }) + ); + prepared.commit(); + }, + } + : {}), + }; + const result = await registry.install(installCallbacks); + expect(result.state).toBe('kernel'); + const gpt = providerFacades.get('gpt.v1') as { + activateLaterLifecycle: () => Readonly<{ + navigate: (path: string) => Promise; + release: () => void; + }>; + navigation: () => Readonly<{ + generation: object; + currentAuctionProjection?: Readonly<{ auction?: Readonly<{ auctionId?: string }> }>; + }>; + slots: { + request: (input: Readonly>) => unknown; + }; + }; + if (adoptInitialDisplay) { + await Promise.resolve(); + expect(defineSlot).not.toHaveBeenCalled(); + expect(display).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + expect(protect).not.toHaveBeenCalled(); + if (result.state === 'kernel') result.dispose(); + expect(providerFacades.size).toBe(0); + return; + } + await vi.waitFor(() => expect(definedSlots).toHaveLength(1)); + const criticalNavigation = gpt.navigation(); + gpt.slots.request({ + intentId: 'critical-request', + navigationGeneration: criticalNavigation.generation, + operation: 'display', + registeredSlotId: 'critical-slot', + requestClass: 'initial', + }); + await vi.waitFor(() => expect(display).toHaveBeenCalledOnce()); + expect(protect).not.toHaveBeenCalled(); + expect(activeMutationObservers.size).toBe(2); + const firstPhysicalSlot = definedSlots[0]; + expect(firstPhysicalSlot).toBeDefined(); + criticalElement.remove(); + const replacementElement = document.createElement('div'); + replacementElement.id = 'critical-slot'; + document.body.appendChild(replacementElement); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(250); + expect(destroySlots).toHaveBeenCalledWith([firstPhysicalSlot]); + expect(definedSlots).toHaveLength(1); + expect(definedSlots[0]).not.toBe(firstPhysicalSlot); + expect(activeMutationObservers.size).toBe(2); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + expect([...providerFacades.keys()]).toEqual([ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + 'gpt.v1', + 'gpt.events.v1', + 'pbs_cache.baseline.v1', + ]); + expect(Reflect.ownKeys(providerFacades.get('pbs_cache.baseline.v1') ?? {})).toEqual([]); + const render = providerFacades.get('render.v1') as { + attachPucGamAttemptRegistrar: (registrar: (input: unknown) => boolean) => () => void; + }; + expect(() => render.attachPucGamAttemptRegistrar(() => true)).toThrow('duplicated'); + expect(protect).not.toHaveBeenCalled(); + expect([...listenerTypes].sort()).toEqual( + (diagnosticsActive + ? [ + 'slotRequested', + 'slotRenderEnded', + 'slotResponseReceived', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', + ] + : ['slotRequested', 'slotRenderEnded'] + ).sort() + ); + expect(listenerTypes.slice(0, 2)).toEqual(['slotRequested', 'slotRenderEnded']); + + const placement = { + slot: 'spa-winner', + gamUnitPath: '/123/spa-winner', + divId: 'spa-winner', + formats: [[300, 250]], + targeting: {}, + }; + const bid = { + candidateId: 'BBBBBBBBBBBB', + slot: placement.slot, + provider: 'trusted', + upstreamBidId: 'spa-upstream', + cpm: 2, + currency: 'USD', + targeting: { hb_bidder: 'trusted' }, + renderSource: { + type: 'pbs_cache', + version: 1, + cacheId: 'cache id/with reserved bytes', + cacheHost: 'cache.example:8443', + cachePath: '/pbc/v1/cache', + width: 300, + height: 250, + }, + }; + const pageBids = { + version: 1, + auction: { + version: 1, + auctionId: 'spa-production', + results: [{ slot: placement.slot, outcome: 'winner', candidateId: bid.candidateId }], + }, + slots: [placement], + bids: [bid], + }; + const slotElement = document.createElement('div'); + slotElement.id = placement.divId; + document.body.appendChild(slotElement); + const fetchPageBids = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue({ ok: true, json: async () => pageBids } as Response); + const initialNavigation = gpt.navigation(); + expect(refresh).not.toHaveBeenCalled(); + const later = gpt.activateLaterLifecycle(); + expect(Object.isFrozen(later)).toBe(true); + expect(activeMutationObservers.size).toBe(2); + expect(() => gpt.activateLaterLifecycle()).toThrow('unavailable'); + const navigationResult = await later.navigate('/spa-production?route=one'); + expect(navigationResult).toEqual({ + status: 'committed', + navigationGeneration: expect.any(Object), + current: true, + }); + expect(fetchPageBids).toHaveBeenCalledExactlyOnceWith( + '/_ts/page-bids?path=%2Fspa-production%3Froute%3Done', + expect.objectContaining({ + credentials: 'include', + headers: { 'X-TSJS-Page-Bids': '1' }, + signal: expect.any(AbortSignal), + }) + ); + expect(gpt.navigation()).not.toBe(initialNavigation); + expect(gpt.navigation()?.currentAuctionProjection?.auction?.auctionId).toBe('spa-production'); + expect(refresh).toHaveBeenCalledExactlyOnceWith( + [publisherSlot], + Object.freeze({ changeCorrelator: false }) + ); + expect(publisherSlot.setTargeting).toHaveBeenCalledWith('hb_adid', bid.renderSource.cacheId); + expect(publisherSlot.setTargeting).toHaveBeenCalledWith( + 'hb_cache_host', + bid.renderSource.cacheHost + ); + expect(publisherSlot.setTargeting).toHaveBeenCalledWith( + 'hb_cache_path', + bid.renderSource.cachePath + ); + + const sourceFrame = document.createElement('iframe'); + slotElement.appendChild(sourceFrame); + const responsePort = Object.freeze({ postMessage: vi.fn() }); + fetchPageBids.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + adm: '
cached
', + w: 320, + h: 100, + price: 2.75, + }), + } as Response); + const requestEvent = new MessageEvent('message', { + data: JSON.stringify({ message: 'Prebid Request', adId: bid.renderSource.cacheId }), + ports: [responsePort as unknown as MessagePort], + source: sourceFrame.contentWindow, + }); + const stopImmediatePropagation = vi.spyOn(requestEvent, 'stopImmediatePropagation'); + window.dispatchEvent(requestEvent); + await vi.waitFor(() => expect(responsePort.postMessage).toHaveBeenCalledOnce()); + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(fetchPageBids).toHaveBeenNthCalledWith( + 2, + 'https://cache.example:8443/pbc/v1/cache?uuid=cache%20id%2Fwith%20reserved%20bytes', + { mode: 'cors' } + ); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: bid.renderSource.cacheId, + ad: '
cached
', + renderer: expect.any(String), + width: 320, + height: 100, + }); + + let resolveStaleResponse!: (response: Response) => void; + const concurrentPageBids = { + version: 1, + auction: { + version: 1, + auctionId: 'concurrent-current', + results: [], + }, + slots: [], + bids: [], + }; + fetchPageBids + .mockReturnValueOnce( + new Promise((resolve) => { + resolveStaleResponse = resolve; + }) + ) + .mockResolvedValueOnce({ + ok: true, + json: async () => concurrentPageBids, + } as Response); + const staleNavigation = later.navigate('/stale-generation'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledTimes(3)); + const currentNavigation = later.navigate('/current-generation'); + await expect(currentNavigation).resolves.toEqual({ + status: 'committed', + navigationGeneration: expect.any(Object), + current: true, + }); + resolveStaleResponse({ ok: true, json: async () => pageBids } as Response); + const staleResult = await staleNavigation; + expect(staleResult).toEqual({ + status: 'rejected', + navigationGeneration: expect.any(Object), + current: false, + }); + expect((staleResult as { navigationGeneration: object }).navigationGeneration).not.toBe( + ((await currentNavigation) as { navigationGeneration: object }).navigationGeneration + ); + later.release(); + expect(activeMutationObservers.size).toBe(1); + await later.navigate('/disposed-owner'); + expect(fetchPageBids).toHaveBeenCalledTimes(4); + fetchPageBids.mockRestore(); + sourceFrame.remove(); + slotElement.remove(); + + if (result.state === 'kernel') result.dispose(); + expect(activeMutationObservers.size).toBe(0); + expect(removedTypes.sort()).toEqual([...listenerTypes].sort()); + expect(providerFacades.size).toBe(0); + expect(() => render.attachPucGamAttemptRegistrar(() => true)).toThrow('unavailable'); + } + ); + + it('protects the complete immutable initial winner batch before starting either GPT request', async () => { + const candidateIds = ['candidate001', 'candidate002'] as const; + const projection = Object.freeze({ + version: 1 as const, + auction: Object.freeze({ + version: 1 as const, + auctionId: 'initial-winners', + results: Object.freeze( + candidateIds.map((candidateId, index) => + Object.freeze({ + slot: `slot-${index + 1}`, + outcome: 'winner' as const, + candidateId, + }) + ) + ), + }), + slots: Object.freeze( + candidateIds.map((_candidateId, index) => + Object.freeze({ + slot: `slot-${index + 1}`, + gamUnitPath: `/123/slot-${index + 1}`, + divId: `slot-${index + 1}`, + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }) + ) + ), + bids: Object.freeze( + candidateIds.map((candidateId, index) => + Object.freeze({ + candidateId, + slot: `slot-${index + 1}`, + provider: 'fictional', + upstreamBidId: `upstream-${index + 1}`, + cpm: index + 1, + currency: 'USD' as const, + targeting: Object.freeze({}), + rendererReservationId: `r1_${String(index + 1).repeat(22)}`, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: `

${index + 1}

`, + width: 300, + height: 250, + }), + }) + ) + ), + }); + for (const placement of projection.slots) { + const element = document.createElement('div'); + element.id = placement.divId; + document.body.appendChild(element); + } + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(projection); + if (!navigationResult.ok) throw new Error(navigationResult.reason); + const navigation = navigationResult.value; + const artifacts = createCommittedArtifactStore(); + const reservations = createReservationService({ + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as never) + : undefined, + }); + const physical = new Map(); + const request = vi.fn(() => + Object.freeze({ + status: 'active' as const, + result: Promise.resolve( + Object.freeze({ status: 'failed' as const, reason: 'gpt_request_failed' as const }) + ), + dispose: vi.fn(), + }) + ); + const slots = Object.freeze({ + adoptGptSlot: ( + _generation: object, + registeredSlotId: string, + binding: Readonly<{ slot: object }> + ) => { + physical.set(registeredSlotId, binding.slot); + return Object.freeze({ ok: true as const }); + }, + isBoundGptSlot: (_generation: object, registeredSlotId: string, slot: object) => + physical.get(registeredSlotId) === slot, + recordPublisherDestruction: vi.fn(() => true), + request, + }); + const targeting = Object.freeze({ + observePublisherMutations: () => + Object.freeze({ status: 'completed', result: Promise.resolve(), dispose: vi.fn() }), + own: (_slot: object, _key: string, _value: string, ownerId: string) => + Object.freeze({ ownerId, release: vi.fn() }), + }); + const facade = Object.freeze({ + slots: () => Object.freeze([]), + slotElementId: () => undefined, + transactionalDefine: ( + definition: Readonly<{ elementId: string }>, + _current: () => boolean, + prepare: (slot: object) => Readonly<{ commit: () => boolean }> + ) => { + const slot = Object.freeze({ elementId: definition.elementId }); + if (!prepare(slot).commit()) return Object.freeze({ status: 'failed' as const }); + return Object.freeze({ status: 'defined' as const, slot }); + }, + clearTargeting: vi.fn(), + getTargeting: vi.fn(() => Object.freeze([])), + setTargeting: vi.fn(), + }); + const googletag = Object.freeze({ + run: (command: (gpt: typeof facade) => unknown) => + Object.freeze({ + status: 'completed', + result: Promise.resolve(command(facade)), + dispose: vi.fn(), + }), + }); + let protectedLatches: readonly PromiseLike[] | undefined; + const protect = vi.fn((latches: readonly PromiseLike[]) => { + expect(Object.isFrozen(latches)).toBe(true); + expect(latches).toHaveLength(2); + expect(request).not.toHaveBeenCalled(); + protectedLatches = latches; + return true; + }); + + await publishInitialGptProjection(document, { + googletag: googletag as never, + navigation, + projection: projection as never, + protect, + pucBridge: Object.freeze({ + registerGamAttempt: vi.fn(() => true), + recordNonemptyGam: vi.fn(() => true), + }), + render: Object.freeze({ + artifacts, + createAttempt: (owner: Parameters[0]['owner']) => + createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => candidate as never, + reservations, + }), + createSlotOperation, + publisherOrigin: window.location.origin, + registerRenderer: vi.fn(), + rendererNonces: Object.freeze({}), + renderWinner: vi.fn(() => false), + reservations, + }) as never, + slots: slots as never, + targeting: targeting as never, + }); + + expect(protect).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledTimes(2); + await Promise.allSettled([...(protectedLatches ?? [])]); + artifacts.dispose(); + reservations.dispose(); + runtime.dispose(); + }); + + it('prepares inertly, activates the reversible guard, and starts only after commit', async () => { + const config = Object.freeze({ scriptUrl: '/integrations/gpt/script' }); + const order: string[] = []; + const start = vi.fn((received: unknown) => { + order.push('start'); + expect(received).toBe(config); + }); + const release = vi.fn(() => order.push('release')); + const activate = vi.fn(() => { + order.push('gpt:activate'); + return release; + }); + let finishPreparation: (() => void) | undefined; + const preparationGate = new Promise((resolve) => { + finishPreparation = resolve; + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'gate']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt', 'gate']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ gpt: Object.freeze({ activate, start }) }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + registry.register( + registration('gate', async () => { + order.push('gate:prepare'); + await preparationGate; + return Object.freeze({ activate: () => order.push('gate:activate') }); + }) + ); + const originalDocumentWrite = document.write; + + const installing = registry.install(callbacks(order)); + await vi.waitFor(() => expect(order).toEqual(['gate:prepare'])); + + expect(isGuardInstalled()).toBe(false); + expect(document.write).toBe(originalDocumentWrite); + expect(start).not.toHaveBeenCalled(); + + finishPreparation?.(); + const result = await installing; + + expect(result).toMatchObject({ state: 'kernel' }); + expect(isGuardInstalled()).toBe(true); + expect(document.write).not.toBe(originalDocumentWrite); + expect(order).toEqual([ + 'gate:prepare', + 'core', + 'gpt:activate', + 'gate:activate', + 'publish', + 'start', + 'drain', + ]); + expect(activate).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(isGuardInstalled()).toBe(false); + expect(document.write).toBe(originalDocumentWrite); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('unwinds the GPT guard before fallback when a later activation fails', async () => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'broken']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt', 'broken']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + registry.register( + registration('broken', () => ({ + activate: () => { + expect(isGuardInstalled()).toBe(true); + throw new Error('fictional activation failure'); + }, + })) + ); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + + expect(isGuardInstalled()).toBe(false); + expect(start).not.toHaveBeenCalled(); + }); + + it('never installs the guard or starts when reversible GPT activation fails', async () => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + gpt: Object.freeze({ + activate: () => { + expect(isGuardInstalled()).toBe(false); + throw new Error('fictional observer activation failure'); + }, + start, + }), + }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(isGuardInstalled()).toBe(false); + expect(start).not.toHaveBeenCalled(); + }); + + it('fails preparation without effects when the composition omits the GPT boundary', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + + expect(isGuardInstalled()).toBe(false); + }); + + it.each([ + [ + 'accessor', + Object.freeze( + Object.defineProperty({}, 'scriptUrl', { + enumerable: true, + get: () => '/publisher-controlled', + }) + ), + ], + ['mutable nested data', Object.freeze({ nested: {} })], + ['non-plain data', Object.freeze({ value: Object.freeze(new Date(0)) })], + ])('rejects %s configuration during inert preparation', async (_caseName, config) => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(start).not.toHaveBeenCalled(); + expect(isGuardInstalled()).toBe(false); + }); + + it('isolates post-commit startup failure and disposes only the GPT module', async () => { + const start = vi.fn(() => { + throw new Error('fictional GPT startup failure'); + }); + const runtimeFailures: unknown[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + onRuntimeFailure: (failure) => runtimeFailures.push(failure), + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'gpt', phase: 'after_commit' }], + }); + + expect(start).toHaveBeenCalledTimes(1); + expect(runtimeFailures).toEqual([{ id: 'gpt', phase: 'after_commit' }]); + expect(isGuardInstalled()).toBe(false); + }); + + it('starts fallback only after an attributable TS-owned empty cycle settles the primary', async () => { + const harness = createAttemptHarness(); + const slot = deferredSlotOutcome(); + const order: string[] = []; + let fallback: RenderAttempt | undefined; + const bridgeInput: unknown[] = []; + const bridge = { + registerGamAttempt: vi.fn((input: GptSlotOperationInput) => { + bridgeInput.push(input); + return input.attempt.beginGamClaim(); + }), + recordNonemptyGam: vi.fn(() => true), + }; + const invokeCreateSlotOperation = vi.fn(createSlotOperation); + const started = startGptSlotOperation({ + artifact: harness.artifact, + attempt: harness.primary, + createSlotOperation: invokeCreateSlotOperation, + createFallback: (parentAttemptId) => { + expect(harness.primary.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'gam_empty', + }); + order.push('fallback:create'); + fallback = harness.createAttempt(parentAttemptId); + return Object.freeze({ ok: true as const, value: fallback }); + }, + operation: 'refresh', + owner: harness.primaryOwner, + pucBridge: bridge, + requestClass: 'primary', + reservationId: RESERVATION_ID, + slots: { request: slot.request }, + }); + + expect(started.ok).toBe(true); + expect(invokeCreateSlotOperation).toHaveBeenCalledExactlyOnceWith({ + primary: harness.primary, + createFallback: expect.any(Function), + }); + expect(bridge.registerGamAttempt).toHaveBeenCalledTimes(1); + expect(slot.request).toHaveBeenCalledWith({ + intentId: harness.primary.id, + navigationGeneration: harness.primary.navigationGeneration, + operation: 'refresh', + registeredSlotId: harness.primary.slot, + requestClass: 'primary', + }); + + slot.resolve(Object.freeze({ status: 'empty', responseIdentifier: 'response-one' })); + await Promise.resolve(); + + expect(order).toEqual(['fallback:create']); + expect(harness.primary.snapshot()).toMatchObject({ + state: 'failed', + outcome: { outcome: 'failed', reason: 'gam_empty' }, + }); + expect(started.ok && started.value.snapshot()).toEqual({ settled: false }); + expect(slot.dispose).toHaveBeenCalledTimes(1); + expect(bridge.recordNonemptyGam).not.toHaveBeenCalled(); + + expect(fallback?.fail('gpt_request_failed')).toBe(true); + expect(started.ok && started.value.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'fallback', + primaryAttemptId: harness.primary.id, + primary: { outcome: 'failed', reason: 'gam_empty' }, + fallbackAttemptId: fallback?.id, + fallback: { outcome: 'failed', reason: 'gpt_request_failed' }, + }, + }); + expect(harness.primary.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'gam_empty', + }); + harness.runtime.dispose(); + }); + + it('joins an attributable nonempty cycle to the PUC bridge without settling the operation', async () => { + const harness = createAttemptHarness(); + const slot = deferredSlotOutcome(); + const registered: unknown[] = []; + const nonempty: unknown[] = []; + const bridge = { + registerGamAttempt: vi.fn((input: GptSlotOperationInput) => { + registered.push(input); + return input.attempt.beginGamClaim(); + }), + recordNonemptyGam: vi.fn((input: unknown) => { + nonempty.push(input); + return true; + }), + }; + const input = { + artifact: harness.artifact, + attempt: harness.primary, + createSlotOperation, + operation: 'display' as const, + owner: harness.primaryOwner, + pucBridge: bridge, + requestClass: 'primary', + reservationId: RESERVATION_ID, + slots: { request: slot.request }, + }; + const started = startGptSlotOperation(input); + slot.resolve(Object.freeze({ status: 'rendered', responseIdentifier: 'response-one' })); + await Promise.resolve(); + + expect(nonempty).toEqual(registered); + expect(started.ok && started.value.snapshot()).toEqual({ settled: false }); + expect(harness.primary.snapshot().state).toBe('waiting_for_gam_and_claim'); + expect(slot.dispose).not.toHaveBeenCalled(); + + harness.primary.cancel('superseded'); + expect(slot.dispose).toHaveBeenCalledTimes(1); + harness.runtime.dispose(); + }); + + it.each([ + [{ status: 'failed', reason: 'cycle_unattributable' }, 'cycle_unattributable'], + [{ status: 'failed', reason: 'slot_quarantined' }, 'slot_quarantined'], + [{ status: 'failed', reason: 'gpt_request_timeout' }, 'gpt_request_timeout'], + [{ status: 'failed', reason: 'gpt_completion_timeout' }, 'gpt_completion_timeout'], + [{ status: 'failed', reason: 'external_queue_full' }, 'external_queue_full'], + [{ status: 'failed', reason: 'external_ready_timeout' }, 'external_ready_timeout'], + [{ status: 'cancelled', reason: 'navigation_disposed' }, 'navigation_disposed'], + ] as const)( + 'does not start fallback for non-empty terminal cycle outcome %s', + async (slotOutcome, reason) => { + const harness = createAttemptHarness(); + const slot = deferredSlotOutcome(); + const createFallback = vi.fn(); + const started = startGptSlotOperation({ + artifact: harness.artifact, + attempt: harness.primary, + createSlotOperation, + createFallback, + operation: 'refresh', + owner: harness.primaryOwner, + pucBridge: { + registerGamAttempt: (input) => input.attempt.beginGamClaim(), + recordNonemptyGam: () => true, + }, + requestClass: 'primary', + reservationId: RESERVATION_ID, + slots: { request: slot.request }, + }); + slot.resolve(Object.freeze(slotOutcome) as SlotRequestOutcome); + await Promise.resolve(); + + expect(createFallback).not.toHaveBeenCalled(); + expect(started.ok && started.value.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'primary', + outcome: { reason }, + }, + }); + harness.runtime.dispose(); + } + ); +}); + +describe('ordered GPT winner publication', () => { + function preparePublication() { + const harness = createAttemptHarness(); + const source = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
trusted
', + width: 300, + height: 250, + }); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: harness.primary.slot, + provider: 'trusted', + upstreamBidId: 'upstream-one', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trusted' }), + rendererReservationId: RESERVATION_ID, + renderSource: source, + }); + const placement = Object.freeze({ + slot: bid.slot, + gamUnitPath: '/123/gpt-slot', + divId: 'gpt-slot', + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({ hb_bidder: 'publisher', pos: 'top' }), + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'gpt-publication', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + slots: Object.freeze([placement]), + bids: Object.freeze([bid]), + }); + expect(harness.navigation.installAuctionProjection(projection)).toBe(true); + + const order: string[] = []; + const values = new Map(); + const slot = Object.freeze({ + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + order.push(`target:${key}`); + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }); + const facade: GoogletagFacade = Object.freeze({ + bindingToken: () => Object.freeze({}), + clearTargeting: (target: object, key?: string) => (target as typeof slot).clearTargeting(key), + transactionalDefine: () => Object.freeze({ status: 'discarded' as const }), + display: vi.fn(), + getTargeting: (target: object, key: string) => (target as typeof slot).getTargeting(key), + observeTargeting: () => { + order.push('observe'); + return Object.assign(vi.fn(), { isCurrent: () => true }); + }, + refresh: vi.fn(), + serviceState: () => + Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), + setTargeting: (target: object, key: string, value: string | readonly string[]) => + (target as typeof slot).setTargeting(key, value), + slotElementId: () => undefined, + slots: () => Object.freeze([slot]), + subscribe: () => vi.fn(), + transactionalReplace: () => Object.freeze({ status: 'destroyed' as const }), + }); + const googletag = Object.freeze({ + ...createNoopGoogletagAdapter(), + bindingStatus: () => 'present' as const, + run: (command: (gpt: Readonly) => Value) => { + let result: Promise; + try { + result = Promise.resolve(command(facade)); + } catch (error) { + result = Promise.reject(error); + } + return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); + }, + }); + const targeting = createTargetingService(); + const slotOutcome = deferredSlotOutcome(); + const slots = { + isBoundGptSlot: vi.fn(() => { + order.push('slot:validate'); + return true; + }), + request: vi.fn((input: unknown) => { + order.push('request'); + expect(input).toMatchObject({ registeredSlotId: bid.slot }); + expect(harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'renderable', + }); + return slotOutcome.request(); + }), + }; + let bridgeArtifact: CommittedRenderArtifact | undefined; + const pucBridge = { + registerGamAttempt: vi.fn((input: GptSlotOperationInput) => { + order.push('bridge'); + bridgeArtifact = input.artifact; + return input.attempt.beginGamClaim(); + }), + recordNonemptyGam: vi.fn(() => true), + }; + const reservations = { + registerRender: vi.fn((input: Parameters[0]) => { + order.push('reservation'); + return harness.reservations.registerRender(input); + }), + tombstone: harness.reservations.tombstone, + }; + const input: GptWinnerPublicationInput = { + artifact: harness.artifact, + attempt: harness.primary, + bid, + createSlotOperation, + googletag, + navigation: harness.navigation, + operation: 'refresh', + owner: harness.primaryOwner, + placement, + pucBridge, + requestClass: 'primary', + reservations, + slot, + slots, + targeting, + }; + return { + bid, + bridgeArtifact: () => bridgeArtifact, + harness, + input, + order, + pucBridge, + reservations, + slot, + slots, + targeting, + values, + }; + } + + it('publishes reservation, targeting, intent, and request in that exact order', async () => { + const publication = preparePublication(); + + const result = await publishGptWinner(publication.input); + + expect(result.ok).toBe(true); + expect(publication.order).toEqual([ + 'slot:validate', + 'reservation', + 'observe', + 'slot:validate', + 'target:hb_adid', + 'target:hb_bidder', + 'target:pos', + 'slot:validate', + 'bridge', + 'request', + ]); + expect(publication.values).toEqual( + new Map([ + ['hb_adid', [RESERVATION_ID]], + ['hb_bidder', ['trusted']], + ['pos', ['top']], + ]) + ); + publication.bridgeArtifact()?.dispose(); + expect(publication.values.size).toBe(0); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('fails before targeting when exact slot ownership is lost across observation', async () => { + const publication = preparePublication(); + publication.slots.isBoundGptSlot + .mockImplementationOnce(() => { + publication.order.push('slot:validate'); + return true; + }) + .mockImplementation(() => { + publication.order.push('slot:validate'); + return false; + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'slot_unresolved', + }); + expect(publication.order).toEqual(['slot:validate', 'reservation', 'observe', 'slot:validate']); + expect(publication.values.size).toBe(0); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('compare-restores targeting when exact slot ownership is lost during writes', async () => { + const publication = preparePublication(); + publication.slots.isBoundGptSlot + .mockImplementationOnce(() => { + publication.order.push('slot:validate'); + return true; + }) + .mockImplementationOnce(() => { + publication.order.push('slot:validate'); + return true; + }) + .mockImplementation(() => { + publication.order.push('slot:validate'); + return false; + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'slot_unresolved', + }); + expect(publication.order).toEqual([ + 'slot:validate', + 'reservation', + 'observe', + 'slot:validate', + 'target:hb_adid', + 'target:hb_bidder', + 'target:pos', + 'slot:validate', + ]); + expect(publication.values.size).toBe(0); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + publication.harness.runtime.dispose(); + }); + + it('rolls back targeting and tombstones when the bridge refuses before request', async () => { + const publication = preparePublication(); + publication.pucBridge.registerGamAttempt.mockImplementation(() => { + publication.order.push('bridge'); + return false; + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'gpt_request_failed', + }); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.values.size).toBe(0); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('rolls back targeting and tombstones when the slot request throws', async () => { + const publication = preparePublication(); + publication.slots.request.mockImplementation(() => { + publication.order.push('request'); + throw new Error('fictional request failure'); + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'gpt_request_failed', + }); + expect(publication.order).toEqual([ + 'slot:validate', + 'reservation', + 'observe', + 'slot:validate', + 'target:hb_adid', + 'target:hb_bidder', + 'target:pos', + 'slot:validate', + 'bridge', + 'request', + ]); + expect(publication.values.size).toBe(0); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('fails before exposure when reservation insertion collides', async () => { + const publication = preparePublication(); + expect( + publication.harness.reservations.registerRender({ + reservationId: RESERVATION_ID, + slot: publication.bid.slot, + navigation: publication.harness.navigation, + attemptId: publication.harness.primary.id, + renderSource: publication.bid.renderSource, + winnerContext: Object.freeze({ selectedCpm: publication.bid.cpm }), + }) + ).toMatchObject({ ok: true }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'reservation_collision', + }); + expect(publication.order).toEqual(['slot:validate', 'reservation']); + expect(publication.values.size).toBe(0); + expect(publication.pucBridge.registerGamAttempt).not.toHaveBeenCalled(); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('compare-restores earlier targeting when a later targeting write throws', async () => { + const publication = preparePublication(); + publication.slot.setTargeting.mockImplementation((key, value) => { + publication.order.push(`target:${key}`); + if (key === 'hb_bidder') throw new Error('fictional targeting failure'); + publication.values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'gpt_request_failed', + }); + expect(publication.values.size).toBe(0); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + publication.harness.runtime.dispose(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts deleted file mode 100644 index 999c60c55..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import type { TsjsApi } from '../../../src/core/types'; - -type TestWindow = Window & { - googletag?: unknown; - tsjs?: TsjsApi; -}; - -const originalPushState = history.pushState.bind(history); -const originalReplaceState = history.replaceState.bind(history); - -/** - * Executable lifecycle coverage for `tsjs.scheduleInitialAdInit` — the - * deferred initial-adInit bootstrap the server's `` bids script hands - * off to. These tests run the real scheduler (and, where noted, the real - * `adInit()` and SPA auction hook) instead of string-matching the emitted - * script, so post-load ordering, two-frame deferral, exactly-once invocation, - * and stale-navigation cancellation are all exercised, not just spelled. - */ -describe('scheduleInitialAdInit', () => { - let rafQueue: FrameRequestCallback[]; - let readyState: DocumentReadyState; - let fetchStub: ReturnType; - let popstateHandlers: EventListenerOrEventListenerObject[] = []; - const realAddEventListener = window.addEventListener.bind(window); - - /** Run every queued animation-frame callback (one frame's worth). */ - function flushFrame(): void { - const queued = [...rafQueue]; - rafQueue.length = 0; - queued.forEach((cb) => cb(0)); - } - - /** Flush the microtask/timer queue so the SPA hook's awaits settle. */ - async function flushAsync(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - async function importGptModule() { - return import('../../../src/integrations/gpt/index'); - } - - beforeEach(() => { - vi.resetModules(); - delete (window as TestWindow).tsjs; - delete (window as TestWindow).googletag; - // Restore unwrapped history methods so each module import wraps exactly - // once — without this, wrappers from prior imports accumulate. - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - fetchStub = vi.fn(); - vi.stubGlobal('fetch', fetchStub); - popstateHandlers = []; - vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { - if (type === 'popstate' && listener) popstateHandlers.push(listener); - return realAddEventListener(type, listener, options); - }); - // Manual animation-frame queue: the scheduler must be observed frame by - // frame, so frames only run when a test flushes them explicitly. - rafQueue = []; - ( - window as { requestAnimationFrame: typeof window.requestAnimationFrame } - ).requestAnimationFrame = ((cb: FrameRequestCallback) => { - rafQueue.push(cb); - return rafQueue.length; - }) as typeof window.requestAnimationFrame; - // Controllable document.readyState (jsdom reports 'complete' by default; - // the scheduler branches on it). - readyState = 'loading'; - Object.defineProperty(document, 'readyState', { - configurable: true, - get: () => readyState, - }); - }); - - afterEach(() => { - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - // Reset jsdom location back to root for the next test. - originalReplaceState({}, '', '/'); - document.body.innerHTML = ''; - popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); - popstateHandlers = []; - // Remove the instance properties so the prototype getters are visible again. - delete (document as unknown as Record).readyState; - delete (document as unknown as Record).hidden; - delete (window as unknown as Record).requestAnimationFrame; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('applies the SSR payload and defers adInit until window load plus two animation frames', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!({ atf: { hb_pb: '1.00' } }); - // On the initial document (generation 0) the SSR bids are adopted - // immediately — the deferral applies to the GPT work, not the payload. - expect(ts.bids).toEqual({ atf: { hb_pb: '1.00' } }); - expect(adInit).not.toHaveBeenCalled(); - - // load alone must not run it — React commits after the load-time frame. - window.dispatchEvent(new Event('load')); - expect(adInit).not.toHaveBeenCalled(); - - // One frame is not enough: the double rAF exists so the call lands after - // React's post-hydration commit, not inside the load-event frame. - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('runs after two frames without a load event when the document is already complete', async () => { - readyState = 'complete'; - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - // Still never synchronous — even past load, adInit waits two frames. - expect(adInit).not.toHaveBeenCalled(); - - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('invokes adInit exactly once even across duplicate load events and extra frames', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - window.dispatchEvent(new Event('load')); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - flushFrame(); - - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('still runs after a query-only history change before load', async () => { - // The SPA auction hook identifies routes by pathname only, so a query-only - // replaceState is not a navigation: it must neither trigger an auction nor - // cancel the pending initial adInit. (A URL-equality guard would abort - // here and leave the initial ads uninitialized.) - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - history.replaceState({}, '', '/?utm_source=newsletter'); - await flushAsync(); - expect(fetchStub).not.toHaveBeenCalled(); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('cancels the initial run after an /a → /b → /a round trip before load', async () => { - // Both navigations commit and return to the original URL, so a URL - // comparison would see "unchanged" and run adInit a second time against - // the round-tripped route's live state. The navigation generation counts - // both commits and stands the initial callback down. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - history.pushState({}, '', '/b'); - await flushAsync(); - history.pushState({}, '', '/'); - await flushAsync(); - expect(ts.navGeneration).toBe(2); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('applies server targeting to a publisher-displayed slot before its refresh', async () => { - // A publisher that defined and displayed its GPT slot before window load - // still gets the server-side targeting applied before the refresh that - // delivers it — the deferred run must order setTargeting ahead of the ad - // request it triggers. - const div = document.createElement('div'); - div.id = 'div-atf-sidebar'; - document.body.appendChild(div); - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - clearTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ]; - ts.bids = { atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo' } }; - - ts.scheduleInitialAdInit!(); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); - const targetingOrder = mockSlot.setTargeting.mock.invocationCallOrder[0]!; - const refreshOrder = mockPubads.refresh.mock.invocationCallOrder[0]!; - expect(targetingOrder).toBeLessThan(refreshOrder); - }); - - it('drops the SSR payload when a navigation committed before scheduling', async () => { - // The SPA hook is installed by the synchronous head bundle, so a - // navigation can commit while the document is still streaming — before - // the script calls the scheduler. The SSR payload then belongs - // to a document the page has already left: it must not overwrite the - // live route's bids, and the initial adInit must never fire. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.navGeneration).toBe(1); - ts.bids = { live_slot: { hb_pb: '2.50' } }; - - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); - expect(ts.bids).toEqual({ live_slot: { hb_pb: '2.50' } }); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('preserves a page-bids response applied before scheduling', async () => { - // Same race, with the SPA navigation's page-bids response fully applied - // (slots + bids + its own adInit) before the scheduler is called: the - // stale SSR payload must not corrupt the applied state, and the route's - // adInit count must stay at the SPA hook's single call. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '3.00' } }, - }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.bids).toEqual({ s1: { hb_pb: '3.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - - expect(ts.bids).toEqual({ s1: { hb_pb: '3.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('cancels queued GPT work when a navigation commits before the command queue drains', async () => { - // adInit() only queues its slot work on googletag.cmd, which drains when - // GPT itself loads — possibly long after the generation check that - // guarded the adInit() call. A navigation in that gap must cancel the - // queued mutation, not let it run against the new route's DOM. - const commandQueue: Array<() => void> = []; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const defineSlot = vi.fn(); - const destroySlots = vi.fn(); - (window as TestWindow).googletag = { - cmd: commandQueue, - defineSlot, - destroySlots, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - document.body.innerHTML = '
'; - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - }, - ]; - ts.bids = { atf_sidebar_ad: { hb_pb: '1.00' } }; - - // GPT not loaded yet: the queued work sits in the command array. - ts.adInit!(); - expect(commandQueue.length).toBeGreaterThan(0); - - // A navigation commits before GPT drains the queue. - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.navGeneration).toBe(1); - - // GPT loads and drains the queue: the stale callback must stand down. - commandQueue.splice(0).forEach((fn) => fn()); - expect(defineSlot).not.toHaveBeenCalled(); - expect(destroySlots).not.toHaveBeenCalled(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - expect(mockPubads.enableSingleRequest).not.toHaveBeenCalled(); - }); - - it('rides animation frames in a hidden document, holding adInit until first view', async () => { - // Browsers do not service rAF while the document is hidden, so a - // background-tab load queues the frames but does not run them until the - // tab is first viewed. This is intended (see installScheduleInitialAdInit): - // the initial request spends its impression on a viewed tab. The scheduler - // must keep riding rAF — not switch to a timer — while hidden. - Object.defineProperty(document, 'hidden', { - configurable: true, - get: () => true, - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!({ atf: { hb_pb: '1.00' } }); - window.dispatchEvent(new Event('load')); - - // Hidden tab: the frame chain is queued but unserviced — adInit waits. - expect(rafQueue.length).toBeGreaterThan(0); - expect(adInit).not.toHaveBeenCalled(); - - // First view: the browser services the pending frames. - flushFrame(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts deleted file mode 100644 index 7127efcb4..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ /dev/null @@ -1,770 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import type { TsjsApi } from '../../../src/core/types'; - -type TestWindow = Window & { - googletag?: unknown; - tsjs?: TsjsApi; -}; - -const originalPushState = history.pushState.bind(history); -const originalReplaceState = history.replaceState.bind(history); - -async function importGptModule() { - return import('../../../src/integrations/gpt/index'); -} - -/** Flush the microtask/timer queue so onNavigate's awaits settle. */ -async function flushAsync(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)); -} - -/** Allow a MutationObserver-scheduled slot check to run. */ -async function flushAnimationFrame(): Promise { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - await Promise.resolve(); -} - -describe('installSpaAuctionHook', () => { - let fetchStub: ReturnType; - // popstate listeners registered by each module import. In production the hook - // installs once (guarded by `ts.spaHookInstalled`), but tests wipe - // `window.tsjs` and re-import per test, so without explicit removal the - // listeners accumulate on the shared window and all fire on every dispatch. - let popstateHandlers: EventListenerOrEventListenerObject[] = []; - const realAddEventListener = window.addEventListener.bind(window); - - beforeEach(() => { - vi.resetModules(); - delete (window as TestWindow).tsjs; - // Restore unwrapped history methods so each module import wraps exactly - // once — without this, wrappers from prior imports accumulate. - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - fetchStub = vi.fn(); - vi.stubGlobal('fetch', fetchStub); - popstateHandlers = []; - vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { - if (type === 'popstate' && listener) popstateHandlers.push(listener); - return realAddEventListener(type, listener, options); - }); - }); - - afterEach(() => { - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - // Reset jsdom location back to root for the next test. - originalReplaceState({}, '', '/'); - // Drop any ad containers inserted by a test so DOM state does not leak. - document.body.innerHTML = ''; - // Remove this test's popstate listener(s) so they do not fire in later tests. - popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); - popstateHandlers = []; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('increments navGeneration only when a pathname navigation is accepted', async () => { - // The deferred initial-adInit bootstrap keys off this counter, so it must - // move in lockstep with the hook's own navigation identity: bumped - // synchronously for each accepted pathname change, untouched by the - // query-only and same-path history calls the hook ignores. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - expect(ts.navGeneration).toBe(0); - - history.pushState({}, '', '/next-page'); - expect(ts.navGeneration).toBe(1); - - history.replaceState({}, '', '/next-page?utm_source=x'); - expect(ts.navGeneration).toBe(1); - - history.pushState({}, '', '/next-page'); - expect(ts.navGeneration).toBe(1); - await flushAsync(); - }); - - it.each(['bootstrap', 'bundle'] as const)( - 'invalidates unclaimed GPT handoffs before an SPA fetch (%s)', - async (implementation) => { - let resolveFetch: ((response: unknown) => void) | undefined; - fetchStub.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); - - const routeADiv = document.createElement('div'); - routeADiv.id = 'div-atf-sidebar'; - document.body.appendChild(routeADiv); - const routeASlot = { - getSlotElementId: vi.fn().mockReturnValue(routeADiv.id), - }; - const routeBSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar-2'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(routeBSlot); - const nativeDisplay = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([routeASlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - }; - const staleHandoff = { - gamUnitPath: '/123/atf', - formats: [[300, 250]], - divIdPrefix: 'div-atf-sidebar', - slotElementId: routeADiv.id, - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const claimedHandoff = { - ...staleHandoff, - slotElementId: 'div-claimed', - publisherClaimed: true, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - [staleHandoff.slotElementId]: staleHandoff, - 'div-atf-sidebar-hydrated': staleHandoff, - [claimedHandoff.slotElementId]: claimedHandoff, - }, - }; - - if (implementation === 'bootstrap') { - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); - } - await importGptModule(); - - routeADiv.remove(); - const routeBDiv = document.createElement('div'); - routeBDiv.id = 'div-atf-sidebar-2'; - document.body.appendChild(routeBDiv); - history.pushState({}, '', '/route-b'); - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => typeof routeBSlot - )('/123/atf', [[300, 250]], routeBDiv.id); - googletag.display(routeBDiv.id); - - expect(publisherSlot).toBe(routeBSlot); - expect(nativeDefineSlot).toHaveBeenCalledWith('/123/atf', [[300, 250]], routeBDiv.id); - expect(nativeDisplay).toHaveBeenCalledWith(routeBDiv.id); - expect((window as TestWindow).tsjs!.gptSlotHandoffs).toEqual({ - [claimedHandoff.slotElementId]: claimedHandoff, - }); - expect(resolveFetch).toBeDefined(); - - resolveFetch!({ ok: true, json: async () => ({ slots: [], bids: {} }) }); - await flushAsync(); - } - ); - - it('fetches page-bids on pushState and applies slots/bids via adInit', async () => { - // The route's ad container already exists, so bids apply immediately. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/next-page?edition=fictional#section'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledWith( - '/_ts/page-bids?path=%2Fnext-page', - expect.objectContaining({ - credentials: 'include', - headers: { 'X-TSJS-Page-Bids': '1' }, - }) - ); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - expect(ts.bids).toEqual({ s1: { hb_pb: '1.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('skips adInit on an empty page-bids response with no prior TS state', async () => { - // A gated page-bids response (auction kill switch or consent denial) returns - // no slots. With no prior TS state to sweep, the hook must not call adInit() - // so a consent-denied navigation cannot activate the publisher's GPT setup. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/gated-route'); - await flushAsync(); - - expect(ts.adSlots).toEqual([]); - expect(ts.bids).toEqual({}); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('runs adInit on an empty page-bids response when prior TS state exists', async () => { - // When TS touched slots on a previous navigation, an empty response still - // needs adInit() to sweep the stale TS targeting from those slots. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - ts.prevSlotTargetingKeys = { 'div-prev': ['hb_pb'] }; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/cleanup-route'); - await flushAsync(); - - expect(ts.adSlots).toEqual([]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('defers applying bids until the route ad container is inserted', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 'late', div_id: 'div-late' }], - bids: { late: { hb_pb: '2.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - // Navigate before the new route's container has rendered. - history.pushState({}, '', '/late-route'); - await flushAsync(); - expect(adInit).not.toHaveBeenCalled(); - expect(ts.adSlots).toBeUndefined(); - - // Container commits — the hook should now apply bids exactly once. - document.body.innerHTML = '
'; - await flushAnimationFrame(); - - expect(ts.adSlots).toEqual([{ id: 'late', div_id: 'div-late' }]); - expect(ts.bids).toEqual({ late: { hb_pb: '2.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('applies bids immediately when a prefix-configured placement exists but is hidden', async () => { - // A breakpoint-hidden placement (mobile-only config while on desktop) has - // rendered its div but the tiered resolver returns no element for it. The - // slot wait must count it as present — otherwise every navigation to the - // route stalls for the full SPA_SLOT_WAIT_MS before applying bids to the - // visible slots, and adInit skips the hidden slot anyway. - document.body.innerHTML = - '
' + ''; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [ - { id: 'visible', div_id: 'div-visible' }, - { id: 'hidden', div_id: 'ad-hidden-' }, - ], - bids: { visible: { hb_pb: '2.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/mixed-route'); - await flushAsync(); - - // Bids apply without waiting out the slot timeout. - expect(ts.adSlots).toEqual([ - { id: 'visible', div_id: 'div-visible' }, - { id: 'hidden', div_id: 'ad-hidden-' }, - ]); - expect(ts.bids).toEqual({ visible: { hb_pb: '2.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('checks for route containers directly in a hidden document', async () => { - vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); - vi.stubGlobal('requestAnimationFrame', undefined); - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 'hidden', div_id: 'div-hidden' }], - bids: { hidden: { hb_pb: '3.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/hidden-route'); - await flushAsync(); - expect(adInit).not.toHaveBeenCalled(); - - document.body.innerHTML = '
'; - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'hidden', div_id: 'div-hidden' }]); - expect(ts.bids).toEqual({ hidden: { hb_pb: '3.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('cancels a pending visible-tab frame when the document becomes hidden', async () => { - let visibility: DocumentVisibilityState = 'visible'; - vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility); - const requestAnimationFrameMock = vi.fn().mockReturnValue(17); - const cancelAnimationFrameMock = vi.fn(); - vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock); - vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock); - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 'hidden-late', div_id: 'div-hidden-late' }], - bids: { 'hidden-late': { hb_pb: '3.50' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/hidden-late-route'); - await flushAsync(); - - // A mutation while visible schedules a frame that never runs. - document.body.appendChild(document.createElement('span')); - await flushAsync(); - expect(requestAnimationFrameMock).toHaveBeenCalledTimes(1); - - // The next mutation happens after the document is hidden. It must cancel - // the stale frame and perform the presence check immediately. - visibility = 'hidden'; - document.body.innerHTML = '
'; - await flushAsync(); - - expect(cancelAnimationFrameMock).toHaveBeenCalledWith(17); - expect(adInit).toHaveBeenCalledTimes(1); - expect(ts.adSlots).toEqual([{ id: 'hidden-late', div_id: 'div-hidden-late' }]); - }); - - it('waits for every configured route ad container before applying bids', async () => { - document.body.innerHTML = '
'; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [ - { id: 'first', div_id: 'div-first' }, - { id: 'second', div_id: 'div-second' }, - ], - bids: { - first: { hb_pb: '1.00' }, - second: { hb_pb: '2.00' }, - }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/multi-slot-route'); - await flushAsync(); - - expect(adInit).not.toHaveBeenCalled(); - expect(ts.adSlots).toBeUndefined(); - - const second = document.createElement('div'); - second.id = 'div-second'; - document.body.appendChild(second); - await flushAnimationFrame(); - - expect(ts.adSlots).toEqual([ - { id: 'first', div_id: 'div-first' }, - { id: 'second', div_id: 'div-second' }, - ]); - expect(ts.bids).toEqual({ - first: { hb_pb: '1.00' }, - second: { hb_pb: '2.00' }, - }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('does not fetch when pushState targets the current path', async () => { - await importGptModule(); - - history.pushState({}, '', '/'); - await flushAsync(); - - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('fetches on replaceState navigation', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - - history.replaceState({}, '', '/replaced'); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledWith( - '/_ts/page-bids?path=%2Freplaced', - expect.objectContaining({ credentials: 'include' }) - ); - }); - - it('fetches on popstate navigation to a new path', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - - // Browsers change the URL out-of-band on back/forward, then fire popstate. - // Use the unwrapped history method so the patched handler is not invoked. - originalReplaceState({}, '', '/popped'); - window.dispatchEvent(new PopStateEvent('popstate')); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledWith( - '/_ts/page-bids?path=%2Fpopped', - expect.objectContaining({ credentials: 'include' }) - ); - }); - - it('does not re-fetch on popstate to the same path', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - - history.replaceState({}, '', '/replaced'); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledTimes(1); - - // popstate on the same path (hash-only change or scroll-restoration - // back/forward) must not re-request impressions. - window.dispatchEvent(new PopStateEvent('popstate')); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledTimes(1); - }); - - it('drops a stale response that resolves after a newer navigation started', async () => { - let resolveFirst: ((value: unknown) => void) | undefined; - fetchStub - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }) - ) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ slots: [{ id: 'newer', div_id: 'div-newer' }], bids: {} }), - }); - // Container for the newer route exists so its bids apply without waiting. - document.body.innerHTML = '
'; - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/first'); - history.pushState({}, '', '/second'); - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'newer', div_id: 'div-newer' }]); - expect(adInit).toHaveBeenCalledTimes(1); - - // First navigation's response arrives late — it must not overwrite the - // newer route's slots or trigger another adInit. - resolveFirst!({ - ok: true, - json: async () => ({ slots: [{ id: 'stale' }], bids: {} }), - }); - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'newer', div_id: 'div-newer' }]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('leaves slots and bids untouched on a non-OK response', async () => { - fetchStub.mockResolvedValue({ ok: false, status: 500 }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [{ id: 'existing' } as never]; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/error-page'); - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'existing' }]); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('retries the same path after a failed page-bids fetch (currentPath rollback)', async () => { - // A failed load must roll `currentPath` back so re-navigating to the SAME - // path retries instead of being swallowed by the no-op guard at the top of - // onNavigate. Without the rollback, currentPath would already equal the - // failed path and the second navigation would return early. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValueOnce({ ok: false, status: 500 }).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - // First navigation to the path fails; nothing is applied. - history.pushState({}, '', '/retry-page'); - await flushAsync(); - expect(ts.adSlots).toBeUndefined(); - - // Re-navigate to the same path — the retry must re-fetch and apply. - history.pushState({}, '', '/retry-page'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(2); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('does not strand a path that was aborted mid-flight then failed on the next nav', async () => { - // Rapid A→B where A is aborted mid-flight and B then fails must roll - // `currentPath` back to the last *applied* path (here the initial route), - // not to A. Rolling back to A — which never loaded — would leave it behind - // the no-op guard so a later real navigation to A never re-fetches. - document.body.innerHTML = '
'; - let resolveA: ((value: unknown) => void) | undefined; - fetchStub - // A: still in flight when B starts (aborted, never settles on its own). - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveA = resolve; - }) - ) - // B: fails. - .mockResolvedValueOnce({ ok: false, status: 500 }) - // A retried: succeeds. - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 'a', div_id: 'div-a' }], - bids: { a: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - // A starts (left in flight), then B aborts A and fails. - history.pushState({}, '', '/a'); - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.adSlots).toBeUndefined(); - - // Navigate back to /a. With the rollback keyed to the last applied path - // (the initial route) instead of B's previous path (/a), this is NOT - // swallowed by the no-op guard and re-fetches. - history.pushState({}, '', '/a'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(3); - expect(ts.adSlots).toEqual([{ id: 'a', div_id: 'div-a' }]); - expect(adInit).toHaveBeenCalledTimes(1); - - // The original aborted A fetch resolving late must not clobber the retry. - resolveA?.({ ok: true, json: async () => ({ slots: [{ id: 'stale' }], bids: {} }) }); - await flushAsync(); - expect(ts.adSlots).toEqual([{ id: 'a', div_id: 'div-a' }]); - }); - - it('falls back to the deprecated alias when the canonical path is behind Basic Auth', async () => { - // An operator `[[handlers]]` regex broad enough to cover `/_ts` answers the - // canonical path with 401 that no anonymous browser fetch can satisfy. - // Without the fallback, every SPA navigation on that deployment loses ads. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValueOnce({ ok: false, status: 401 }).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/auth-gated'); - await flushAsync(); - - expect(fetchStub).toHaveBeenNthCalledWith( - 1, - '/_ts/page-bids?path=%2Fauth-gated', - expect.anything() - ); - // The fallback marks itself so the server can separate a current bundle - // that could not use the canonical path (a deployment to fix) from a - // pre-rename bundle (which ages out on its own). - expect(fetchStub).toHaveBeenNthCalledWith( - 2, - '/__ts/page-bids?path=%2Fauth-gated', - expect.objectContaining({ headers: { 'X-TSJS-Page-Bids': 'fallback' } }) - ); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('falls back to the deprecated alias when the canonical path returns a non-JSON body', async () => { - // A server rolled back to before the rename does not register the canonical - // path, so it falls through to the publisher-origin proxy and answers 200 - // HTML. That is the wrong endpoint, not a transient failure. - document.body.innerHTML = '
'; - fetchStub - .mockResolvedValueOnce({ - ok: true, - json: async () => { - throw new SyntaxError('Unexpected token <'); - }, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - - history.pushState({}, '', '/rolled-back'); - await flushAsync(); - - expect(fetchStub).toHaveBeenNthCalledWith( - 2, - '/__ts/page-bids?path=%2Frolled-back', - expect.anything() - ); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - }); - - it('stays on the alias for the rest of the session once the fallback works', async () => { - // Re-probing the canonical path on every navigation would double the - // request count for the whole session on an affected deployment. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValueOnce({ ok: false, status: 401 }).mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - - history.pushState({}, '', '/first'); - await flushAsync(); - history.pushState({}, '', '/second'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(3); - expect(fetchStub).toHaveBeenNthCalledWith( - 3, - '/__ts/page-bids?path=%2Fsecond', - expect.anything() - ); - }); - - it('does not retry the alias when the endpoint denies the request', async () => { - // 403 is the cross-site gate, which applies to both registered paths — the - // alias would deny it identically, so retrying only burns a request. - fetchStub.mockResolvedValue({ ok: false, status: 403 }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/denied'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(1); - expect(ts.adSlots).toBeUndefined(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('is idempotent — repeated install calls do not double-fetch a navigation', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - // Module init already installed the hook; both calls must be no-ops. - installSpaAuctionHook(); - installSpaAuctionHook(); - - history.pushState({}, '', '/once'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(1); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts new file mode 100644 index 000000000..cead76a52 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createBrowserGoogletagAdapter, + type GoogletagAdapter, + type GoogletagPublisherCallObserver, +} from '../../../src/adapters/googletag'; +import { createGptStartup } from '../../../src/integrations/gpt/startup'; +import { createSlotService, type SlotService } from '../../../src/services/slots'; + +describe('GPT startup bridge', () => { + it('installs one reversible typed observer and delegates all handoff state to slots', () => { + const order: string[] = []; + let observer: GoogletagPublisherCallObserver | undefined; + const release = vi.fn(); + const observePublisherCalls = vi.fn((candidate: GoogletagPublisherCallObserver) => { + observer = candidate; + return release; + }); + const adapter = Object.freeze({ observePublisherCalls }) as unknown as GoogletagAdapter; + const slot = {}; + const slots = Object.freeze({ + claimPublisherGptSlot: vi.fn(() => Object.freeze({ action: 'handoff' as const, slot })), + preparePublisherDisplay: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + preparePublisherRefresh: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + recordPublisherDestruction: vi.fn(() => true), + start: vi.fn(() => { + order.push('slots:start'); + return Object.freeze({ + status: 'present' as const, + result: Promise.resolve(), + dispose: vi.fn(), + }); + }), + }) satisfies Pick< + SlotService, + | 'claimPublisherGptSlot' + | 'preparePublisherDisplay' + | 'preparePublisherRefresh' + | 'recordPublisherDestruction' + | 'start' + >; + const start = vi.fn(() => order.push('external:start')); + const startup = createGptStartup({ googletag: adapter, slots: () => slots, start }); + + expect(startup.activate()).toBe(release); + expect(slots.start).not.toHaveBeenCalled(); + expect(observePublisherCalls).toHaveBeenCalledTimes(1); + expect( + observer?.defineSlot?.({ + adUnitPath: '/publisher', + elementId: 'slot', + initialLoadDisabled: true, + sizes: [300, 250], + }) + ).toEqual({ action: 'handoff', slot }); + expect(observer?.display?.({ initialLoadDisabled: true, target: 'slot' })).toEqual({ + action: 'suppress', + }); + expect( + observer?.refresh?.({ + requestedSlots: undefined, + slots: Object.freeze([slot]), + options: undefined, + }) + ).toEqual({ action: 'suppress' }); + observer?.destroySlots?.({ slots: Object.freeze([slot, {}]) }); + expect(slots.recordPublisherDestruction).toHaveBeenCalledTimes(2); + + const config = Object.freeze({ disableInitialLoad: true }); + startup.start(config); + expect(slots.start).toHaveBeenCalledOnce(); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + expect(order).toEqual(['slots:start', 'external:start']); + }); + + it('keeps reversible activation timer-free and begins readiness only from start', () => { + vi.useFakeTimers(); + const adapter = createBrowserGoogletagAdapter({}); + const slots = createSlotService({ googletag: adapter }); + const startup = createGptStartup({ googletag: adapter, slots: () => slots }); + + const release = startup.activate(); + slots.activate(); + expect(vi.getTimerCount()).toBe(0); + + startup.start(Object.freeze({})); + expect(vi.getTimerCount()).toBe(1); + + release(); + slots.dispose(); + adapter.dispose(); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + }); + + it('installs one optional reversible Prebid refresh policy into the sole GPT observer', () => { + let observer: GoogletagPublisherCallObserver | undefined; + const observePublisherCalls = vi.fn((candidate: GoogletagPublisherCallObserver) => { + observer = candidate; + return vi.fn(); + }); + const adapter = Object.freeze({ observePublisherCalls }) as unknown as GoogletagAdapter; + const slot = Object.freeze({ id: 'slot' }); + const admission = Object.freeze({ commit: vi.fn(), rollback: vi.fn() }); + const completion = Promise.resolve(); + const slots = Object.freeze({ + claimPublisherGptSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + preparePublisherDisplay: vi.fn(() => Object.freeze({ action: 'forward' as const })), + preparePublisherRefresh: vi.fn(() => + Object.freeze({ action: 'forward' as const, admission }) + ), + recordPublisherDestruction: vi.fn(), + start: vi.fn(), + }) as unknown as Pick< + SlotService, + | 'claimPublisherGptSlot' + | 'preparePublisherDisplay' + | 'preparePublisherRefresh' + | 'recordPublisherDestruction' + | 'start' + >; + const startup = createGptStartup({ googletag: adapter, slots: () => slots }); + const boundary = startup as typeof startup & { + installRefreshPolicy: ( + policy: Readonly<{ prepare: (call: unknown) => PromiseLike | undefined }> + ) => (() => void) | undefined; + }; + const prepare = vi.fn(() => completion); + const release = boundary.installRefreshPolicy(Object.freeze({ prepare })); + + expect(release).toBeTypeOf('function'); + expect( + boundary.installRefreshPolicy(Object.freeze({ prepare: vi.fn(() => completion) })) + ).toBeUndefined(); + startup.activate(); + const call = Object.freeze({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + options: Object.freeze({ changeCorrelator: false }), + }); + expect(observer?.refresh?.(call)).toEqual({ + action: 'defer', + admission, + completion, + slots: [slot], + }); + expect(prepare).toHaveBeenCalledExactlyOnceWith(call); + + release?.(); + release?.(); + expect(observer?.refresh?.(call)).toEqual({ action: 'forward', admission }); + expect(prepare).toHaveBeenCalledOnce(); + expect(boundary.installRefreshPolicy(Object.freeze({ prepare: vi.fn() }))).toBeTypeOf( + 'function' + ); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts index 2e2ae2d2b..33db076ac 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { GptDiagnosticsBinding } from '../../../src/core/types'; +import { DiagnosticsSubscriberLimitError } from '../../../src/core/trace'; import { GptDiagnosticsApiController } from '../../../src/integrations/gpt_diagnostics/api'; import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; @@ -63,7 +64,7 @@ function fakeApiStore() { evictedRequestCycles: 0, }, })), - subscribe: vi.fn(() => () => undefined), + subscribeCommits: vi.fn(() => () => undefined), recordTrustedServerOpportunity: vi.fn(), recordPrebidRefresh: vi.fn(), recordTrustedServerCreativeRequest: vi.fn((_auctionSlotId: string) => 41), @@ -72,6 +73,16 @@ function fakeApiStore() { }; } +function scheduleInto(tasks: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + tasks.push(callback); + return () => { + const index = tasks.indexOf(callback); + if (index >= 0) tasks.splice(index, 1); + }; + }; +} + beforeEach(() => { vi.restoreAllMocks(); window.history.replaceState({}, '', '/article?private=value#fragment'); @@ -368,6 +379,11 @@ describe('GptDiagnosticsApiController', () => { const second = controller.api.snapshot(); expect(second).not.toBe(snapshot); expect(second.slots).not.toBe(snapshot.slots); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.page)).toBe(true); + expect(Object.isFrozen(snapshot.slots)).toBe(true); + expect(Object.isFrozen(snapshot.slots[0]?.requests)).toBe(true); + expect(Object.isFrozen(snapshot.slots[0]?.requests[0]?.durations)).toBe(true); }); it('coalesces store and binding updates and isolates subscribers', () => { @@ -383,7 +399,7 @@ describe('GptDiagnosticsApiController', () => { { show: vi.fn(), hide: vi.fn() }, { now: () => new Date('2026-07-28T00:00:00.000Z'), - schedule: (callback) => scheduled.push(callback), + schedule: scheduleInto(scheduled), } ); controller.api.subscribe(() => { @@ -408,87 +424,136 @@ describe('GptDiagnosticsApiController', () => { expect(listener).toHaveBeenCalledTimes(1); }); - it('gives subscribers isolated copies of one captured snapshot', () => { + it('captures subscriber membership per commit and coalesces to the latest snapshot', () => { const scheduled: Array<() => void> = []; - const sourceListeners = new Set<() => void>(); - const store = fakeApiStore(); - store.subscribe.mockImplementation((listener) => { - sourceListeners.add(listener); - return () => sourceListeners.delete(listener); - }); - store.snapshot.mockReturnValue({ - gptObserved: true, - slots: [ - { - runtimeSlotNumber: 1, - slotElementId: 'ad-slot-example', - adUnitPath: '/example/site/banner', - requests: [ - { - requestNumber: 1, - durations: { requestToResponseMs: 10 }, - incompleteSequence: false, - adManager: { yieldGroupIds: [10], companyIds: [20] }, - trustedServerCreativeFailures: ['cache_fetch_failed' as const], - }, - ], - }, - ], - callbackIssues: [], - attributionIssues: [ - { - reason: 'creative_attempt_expired' as const, - timestampMs: 30, - }, - ], - coverage: emptyCoverage(), - metadata: { - droppedCallbacks: 0, - droppedAttributionIssues: 0, - evictedSlots: 0, - evictedRequestCycles: 0, - }, - }); + const store = new GptDiagnosticsStore({ now: () => 1, schedule: (callback) => callback() }); + const bindings = new FakeBindings(); const controller = new GptDiagnosticsApiController( store, - new FakeBindings(), + bindings, { show: vi.fn(), hide: vi.fn() }, { - now: () => new Date('2026-08-10T00:00:00.000Z'), - schedule: (callback) => scheduled.push(callback), + now: () => new Date('2026-07-28T00:00:00.000Z'), + schedule: scheduleInto(scheduled), } ); - let observedSnapshot: ReturnType | undefined; - - controller.api.subscribe((snapshot) => { - const cycle = snapshot.slots[0]!.requests[0]!; - cycle.durations.requestToResponseMs = 999; - cycle.adManager!.yieldGroupIds!.push(99); - cycle.trustedServerCreativeFailures!.push('response_post_failed'); - snapshot.attributionIssues?.push({ - reason: 'creative_attempt_unknown', - timestampMs: 40, - }); - snapshot.coverage.slotRequested.observed = 99; - snapshot.metadata.droppedCallbacks = 99; + const first = vi.fn(); + const second = vi.fn(); + const releaseFirst = controller.api.subscribe(first); + const observedSlot = fakeSlot(); + + store.recordSlotRequested(observedSlot); + controller.api.subscribe(second); + releaseFirst(); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + + store.recordSlotVisibilityChanged(observedSlot, 10); + store.recordSlotVisibilityChanged(observedSlot, 20); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(second).toHaveBeenCalledOnce(); + expect(second.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + slots: [expect.objectContaining({ currentVisibilityPercentage: 20 })], + }) + ); + }); + + it('excludes a subscriber registered after the store commit but before source microtasks', () => { + const sourceTasks: Array<() => void> = []; + const publicTasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => sourceTasks.push(callback), }); - controller.api.subscribe((snapshot) => { - observedSnapshot = snapshot; + const controller = new GptDiagnosticsApiController( + store, + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() }, + { schedule: scheduleInto(publicTasks) } + ); + + store.recordSlotRequested(fakeSlot()); + const late = vi.fn(); + controller.api.subscribe(late); + while (sourceTasks.length > 0) sourceTasks.shift()?.(); + while (publicTasks.length > 0) publicTasks.shift()?.(); + + expect(late).not.toHaveBeenCalled(); + }); + + it('includes a subscriber registered before the store commit without calling it inline', () => { + const sourceTasks: Array<() => void> = []; + const publicTasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => sourceTasks.push(callback), }); + const controller = new GptDiagnosticsApiController( + store, + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() }, + { schedule: scheduleInto(publicTasks) } + ); + const listener = vi.fn(); + controller.api.subscribe(listener); - for (const listener of sourceListeners) listener(); - expect(scheduled).toHaveLength(1); - scheduled.shift()!(); + store.recordSlotRequested(fakeSlot()); + expect(listener).not.toHaveBeenCalled(); + while (sourceTasks.length > 0) sourceTasks.shift()?.(); + expect(listener).not.toHaveBeenCalled(); + while (publicTasks.length > 0) publicTasks.shift()?.(); + + expect(listener).toHaveBeenCalledOnce(); + }); + + it('defers a subscriber registered during dispatch until the next commit', () => { + const publicTasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ now: () => 1, schedule: (callback) => callback() }); + const controller = new GptDiagnosticsApiController( + store, + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() }, + { schedule: scheduleInto(publicTasks) } + ); + const second = vi.fn(); + const first = vi.fn(() => controller.api.subscribe(second)); + controller.api.subscribe(first); + + const observedSlot = fakeSlot(); + store.recordSlotRequested(observedSlot); + expect(first).not.toHaveBeenCalled(); + publicTasks.shift()?.(); + expect(first).toHaveBeenCalledOnce(); + expect(second).not.toHaveBeenCalled(); + + store.recordSlotVisibilityChanged(observedSlot, 10); + publicTasks.shift()?.(); + expect(first).toHaveBeenCalledTimes(2); + expect(second).toHaveBeenCalledOnce(); + }); + + it('validates callability before enforcing the shared 32-subscriber cap', () => { + const controller = new GptDiagnosticsApiController( + new GptDiagnosticsStore({ now: () => 1 }), + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() } + ); + const releases = Array.from({ length: 32 }, () => controller.api.subscribe(() => undefined)); - expect(store.snapshot).toHaveBeenCalledTimes(1); - expect(observedSnapshot?.capturedAt).toBe('2026-08-10T00:00:00.000Z'); - const observedCycle = observedSnapshot?.slots[0]?.requests[0]; - expect(observedCycle?.durations.requestToResponseMs).toBe(10); - expect(observedCycle?.adManager?.yieldGroupIds).toEqual([10]); - expect(observedCycle?.trustedServerCreativeFailures).toEqual(['cache_fetch_failed']); - expect(observedSnapshot?.attributionIssues).toHaveLength(1); - expect(observedSnapshot?.coverage.slotRequested.observed).toBe(0); - expect(observedSnapshot?.metadata.droppedCallbacks).toBe(0); + expect(() => controller.api.subscribe(null as never)).toThrow(TypeError); + expect(() => controller.api.subscribe(() => undefined)).toThrow( + DiagnosticsSubscriberLimitError + ); + expect(() => controller.api.subscribe(() => undefined)).toThrow( + expect.objectContaining({ code: 'subscriber_capacity', surface: 'gpt' }) + ); + releases[0]?.(); + releases[0]?.(); + expect(controller.api.subscribe(() => undefined)).toEqual(expect.any(Function)); }); it('delegates show and hide without mutating diagnostics data', () => { @@ -559,13 +624,17 @@ describe('GptDiagnosticsApiController', () => { store, bindings, { show: vi.fn(), hide: vi.fn() }, - { schedule: (callback) => scheduled.push(callback) } + { schedule: scheduleInto(scheduled) } ); const listener = vi.fn(); controller.api.subscribe(listener); - controller.destroy(); store.recordSlotRequested(fakeSlot()); + expect(scheduled).toHaveLength(1); + + controller.destroy(); + while (scheduled.length > 0) scheduled.shift()?.(); + store.recordSlotVisibilityChanged(fakeSlot(), 10); bindings.emit(); expect(scheduled).toEqual([]); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts index 675e4f442..76ce64fba 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { GptDiagnosticsBinding } from '../../../src/core/types'; import type { GptDiagnosticsBindingView } from '../../../src/integrations/gpt_diagnostics/binding'; import { + formatGptDiagnosticsBadgeText, GptDiagnosticsBadgeManager, - gptDiagnosticsBadgeTextForTest, } from '../../../src/integrations/gpt_diagnostics/badges'; import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; @@ -64,6 +64,18 @@ function runFrame(frames: Array<() => void>): void { frame(); } +const gptDiagnosticsBadgeTextForTest = formatGptDiagnosticsBadgeText; + +function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + frames.push(callback); + return () => { + const index = frames.indexOf(callback); + if (index >= 0) frames.splice(index, 1); + }; + }; +} + beforeEach(() => { document.body.replaceChildren(); Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 }); @@ -105,7 +117,7 @@ describe('GptDiagnosticsBadgeManager', () => { const layer = document.createElement('div'); document.body.append(layer); const manager = new GptDiagnosticsBadgeManager(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); manager.setLayer(layer); runFrame(frames); @@ -245,7 +257,7 @@ describe('GptDiagnosticsBadgeManager', () => { it('uses only GPT-observed lifecycle facts in badge text', () => { expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, requestedAtMs: 0, responseAtMs: 276, @@ -262,7 +274,7 @@ describe('GptDiagnosticsBadgeManager', () => { }) ).toBe('Filled · 728×90\nResponse 276 ms · Render 42 ms\nViewable after 1 s'); expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, isEmpty: true, incompleteSequence: false, @@ -270,7 +282,7 @@ describe('GptDiagnosticsBadgeManager', () => { }) ).toBe('Empty'); expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, renderAtMs: 5, incompleteSequence: false, @@ -278,42 +290,38 @@ describe('GptDiagnosticsBadgeManager', () => { }) ).toBe('Rendered (fill unknown)'); expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, incompleteSequence: false, durations: {}, }) ).toBe('Pending'); - expect( - gptDiagnosticsBadgeTextForTest({ - requestNumber: 1, - isEmpty: false, - renderAtMs: 5, - incompleteSequence: true, - durations: {}, - }) - ).toBe('Filled\nIncomplete sequence'); - // Assert over rendered text: the delivery vocabulary lives in a helper that - // a `toString()` of this function would not include. - for (const delivery of [ - 'trusted_server_response_sent', - 'trusted_server_selected', - 'candidate_unconfirmed', - 'no_candidate', - 'unknown', - 'pending', - 'not_applicable', - ] as const) { - expect( - gptDiagnosticsBadgeTextForTest({ - requestNumber: 1, - isEmpty: false, - incompleteSequence: false, - durations: {}, - delivery, - }) - ).not.toMatch(/GAM winner|bidder|provenance/i); - } + expect(formatGptDiagnosticsBadgeText.toString()).not.toMatch( + /Trusted Server|GAM winner|Prebid|bidder|provenance/i + ); + }); + + it('rejects a counterfeit bound element instead of accepting DOM-shaped data', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + store.recordSlotRequested(slot('counterfeit')); + const bindings = new FakeBindings(); + const counterfeit = Object.freeze({ + getBoundingClientRect: () => rectangle(10, 10, 300, 250), + isConnected: true, + }) as unknown as HTMLElement; + bindings.set(1, { status: 'bound' }, counterfeit, true); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(store, bindings, { + scheduleFrame: queueFrame(frames), + }); + + manager.setLayer(layer); + runFrame(frames); + + expect(layer.querySelector('.tsgd-badge')).toBeNull(); + manager.destroy(); }); it('positions in the overlay layer and coalesces scroll and resize updates', () => { @@ -336,7 +344,7 @@ describe('GptDiagnosticsBadgeManager', () => { const layer = document.createElement('div'); document.body.append(layer); const manager = new GptDiagnosticsBadgeManager(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); manager.setLayer(layer); runFrame(frames); @@ -373,7 +381,7 @@ describe('GptDiagnosticsBadgeManager', () => { const layer = document.createElement('div'); document.body.append(layer); const manager = new GptDiagnosticsBadgeManager(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); manager.setLayer(layer); runFrame(frames); @@ -412,7 +420,7 @@ describe('GptDiagnosticsBadgeManager', () => { MutationObserver: undefined, ResizeObserver: undefined, }), - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); expect(() => { @@ -421,4 +429,56 @@ describe('GptDiagnosticsBadgeManager', () => { }).not.toThrow(); manager.destroy(); }); + + it('cancels a pending badge update on destroy and suppresses a hostile late callback', () => { + const frames: Array<() => void> = []; + const cancel = vi.fn(); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return cancel; + }, + }); + const update = vi.spyOn(manager, 'update'); + manager.setLayer(layer); + + manager.destroy(); + frames[0]?.(); + + expect(cancel).toHaveBeenCalledOnce(); + expect(update).not.toHaveBeenCalled(); + }); + + it('runs one scheduled badge callback at most once', () => { + const frames: Array<() => void> = []; + const manager = new GptDiagnosticsBadgeManager(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return vi.fn(); + }, + }); + const update = vi.spyOn(manager, 'update'); + manager.setLayer(document.createElement('div')); + + frames[0]?.(); + frames[0]?.(); + + expect(update).toHaveBeenCalledOnce(); + manager.destroy(); + }); + + it('isolates a hostile frame cancellation during destroy', () => { + const cancel = vi.fn(() => { + throw new Error('cancel failed'); + }); + const manager = new GptDiagnosticsBadgeManager(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: () => cancel, + }); + manager.setLayer(document.createElement('div')); + + expect(() => manager.destroy()).not.toThrow(); + expect(cancel).toHaveBeenCalledOnce(); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts index 5a1773a59..5cd81c754 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts @@ -33,13 +33,23 @@ function createStore(): GptDiagnosticsStore { function createManager( store: GptDiagnosticsStore, - scheduleFrame?: (callback: () => void) => void + scheduleFrame?: (callback: () => void) => () => void ): GptDiagnosticsBindingManager { const manager = new GptDiagnosticsBindingManager(store, { scheduleFrame }); managers.push(manager); return manager; } +function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + frames.push(callback); + return () => { + const index = frames.indexOf(callback); + if (index >= 0) frames.splice(index, 1); + }; + }; +} + function setRectangle( element: HTMLElement, rectangle: { top: number; left: number; width: number; height: number } @@ -108,6 +118,35 @@ describe('GptDiagnosticsBindingManager', () => { }); }); + it('fails closed when the supplied realm has a hostile HTMLElement constructor', () => { + const element = document.createElement('div'); + element.id = 'hostile-realm-slot'; + document.body.append(element); + const store = createStore(); + store.recordSlotRequested(fakeSlot(element.id)); + const hostileWindow = { + CSS: window.CSS, + MutationObserver: undefined, + addEventListener: window.addEventListener.bind(window), + get HTMLElement(): never { + throw new Error('hostile HTMLElement constructor'); + }, + innerHeight: window.innerHeight, + innerWidth: window.innerWidth, + removeEventListener: window.removeEventListener.bind(window), + }; + + const manager = new GptDiagnosticsBindingManager(store, { + window: hostileWindow as never, + }); + managers.push(manager); + + expect(manager.get(1)).toMatchObject({ + binding: { status: 'unbound', reason: 'missing_element' }, + visible: false, + }); + }); + it('reports an empty GPT element ID as unbound without a synthetic DOM ID', () => { const store = createStore(); store.recordSlotRequested(fakeSlot()); @@ -118,7 +157,7 @@ describe('GptDiagnosticsBindingManager', () => { status: 'unbound', reason: 'missing_slot_element_id', }); - expect(store.snapshot().slots[0].slotElementId).toBeUndefined(); + expect(store.snapshot().slots[0]!.slotElementId).toBeUndefined(); }); it('treats duplicate DOM IDs as ambiguous', () => { @@ -258,7 +297,7 @@ describe('GptDiagnosticsBindingManager', () => { document.body.append(element); const store = createStore(); store.recordSlotRequested(fakeSlot('observed')); - const manager = createManager(store, (callback) => frames.push(callback)); + const manager = createManager(store, queueFrame(frames)); const unrelated = document.createElement('div'); unrelated.id = 'unrelated'; @@ -284,7 +323,7 @@ describe('GptDiagnosticsBindingManager', () => { it('coalesces store-driven refreshes to one animation frame', () => { const scheduled: Array<() => void> = []; const store = createStore(); - const manager = createManager(store, (callback) => scheduled.push(callback)); + const manager = createManager(store, queueFrame(scheduled)); const listener = vi.fn(); manager.subscribe(listener); const slot = fakeSlot('scheduled'); @@ -301,4 +340,52 @@ describe('GptDiagnosticsBindingManager', () => { reason: 'missing_element', }); }); + + it('cancels a pending refresh on destroy and suppresses a hostile late callback', () => { + const frames: Array<() => void> = []; + const cancel = vi.fn(); + const store = createStore(); + const manager = createManager(store, (callback) => { + frames.push(callback); + return cancel; + }); + const listener = vi.fn(); + manager.subscribe(listener); + store.recordSlotRequested(fakeSlot('pending-destroy')); + + manager.destroy(); + frames[0]?.(); + + expect(cancel).toHaveBeenCalledOnce(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('runs one scheduled refresh callback at most once', () => { + const frames: Array<() => void> = []; + const store = createStore(); + const manager = createManager(store, (callback) => { + frames.push(callback); + return vi.fn(); + }); + const listener = vi.fn(); + manager.subscribe(listener); + store.recordSlotRequested(fakeSlot('once')); + + frames[0]?.(); + frames[0]?.(); + + expect(listener).toHaveBeenCalledOnce(); + }); + + it('isolates a hostile frame cancellation during destroy', () => { + const store = createStore(); + const cancel = vi.fn(() => { + throw new Error('cancel failed'); + }); + const manager = createManager(store, () => cancel); + store.recordSlotRequested(fakeSlot('hostile-cancel')); + + expect(() => manager.destroy()).not.toThrow(); + expect(cancel).toHaveBeenCalledOnce(); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/data_api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/data_api.test.ts new file mode 100644 index 000000000..7d745e00d --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/data_api.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + GptDiagnosticsDataApiController, + type GptDiagnosticsPresentationControls, +} from '../../../src/integrations/gpt_diagnostics/data_api'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; + +function controls(overrides: Partial = {}) { + return Object.freeze({ + dispose: vi.fn(), + download: vi.fn(), + exportBinding: vi.fn(() => Object.freeze({ status: 'bound' as const })), + hide: vi.fn(), + show: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + ...overrides, + }); +} + +function controller(store = new GptDiagnosticsStore()) { + return new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + callback(); + return () => undefined; + }, + }); +} + +describe('critical GPT diagnostics data API', () => { + it('deeply isolates delivery evidence and exports attribution issues', () => { + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const slot = Object.freeze({ + getSlotElementId: () => 'delivery-slot', + getAdUnitPath: () => '/example/delivery-slot', + }); + store.recordSlotRequested(slot, 1); + store.recordSlotRenderEnded( + slot, + { + isEmpty: false, + adManager: { + yieldGroupIds: [10], + companyIds: [20], + }, + }, + 2 + ); + store.recordTrustedServerCreativeRequest('unknown-auction-slot'); + const target = controller(store); + + const snapshot = target.api.snapshot(); + const cycle = snapshot.slots[0]?.requests[0]; + + expect(cycle?.adManager).toEqual({ yieldGroupIds: [10], companyIds: [20] }); + expect(Object.isFrozen(cycle?.adManager)).toBe(true); + expect(Object.isFrozen(cycle?.adManager?.yieldGroupIds)).toBe(true); + expect(Object.isFrozen(cycle?.adManager?.companyIds)).toBe(true); + expect(snapshot.attributionIssues).toEqual([ + expect.objectContaining({ reason: 'creative_request_without_slot' }), + ]); + expect(Object.isFrozen(snapshot.attributionIssues)).toBe(true); + expect(Object.isFrozen(snapshot.attributionIssues?.[0])).toBe(true); + target.destroy(); + }); + + it('coalesces exactly zero, one, and two committed updates to the latest snapshot', () => { + const tasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const target = new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + tasks.push(callback); + return () => undefined; + }, + }); + const listener = vi.fn(); + target.api.subscribe(listener); + const slot = Object.freeze({ + getSlotElementId: () => 'coalesced-slot', + getAdUnitPath: () => '/example/coalesced-slot', + }); + + expect(tasks).toEqual([]); + store.recordSlotRequested(slot); + expect(tasks).toHaveLength(1); + tasks.shift()?.(); + expect(listener).toHaveBeenCalledOnce(); + + store.recordSlotVisibilityChanged(slot, 10); + store.recordSlotVisibilityChanged(slot, 20); + expect(tasks).toHaveLength(1); + tasks.shift()?.(); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + slots: [expect.objectContaining({ currentVisibilityPercentage: 20 })], + }) + ); + target.destroy(); + }); + + it('captures subscriber ids at commit and suppresses an id unsubscribed before delivery', () => { + const tasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const target = new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + tasks.push(callback); + return () => undefined; + }, + }); + const first = vi.fn(); + const second = vi.fn(); + const releaseFirst = target.api.subscribe(first); + const slot = Object.freeze({ getSlotElementId: () => 'captured-id-slot' }); + + store.recordSlotRequested(slot); + target.api.subscribe(second); + releaseFirst(); + tasks.shift()?.(); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + + store.recordSlotVisibilityChanged(slot, 25); + tasks.shift()?.(); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + target.destroy(); + }); + + it('keeps a slow listener and its reentrant commit on separate notifier task stacks', () => { + const tasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const target = new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + tasks.push(callback); + return () => undefined; + }, + }); + const slot = Object.freeze({ getSlotElementId: () => 'slow-listener-slot' }); + let listenerDepth = 0; + let maximumDepth = 0; + const slow = vi.fn(() => { + listenerDepth += 1; + maximumDepth = Math.max(maximumDepth, listenerDepth); + if (slow.mock.calls.length === 1) store.recordSlotVisibilityChanged(slot, 50); + listenerDepth -= 1; + }); + const peer = vi.fn(); + target.api.subscribe(slow); + target.api.subscribe(peer); + + store.recordSlotRequested(slot); + expect(slow).not.toHaveBeenCalled(); + expect(peer).not.toHaveBeenCalled(); + tasks.shift()?.(); + expect(slow).toHaveBeenCalledOnce(); + expect(peer).toHaveBeenCalledOnce(); + expect(tasks).toHaveLength(1); + tasks.shift()?.(); + expect(slow).toHaveBeenCalledTimes(2); + expect(peer).toHaveBeenCalledTimes(2); + expect(maximumDepth).toBe(1); + target.destroy(); + }); + + it('keeps public identity stable and does not spend public subscriber capacity on presentation', () => { + const target = controller(); + const api = target.api; + const presentation = controls(); + const factory = vi.fn((source, attachedApi) => { + expect(source).toEqual( + expect.objectContaining({ + bindingInputs: expect.any(Function), + snapshot: expect.any(Function), + subscribe: expect.any(Function), + }) + ); + expect(attachedApi).toBe(api); + return presentation; + }); + + const detach = target.attachPresentation(factory); + const publicReleases = Array.from({ length: 32 }, () => api.subscribe(vi.fn())); + + expect(() => api.subscribe(vi.fn())).toThrow( + expect.objectContaining({ code: 'subscriber_capacity', surface: 'gpt' }) + ); + expect(target.api).toBe(api); + detach(); + detach(); + expect(presentation.subscribe).toHaveBeenCalledOnce(); + expect(vi.mocked(presentation.subscribe).mock.results[0]?.value).toHaveBeenCalledOnce(); + expect(presentation.dispose).toHaveBeenCalledOnce(); + expect(target.api).toBe(api); + publicReleases.forEach((release) => release()); + target.destroy(); + }); + + it('validates callability before state and rejects reentrant attachment without losing the outer owner', () => { + const target = controller(); + const presentation = controls(); + const nested = vi.fn(); + const detach = target.attachPresentation(() => { + expect(() => target.attachPresentation(nested)).toThrow( + 'GPT diagnostics presentation is unavailable' + ); + return presentation; + }); + + expect(nested).not.toHaveBeenCalled(); + expect(() => target.attachPresentation(null as never)).toThrow( + 'GPT diagnostics presentation factory must be callable' + ); + expect(() => target.attachPresentation(() => controls())).toThrow( + 'GPT diagnostics presentation is unavailable' + ); + detach(); + expect(() => target.attachPresentation(() => controls())).not.toThrow(); + target.destroy(); + }); + + it.each([ + [ + 'malformed controls', + () => Object.freeze({ dispose: vi.fn() }), + 'GPT diagnostics presentation controls are malformed', + ], + [ + 'invalid subscription disposer', + () => controls({ subscribe: vi.fn(() => undefined as never) }), + 'GPT diagnostics presentation disposer is unavailable', + ], + [ + 'throwing subscription', + () => + controls({ + subscribe: vi.fn(() => { + throw new Error('subscription failed'); + }), + }), + 'subscription failed', + ], + ])('rolls back %s without publishing presentation ownership', (_name, create, message) => { + const target = controller(); + const presentation = create(); + + expect(() => target.attachPresentation(() => presentation as never)).toThrow(message); + expect(presentation.dispose).toHaveBeenCalledOnce(); + expect(() => target.attachPresentation(() => controls())).not.toThrow(); + target.destroy(); + }); + + it('releases subscription and controls independently during detach and destroy', () => { + const detachedDispose = vi.fn(); + const target = controller(); + const detach = target.attachPresentation(() => + controls({ + dispose: detachedDispose, + subscribe: vi.fn(() => () => { + throw new Error('hostile subscription release'); + }), + }) + ); + + expect(() => detach()).not.toThrow(); + expect(detachedDispose).toHaveBeenCalledOnce(); + + const destroyedDispose = vi.fn(); + target.attachPresentation(() => + controls({ + dispose: destroyedDispose, + subscribe: vi.fn(() => () => { + throw new Error('hostile destroy release'); + }), + }) + ); + expect(() => target.destroy()).not.toThrow(); + expect(destroyedDispose).toHaveBeenCalledOnce(); + }); + + it.each(['throw', 'invalid disposer'] as const)( + 'contains a notifier scheduler %s and recovers on the next commit', + (failure) => { + const tasks: Array<() => void> = []; + let attempts = 0; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const target = new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + attempts += 1; + if (attempts === 1) { + if (failure === 'throw') throw new Error('fictional scheduler failure'); + return undefined as never; + } + tasks.push(callback); + return () => undefined; + }, + }); + target.api.subscribe(() => { + throw new Error('fictional subscriber failure'); + }); + const listener = vi.fn(); + target.api.subscribe(listener); + const slot = Object.freeze({ + getSlotElementId: () => 'notifier-slot', + getAdUnitPath: () => '/example/notifier-slot', + }); + + expect(() => store.recordSlotRequested(slot)).not.toThrow(); + expect(listener).not.toHaveBeenCalled(); + expect(() => store.recordSlotVisibilityChanged(slot, 25)).not.toThrow(); + expect(tasks).toHaveLength(1); + expect(() => tasks.shift()?.()).not.toThrow(); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + slots: [expect.objectContaining({ currentVisibilityPercentage: 25 })], + }) + ); + target.destroy(); + } + ); + + it('makes a retained scheduled notifier inert after controller destruction', () => { + const tasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const target = new GptDiagnosticsDataApiController(store, { + location: { origin: 'https://publisher.example', pathname: '/article' }, + schedule: (callback) => { + tasks.push(callback); + return () => undefined; + }, + }); + const listener = vi.fn(); + target.api.subscribe(listener); + + store.recordSlotRequested(Object.freeze({ getSlotElementId: () => 'stale-notifier-slot' })); + expect(tasks).toHaveLength(1); + target.destroy(); + expect(() => tasks.shift()?.()).not.toThrow(); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts index fa50d6366..5971a0380 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts @@ -1,69 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; -import { - installGptDiagnosticsRuntime, - isGptDiagnosticsActive, -} from '../../../src/integrations/gpt_diagnostics'; +import type { GoogletagDiagnosticsFact } from '../../../src/adapters/googletag'; +import { createGptDiagnosticsFactBuffer } from '../../../src/integrations/gpt/diagnostics_facts'; +import { createGptDiagnosticsRuntime } from '../../../src/composition/browser_test_gpt_diagnostics'; import { GPT_DIAGNOSTICS_HOST_ID } from '../../../src/integrations/gpt_diagnostics/overlay'; -interface FakeSlot { - getSlotElementId(): string; - getAdUnitPath(): string; -} - -type Listener = (event: unknown) => void; - -type DiagnosticsTestWindow = NonNullable[0]>; - -const target = window as unknown as DiagnosticsTestWindow; - -function coreApi(): TsjsApi { - return { - version: 'test', - que: [], - addAdUnits: vi.fn(), - renderAdUnit: vi.fn(), - renderAllAdUnits: vi.fn(), - }; -} - -function installGptStub() { - const listeners = new Map(); - const addEventListener = vi.fn((name: string, listener: Listener) => { - const existing = listeners.get(name) ?? []; - existing.push(listener); - listeners.set(name, existing); +function slot(id: string): GoogletagDiagnosticsFact['slot'] { + return Object.freeze({ + token: Object.freeze(Object.create(null) as object), + elementId: id, + adUnitPath: `/example/site/${id}`, }); - const queue = { - push: vi.fn((callback: () => void) => { - callback(); - return 1; - }), - }; - target.googletag = { - cmd: queue, - pubads: () => ({ addEventListener }), - }; - return { - addEventListener, - queue, - emit(name: string, event: Record) { - for (const listener of listeners.get(name) ?? []) listener(event); - }, - }; -} - -function slot(id: string): FakeSlot { - return { - getSlotElementId: () => id, - getAdUnitPath: () => `/example/site/${id}`, - }; } -async function settle(): Promise { - await Promise.resolve(); - await Promise.resolve(); +function fact( + kind: GoogletagDiagnosticsFact['kind'], + observedSlot: GoogletagDiagnosticsFact['slot'], + fields: Partial = {} +): Readonly { + return Object.freeze({ kind, observedAtMs: 1, slot: observedSlot, ...fields }); } beforeEach(() => { @@ -77,181 +32,106 @@ beforeEach(() => { configurable: true, value: { escape: (value: string) => value }, }); - target.tsjs = coreApi(); - delete target.googletag; - delete target.__tsjs_gpt_diagnostics_active; - delete target.__tsjs_gpt_diagnostics_runtime; }); afterEach(() => { - target.__tsjs_gpt_diagnostics_runtime?.destroy(); - delete target.__tsjs_gpt_diagnostics_active; - delete target.__tsjs_gpt_diagnostics_runtime; - delete target.googletag; - delete target.tsjs; vi.unstubAllGlobals(); vi.restoreAllMocks(); document.body.replaceChildren(); }); -describe('GPT diagnostics integration composition', () => { - it('has no inactive side effects', () => { - const originalMutationObserver = window.MutationObserver; +describe('GPT diagnostics runtime', () => { + it('is inert until activation and publishes no legacy global or mutable authority', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + const legacyTarget = window as unknown as Record; - expect(isGptDiagnosticsActive(target)).toBe(false); - expect(installGptDiagnosticsRuntime(target)).toBeUndefined(); - - expect(target.tsjs?.gptDiagnostics).toBeUndefined(); - expect(target.tsjs?.gptDiagnosticsRecorder).toBeUndefined(); - expect(target.googletag).toBeUndefined(); - expect(target.__tsjs_gpt_diagnostics_runtime).toBeUndefined(); + expect(runtime.currentApi()).toBeUndefined(); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); - expect(window.MutationObserver).toBe(originalMutationObserver); - }); - it('installs one idempotent active runtime and six listeners', () => { - target.__tsjs_gpt_diagnostics_active = true; - const gpt = installGptStub(); - const previousApi = target.tsjs; + const release = runtime.activate(); + const api = runtime.currentApi(); - const first = installGptDiagnosticsRuntime(target); - const second = installGptDiagnosticsRuntime(target); - - expect(first).toBeDefined(); - expect(second).toBe(first); - expect(target.tsjs).toBe(previousApi); - expect(target.tsjs?.gptDiagnostics).toBe(first); - // Evidence writers live on their own channel; the operator API stays read-only. - expect(Object.keys(first!).sort()).toEqual(['export', 'hide', 'show', 'snapshot', 'subscribe']); - expect(Object.keys(target.tsjs!.gptDiagnosticsRecorder!).sort()).toEqual([ - 'recordPrebidRefresh', - 'recordTrustedServerCreativeFailure', - 'recordTrustedServerCreativeRequest', - 'recordTrustedServerCreativeResponse', - 'recordTrustedServerOpportunity', - ]); - expect(gpt.queue.push).toHaveBeenCalledTimes(1); - expect(gpt.addEventListener).toHaveBeenCalledTimes(6); - expect(gpt.addEventListener.mock.calls.map(([name]) => name).sort()).toEqual( - [ - 'impressionViewable', - 'slotOnload', - 'slotRenderEnded', - 'slotRequested', - 'slotResponseReceived', - 'slotVisibilityChanged', - ].sort() + expect(api).toBeDefined(); + expect(Object.isFrozen(api)).toBe(true); + expect(Reflect.ownKeys(api ?? {}).sort()).toEqual( + ['export', 'hide', 'show', 'snapshot', 'subscribe'].sort() + ); + expect(legacyTarget['__tsjs_gpt_diagnostics_active']).toBeUndefined(); + expect(legacyTarget['__tsjs_gpt_diagnostics_runtime']).toBeUndefined(); + expect((legacyTarget['tsjs'] as Record | undefined)?.['gptDiagnostics']).toBe( + undefined ); expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); + + expect(() => runtime.activate()).toThrow(/already active/i); + release(); + release(); + expect(runtime.currentApi()).toBeUndefined(); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); }); - it('keeps capture active while presentation is hidden', async () => { - target.__tsjs_gpt_diagnostics_active = true; - const gpt = installGptStub(); - const api = installGptDiagnosticsRuntime(target)!; + it('replays buffered facts and keeps capture active while presentation is hidden', () => { + const buffer = createGptDiagnosticsFactBuffer(); const observedSlot = slot('hidden-slot'); + buffer.publish(fact('slotRequested', observedSlot)); + buffer.publish(fact('slotResponseReceived', observedSlot)); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + const release = runtime.activate(); + const api = runtime.currentApi(); + if (!api) throw new Error('Expected active diagnostics API'); api.hide(); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: false, size: [300, 250] }); - await settle(); + buffer.publish( + fact('slotRenderEnded', observedSlot, { + isEmpty: false, + size: Object.freeze([300, 250]), + }) + ); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); - expect(api.snapshot().slots[0].requests).toHaveLength(1); - expect(api.snapshot().slots[0].requests[0].isEmpty).toBe(false); + expect(api.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestNumber: 1, + isEmpty: false, + size: [300, 250], + }); api.show(); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).not.toBeNull(); + release(); }); - it('keeps lifecycle, overlap issues, bindings, panel, and export snapshot consistent', async () => { - target.__tsjs_gpt_diagnostics_active = true; - const gpt = installGptStub(); - const element = document.createElement('div'); - element.id = 'lifecycle-slot'; - vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ - left: 20, - top: 100, - right: 320, - bottom: 350, - width: 300, - height: 250, - x: 20, - y: 100, - toJSON: () => ({}), - } as DOMRect); - document.body.append(element); - const api = installGptDiagnosticsRuntime(target)!; - const observedSlot = slot('lifecycle-slot'); - - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - gpt.emit('slotRenderEnded', { - slot: observedSlot, - isEmpty: false, - size: [300, 250], - isBackfill: true, - }); - gpt.emit('slotOnload', { slot: observedSlot }); - gpt.emit('impressionViewable', { slot: observedSlot }); - gpt.emit('slotVisibilityChanged', { slot: observedSlot, inViewPercentage: 75 }); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: true }); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - await settle(); - - const snapshot = api.snapshot(); - expect(snapshot.slots).toHaveLength(1); - expect(snapshot.slots[0]).toMatchObject({ - slotElementId: 'lifecycle-slot', - adUnitPath: '/example/site/lifecycle-slot', - binding: { status: 'bound' }, - currentVisibilityPercentage: 75, + it('retains adapter callback timing across delayed fact-buffer replay', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const observedSlot = slot('timed-slot'); + buffer.publish(fact('slotRequested', observedSlot, { observedAtMs: 10 })); + buffer.publish(fact('slotResponseReceived', observedSlot, { observedAtMs: 25 })); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + + const release = runtime.activate(); + buffer.publish(fact('slotRenderEnded', observedSlot, { observedAtMs: 30, isEmpty: false })); + + expect(runtime.currentApi()?.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestedAtMs: 10, + responseAtMs: 25, + renderAtMs: 30, + durations: { requestToResponseMs: 15, responseToRenderMs: 5, requestToRenderMs: 20 }, }); - expect(snapshot.slots[0].requests.map((cycle) => cycle.requestNumber)).toEqual([1, 2, 3, 4]); - expect(snapshot.callbackIssues).toContainEqual( - expect.objectContaining({ - kind: 'slotResponseReceived', - disposition: 'ambiguous', - reason: 'overlapping_request_cycles', - }) - ); - expect(snapshot.coverage.slotResponseReceived.observed).toBe( - snapshot.coverage.slotResponseReceived.matched + - snapshot.coverage.slotResponseReceived.unmatched + - snapshot.coverage.slotResponseReceived.ambiguous - ); - expect(document.querySelector(`#${GPT_DIAGNOSTICS_HOST_ID}`)).not.toBeNull(); - expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); - expect(element.getAttributeNames()).toEqual(['id']); + release(); }); - it('removes both diagnostics channels on teardown', () => { - target.__tsjs_gpt_diagnostics_active = true; - installGptStub(); - installGptDiagnosticsRuntime(target); - - expect(target.tsjs?.gptDiagnostics).toBeDefined(); - expect(target.tsjs?.gptDiagnosticsRecorder).toBeDefined(); + it('releases its consumer so replacement activation receives intervening buffered facts', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + const firstRelease = runtime.activate(); + firstRelease(); + const observedSlot = slot('replacement-slot'); + buffer.publish(fact('slotRequested', observedSlot)); - target.__tsjs_gpt_diagnostics_runtime!.destroy(); + const secondRelease = runtime.activate(); - expect(target.tsjs).toBeDefined(); - expect(target.tsjs?.gptDiagnostics).toBeUndefined(); - expect(target.tsjs?.gptDiagnosticsRecorder).toBeUndefined(); - expect(target.__tsjs_gpt_diagnostics_runtime).toBeUndefined(); - }); - - it('leaves no half-initialized API when the core API is unavailable', () => { - target.__tsjs_gpt_diagnostics_active = true; - delete target.tsjs; - - expect(installGptDiagnosticsRuntime(target)).toBeUndefined(); - expect(target.__tsjs_gpt_diagnostics_runtime).toBeUndefined(); - expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + expect(runtime.currentApi()?.snapshot().slots[0]?.slotElementId).toBe('replacement-slot'); + secondRelease(); + buffer.dispose(); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts new file mode 100644 index 000000000..ac913e4ea --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createGptDiagnosticsIntegrationRegistration } from '../../../src/integrations/gpt_diagnostics/module'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +function capabilities( + subscribe: (listener: (fact: Readonly>) => void) => () => void = vi.fn( + () => vi.fn() + ), + runtimeDocument: unknown = document +) { + return Object.freeze({ + 'runtime.v1': Object.freeze({ document: runtimeDocument }), + 'gpt.events.v1': Object.freeze({ subscribe }), + }); +} + +function prepare( + interfaces: Readonly>, + preparationRelease: Array<() => void> = [] +): PreparedIntegration { + return createGptDiagnosticsIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: Object.freeze({ active: true }), + interfaces, + signal: new AbortController().signal, + onDispose: (callback: () => void) => preparationRelease.push(callback), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; +} + +describe('critical GPT diagnostics data provider', () => { + it('accepts a valid foreign-realm Document at the registration boundary', () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const foreignDocument = frame.contentDocument; + const foreignWindow = frame.contentWindow; + if (!foreignDocument || !foreignWindow) throw new Error('Expected an iframe document realm'); + const foreignRealm = foreignWindow as Window & typeof globalThis; + expect(foreignDocument).not.toBeInstanceOf(window.Document); + expect(foreignDocument).toBeInstanceOf(foreignRealm.Document); + const releases: Array<() => void> = []; + + expect(() => prepare(capabilities(undefined, foreignDocument), releases)).not.toThrow(); + + releases.reverse().forEach((release) => release()); + frame.remove(); + }); + + it.each([ + ['plain record', Object.freeze({})], + [ + 'counterfeit realm', + Object.freeze({ defaultView: Object.freeze({ Document: class CounterfeitDocument {} }) }), + ], + [ + 'hostile defaultView', + Object.freeze( + Object.defineProperty({}, 'defaultView', { + get: () => { + throw new Error('hostile defaultView'); + }, + }) + ), + ], + ])('rejects a %s runtime Document candidate at the registration boundary', (_name, candidate) => { + expect(() => prepare(capabilities(undefined, candidate))).toThrow( + 'GPT diagnostics requires runtime.v1' + ); + }); + + it('prepares inertly, captures the GPT stream only while active, and exposes no presentation', () => { + const preparationRelease: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + let publish: ((fact: Readonly>) => void) | undefined; + const releaseEvents = vi.fn(); + const subscribe = vi.fn((listener: (fact: Readonly>) => void) => { + publish = listener; + return releaseEvents; + }); + const prepared = prepare(capabilities(subscribe), preparationRelease); + const data = prepared.interfaces?.['gpt_diag.v1'] as { + api: { + snapshot: () => { slots: readonly Readonly>[] }; + }; + attachPresentation: (controls: Readonly>) => () => void; + }; + + expect(Reflect.ownKeys(prepared.interfaces ?? {})).toEqual(['gpt_diag.v1']); + expect(Reflect.ownKeys(data)).toEqual(['api', 'attachPresentation']); + expect(Object.isFrozen(data)).toBe(true); + expect(subscribe).not.toHaveBeenCalled(); + expect(data.api.snapshot().slots).toEqual([]); + expect(document.querySelector('[id^="trusted-server-gpt-diagnostics"]')).toBeNull(); + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + expect(subscribe).toHaveBeenCalledOnce(); + const fact = Object.freeze({ + kind: 'slotRequested', + observedAtMs: 1, + slot: Object.freeze({ token: Object.freeze({}) }), + }); + publish?.(fact); + expect(data.api.snapshot().slots[0]).toMatchObject({ + runtimeSlotNumber: 1, + binding: { status: 'unbound', reason: 'missing_element' }, + }); + expect(document.querySelector('[id^="trusted-server-gpt-diagnostics"]')).toBeNull(); + + activationRelease.reverse().forEach((callback) => callback()); + expect(releaseEvents).toHaveBeenCalledOnce(); + preparationRelease.reverse().forEach((callback) => callback()); + }); + + it('consumes only runtime.v1 and gpt.events.v1 without inspecting trace capabilities', () => { + const traceRead = vi.fn(() => { + throw new Error('trace capability must remain unobserved'); + }); + const interfaces = Object.freeze( + Object.defineProperty( + { + 'runtime.v1': Object.freeze({ document }), + 'gpt.events.v1': Object.freeze({ subscribe: vi.fn(() => vi.fn()) }), + }, + 'trace.v1', + { enumerable: true, get: traceRead } + ) + ); + + expect(() => prepare(interfaces)).not.toThrow(); + expect(traceRead).not.toHaveBeenCalled(); + }); + + it('pre-registers rollback before the GPT subscription can throw', () => { + const activationRelease: Array<() => void> = []; + const prepared = prepare( + capabilities( + vi.fn(() => { + throw new Error('listener collision'); + }) + ) + ); + + expect(() => + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ) + ).toThrow('listener collision'); + activationRelease.reverse().forEach((callback) => callback()); + const data = prepared.interfaces?.['gpt_diag.v1'] as { + api: { snapshot: () => { slots: readonly unknown[] } }; + }; + expect(data.api.snapshot().slots).toEqual([]); + }); + + it.each([ + ['inactive config', Object.freeze({ active: false }), capabilities()], + ['mutable config', { active: true }, capabilities()], + [ + 'missing GPT event stream', + Object.freeze({ active: true }), + Object.freeze({ + 'runtime.v1': Object.freeze({ document }), + }), + ], + ])('rejects %s during inert preparation', (_name, config, interfaces) => { + const registration = createGptDiagnosticsIntegrationRegistration(RELEASE_ID); + expect(() => + registration.prepare( + Object.freeze({ + config, + interfaces, + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) + ).toThrow(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts index 47d82697b..b7ce96bc5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts @@ -1,23 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; +import type { + GoogletagDiagnosticsFact, + GoogletagDiagnosticsSlotSnapshot, +} from '../../../src/adapters/googletag'; import { GptDiagnosticsObserver, type GptDiagnosticsObserverStore, - type GptObserverWindow, } from '../../../src/integrations/gpt_diagnostics/observer'; -import type { GptDiagnosticsSlotLike } from '../../../src/integrations/gpt_diagnostics/store'; - -const EVENT_NAMES = [ - 'slotRequested', - 'slotResponseReceived', - 'slotRenderEnded', - 'slotOnload', - 'impressionViewable', - 'slotVisibilityChanged', -] as const; - -type EventName = (typeof EVENT_NAMES)[number]; -type EventListener = (event: { slot: GptDiagnosticsSlotLike; [key: string]: unknown }) => void; function fakeStore(): GptDiagnosticsObserverStore { return { @@ -28,411 +18,114 @@ function fakeStore(): GptDiagnosticsObserverStore { recordSlotOnload: vi.fn(), recordImpressionViewable: vi.fn(), recordSlotVisibilityChanged: vi.fn(), - recordPublisherRefresh: vi.fn(), }; } -function fakeSlot(): GptDiagnosticsSlotLike { - return { - getSlotElementId: () => 'ad-slot-example', - getAdUnitPath: () => '/example/site/banner', - }; -} - -function controlledGpt() { - const listeners = new Map(); - const addEventListener = vi.fn((name: EventName, listener: EventListener) => { - const current = listeners.get(name) ?? []; - current.push(listener); - listeners.set(name, current); +function fakeSlot(): GoogletagDiagnosticsSlotSnapshot { + return Object.freeze({ + token: Object.freeze(Object.create(null) as object), + elementId: 'ad-slot-example', + adUnitPath: '/example/site/banner', }); - const pubads = { - addEventListener, - refresh: vi.fn(), - }; - const display = vi.fn(); - const defineSlot = vi.fn(); - const cmd: Array<() => void> = []; - const googletag = { - cmd, - pubads: () => pubads, - display, - defineSlot, - }; +} - return { - window: { googletag }, - googletag, - pubads, - listeners, - emit(name: EventName, event: Parameters[0]) { - for (const listener of listeners.get(name) ?? []) listener(event); - }, - }; +function fact( + kind: GoogletagDiagnosticsFact['kind'], + slot: GoogletagDiagnosticsFact['slot'], + fields: Partial = {} +): Readonly { + return Object.freeze({ kind, observedAtMs: 1, slot, ...fields }); } describe('GptDiagnosticsObserver', () => { - it('installs exactly the six documented listeners through googletag.cmd', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - - observer.install(); - - expect(gpt.googletag.cmd).toHaveLength(1); - expect(gpt.pubads.addEventListener).not.toHaveBeenCalled(); - - gpt.googletag.cmd[0](); - - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - expect(gpt.pubads.addEventListener.mock.calls.map(([name]) => name)).toEqual(EVENT_NAMES); - expect(store.markGptObserved).toHaveBeenCalledTimes(1); - }); - - it('is idempotent before and after command queue execution', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - - observer.install(); - observer.install(); - expect(gpt.googletag.cmd).toHaveLength(1); - - gpt.googletag.cmd[0](); - observer.install(); - gpt.googletag.cmd[0](); - - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - expect(store.markGptObserved).toHaveBeenCalledTimes(1); - }); - - it('observes publisher refresh slots without changing the delegated call', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const slot = fakeSlot(); - const receiver = { refresh: gpt.pubads.refresh }; - const originalRefresh = vi.fn(function (this: unknown, ...args: unknown[]) { - return { receiver: this, args }; - }); - gpt.pubads.refresh = originalRefresh; - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0](); - - const result = Reflect.apply(gpt.pubads.refresh, receiver, [ - [slot], - { changeCorrelator: false }, - ]); - - expect(store.recordPublisherRefresh).toHaveBeenCalledWith([slot]); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(result).toEqual({ receiver, args: [[slot], { changeCorrelator: false }] }); - }); - - it('preserves bare, explicit-undefined, malformed, throwing, and nested refresh behavior', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const slot = fakeSlot(); - const secondSlot = fakeSlot(); - const originalRefresh = vi.fn(function (this: unknown, ...args: unknown[]) { - if (args[0] === 'throw') throw new Error('refresh failure'); - return { receiver: this, args }; - }); - const getSlots = vi.fn(() => [slot, null, secondSlot]); - gpt.pubads.refresh = originalRefresh; - Object.assign(gpt.pubads, { getSlots }); - const runtime = { tsjs: {} }; - const observer = new GptDiagnosticsObserver(store, { window: { ...gpt.window, ...runtime } }); - observer.install(); - gpt.googletag.cmd[0](); - observer.install(); - - expect(gpt.pubads.refresh()).toEqual({ receiver: gpt.pubads, args: [] }); - expect(store.recordPublisherRefresh).toHaveBeenLastCalledWith([slot, secondSlot]); - - // GPT treats an omitted, undefined, or null slot list as "refresh all", and - // `refresh(null, opts)` is the documented way to pass options while doing so. - expect(gpt.pubads.refresh(undefined)).toEqual({ receiver: gpt.pubads, args: [undefined] }); - expect(gpt.pubads.refresh(null, { changeCorrelator: false })).toEqual({ - receiver: gpt.pubads, - args: [null, { changeCorrelator: false }], - }); - expect(store.recordPublisherRefresh).toHaveBeenCalledTimes(3); - expect(store.recordPublisherRefresh).toHaveBeenLastCalledWith([slot, secondSlot]); - - getSlots.mockImplementationOnce(() => { - throw new Error('getSlots failure'); - }); - expect(gpt.pubads.refresh()).toEqual({ receiver: gpt.pubads, args: [] }); - expect(() => gpt.pubads.refresh('throw')).toThrow('refresh failure'); - expect( - store.recordPublisherRefresh, - 'a failed slot lookup records nothing' - ).toHaveBeenCalledTimes(3); - - ( - observer as unknown as { window: { tsjs: { prebidRefreshDispatchInProgress?: boolean } } } - ).window.tsjs.prebidRefreshDispatchInProgress = true; - gpt.pubads.refresh([slot]); - expect( - store.recordPublisherRefresh, - 'a Prebid-delegated refresh is not publisher intent' - ).toHaveBeenCalledTimes(3); - expect(originalRefresh).toHaveBeenCalledTimes(6); - }); - - it('delegates when the shared diagnostics context accessor throws', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const slot = fakeSlot(); - const originalRefresh = vi.fn(() => 'delegated'); - gpt.pubads.refresh = originalRefresh; - Object.assign(gpt.pubads, { getSlots: () => [slot] }); - const target = { googletag: gpt.googletag } as unknown as GptObserverWindow; - Object.defineProperty(target, 'tsjs', { - get: () => { - throw new Error('context unavailable'); - }, - }); - const observer = new GptDiagnosticsObserver(store, { window: target }); - observer.install(); - gpt.googletag.cmd[0](); - - expect(gpt.pubads.refresh()).toBe('delegated'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(store.recordPublisherRefresh).not.toHaveBeenCalled(); - }); - - it('creates a command queue and waits when GPT is absent', () => { - const store = fakeStore(); - const delayedWindow: { - googletag?: { - cmd: Array<() => void>; - pubads?: () => { addEventListener: (name: EventName, listener: EventListener) => void }; - }; - } = {}; - const observer = new GptDiagnosticsObserver(store, { window: delayedWindow }); - - observer.install(); - - expect(delayedWindow.googletag?.cmd).toHaveLength(1); - const gpt = controlledGpt(); - delayedWindow.googletag!.pubads = gpt.googletag.pubads; - delayedWindow.googletag!.cmd[0](); - - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - }); - - it('preserves an already-loaded custom command push contract', () => { + it('does not claim GPT observation merely because the diagnostics module activated', () => { const store = fakeStore(); - const gpt = controlledGpt(); - const callbacks: Array<() => void> = []; - const customPush = vi.fn((...next: Array<() => void>) => { - callbacks.push(...next); - for (const callback of next) callback(); - return callbacks.length; - }); - const observer = new GptDiagnosticsObserver(store, { - window: { - googletag: { - cmd: { push: customPush }, - pubads: gpt.googletag.pubads, - }, - }, - }); + const observer = new GptDiagnosticsObserver(store); - observer.install(); + observer.start(); + observer.start(); - expect(customPush).toHaveBeenCalledTimes(1); - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); + expect(store.markGptObserved).not.toHaveBeenCalled(); }); - it('normalizes allowed callback facts and forwards every event kind', () => { + it('consumes all six normalized adapter facts', () => { const store = fakeStore(); - const gpt = controlledGpt(); const slot = fakeSlot(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0](); - - gpt.emit('slotRequested', { slot }); - gpt.emit('slotResponseReceived', { slot }); - gpt.emit('slotRenderEnded', { - slot, - isEmpty: false, - size: [300, 250], - isBackfill: true, - slotContentChanged: false, - creativeId: 'must-not-pass-through', - }); - gpt.emit('slotOnload', { slot }); - gpt.emit('impressionViewable', { slot }); - gpt.emit('slotVisibilityChanged', { slot, inViewPercentage: 42 }); - - expect(store.recordSlotRequested).toHaveBeenCalledWith(slot); - expect(store.recordSlotResponseReceived).toHaveBeenCalledWith(slot); - expect(store.recordSlotRenderEnded).toHaveBeenCalledWith(slot, { - isEmpty: false, - size: [300, 250], - isBackfill: true, - slotContentChanged: false, - }); - expect(store.recordSlotOnload).toHaveBeenCalledWith(slot); - expect(store.recordImpressionViewable).toHaveBeenCalledWith(slot); - expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(slot, 42); - }); - - it('forwards the Ad Manager identifiers GPT reports for the delivered ad', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const slot = fakeSlot(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0](); - - gpt.emit('slotRenderEnded', { - slot, - isEmpty: false, - lineItemId: 6543210987, - creativeId: 1234567890, - campaignId: 2345678901, - advertiserId: 3456789012, - sourceAgnosticLineItemId: 6543210987, - yieldGroupIds: [11, 12], - companyIds: [], - }); + const observer = new GptDiagnosticsObserver(store); + + observer.consume(fact('slotRequested', slot)); + observer.consume(fact('slotResponseReceived', slot)); + observer.consume( + fact('slotRenderEnded', slot, { + isEmpty: false, + size: Object.freeze([300, 250]), + isBackfill: true, + slotContentChanged: false, + }) + ); + observer.consume(fact('slotOnload', slot)); + observer.consume(fact('impressionViewable', slot)); + observer.consume(fact('slotVisibilityChanged', slot, { inViewPercentage: 42 })); + expect(store.markGptObserved).toHaveBeenCalledOnce(); + expect(store.recordSlotRequested).toHaveBeenCalledWith(slot, 1); + expect(store.recordSlotResponseReceived).toHaveBeenCalledWith(slot, 1); expect(store.recordSlotRenderEnded).toHaveBeenCalledWith( slot, - expect.objectContaining({ - adManager: { - lineItemId: 6543210987, - creativeId: 1234567890, - campaignId: 2345678901, - advertiserId: 3456789012, - sourceAgnosticLineItemId: 6543210987, - yieldGroupIds: [11, 12], - }, - }) + { + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + }, + 1 ); + expect(store.recordSlotOnload).toHaveBeenCalledWith(slot, 1); + expect(store.recordImpressionViewable).toHaveBeenCalledWith(slot, 1); + expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(slot, 42, 1); }); - it('drops malformed Ad Manager identifiers instead of reporting them', () => { + it('passes the immutable adapter callback timestamp through to every store mutation', () => { const store = fakeStore(); - const gpt = controlledGpt(); + const observer = new GptDiagnosticsObserver(store); const slot = fakeSlot(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0](); - - gpt.emit('slotRenderEnded', { + const timestamped = Object.freeze({ + kind: 'slotRequested' as const, slot, - isEmpty: false, - lineItemId: null, - creativeId: '1234567890', - campaignId: 0, - advertiserId: 1.5, - yieldGroupIds: 'not-a-list', + observedAtMs: 123.5, }); - expect(store.recordSlotRenderEnded).toHaveBeenCalledWith( - slot, - expect.objectContaining({ adManager: undefined }) - ); + observer.consume(timestamped as Readonly); + + expect(store.recordSlotRequested).toHaveBeenCalledWith(slot, 123.5); }); - it('drops unsupported or invalid rendered sizes', () => { + it('records a malformed visibility fact as unmatched instead of dropping its coverage', () => { const store = fakeStore(); - const gpt = controlledGpt(); - const slot = fakeSlot(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0](); + const observer = new GptDiagnosticsObserver(store); - gpt.emit('slotRenderEnded', { slot, isEmpty: false, size: 'fluid' }); + observer.consume(fact('slotVisibilityChanged', fakeSlot())); - expect(store.recordSlotRenderEnded).toHaveBeenCalledWith( - slot, - expect.objectContaining({ size: undefined }) - ); + expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(expect.any(Object), NaN, 1); }); - it('contains callback and Slot accessor failures and warns', () => { + it('contains store and logger failures without interrupting later facts', () => { const store = fakeStore(); vi.mocked(store.recordSlotRequested).mockImplementation(() => { throw new Error('store failed'); }); - const logger = { warn: vi.fn() }; - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window, logger }); - observer.install(); - gpt.googletag.cmd[0](); - const event = { - get slot(): GptDiagnosticsSlotLike { - throw new Error('slot accessor failed'); - }, - }; - - expect(() => gpt.emit('slotRequested', { slot: fakeSlot() })).not.toThrow(); - expect(() => gpt.emit('slotOnload', event)).not.toThrow(); - expect(logger.warn).toHaveBeenCalledTimes(2); - }); - - it('contains command queue and listener installation failures', () => { - const store = fakeStore(); - const logger = { warn: vi.fn() }; - const queueObserver = new GptDiagnosticsObserver(store, { - window: { - googletag: { - cmd: { - push: () => { - throw new Error('queue failed'); - }, - }, - }, - }, - logger, - }); - - expect(() => queueObserver.install()).not.toThrow(); - - const gpt = controlledGpt(); - gpt.pubads.addEventListener.mockImplementation(() => { - throw new Error('listener failed'); - }); - const listenerObserver = new GptDiagnosticsObserver(store, { - window: gpt.window, - logger, - }); - listenerObserver.install(); - - expect(() => gpt.googletag.cmd[0]()).not.toThrow(); - expect(logger.warn).toHaveBeenCalledTimes(2); - }); - - it('wraps only PubAds refresh and leaves unrelated GPT and browser methods intact', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - const references = { - display: gpt.googletag.display, - defineSlot: gpt.googletag.defineSlot, - refresh: gpt.pubads.refresh, - fetch: window.fetch, - XMLHttpRequest: window.XMLHttpRequest, - pushState: window.history.pushState, - replaceState: window.history.replaceState, + const logger = { + warn: vi.fn(() => { + throw new Error('logger failed'); + }), }; + const observer = new GptDiagnosticsObserver(store, { logger }); + const slot = fakeSlot(); - observer.install(); - gpt.googletag.cmd[0](); + expect(() => observer.consume(fact('slotRequested', slot))).not.toThrow(); + expect(() => observer.consume(fact('slotOnload', slot))).not.toThrow(); - expect(gpt.googletag.display).toBe(references.display); - expect(gpt.googletag.defineSlot).toBe(references.defineSlot); - expect(gpt.pubads.refresh).not.toBe(references.refresh); - expect(window.fetch).toBe(references.fetch); - expect(window.XMLHttpRequest).toBe(references.XMLHttpRequest); - expect(window.history.pushState).toBe(references.pushState); - expect(window.history.replaceState).toBe(references.replaceState); + expect(logger.warn).toHaveBeenCalledOnce(); + expect(store.recordSlotOnload).toHaveBeenCalledWith(slot, 1); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts index b89a8f507..8646a7cb2 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts @@ -75,6 +75,16 @@ function slotArticle(root: ShadowRoot, slotElementId: string): HTMLElement { return article; } +function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + frames.push(callback); + return () => { + const index = frames.indexOf(callback); + if (index >= 0) frames.splice(index, 1); + }; + }; +} + beforeEach(() => { document.body.replaceChildren(); vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete'); @@ -92,7 +102,7 @@ describe('GptDiagnosticsOverlay', () => { store.recordSlotRequested(slot('early-slot')); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -235,7 +245,7 @@ describe('GptDiagnosticsOverlay', () => { let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -375,7 +385,7 @@ describe('GptDiagnosticsOverlay', () => { let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -439,7 +449,7 @@ describe('GptDiagnosticsOverlay', () => { const exportSnapshot = vi.fn(); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onExport: exportSnapshot, onShadowRoot: (createdRoot) => { root = createdRoot; @@ -515,7 +525,7 @@ describe('GptDiagnosticsOverlay', () => { document.body.append(publisherElement); const warn = vi.spyOn(log, 'warn'); const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); runNextFrame(frames); runNextFrame(frames); @@ -544,7 +554,7 @@ describe('GptDiagnosticsOverlay', () => { store.recordSlotRequested(diagnosticSlot); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -570,7 +580,7 @@ describe('GptDiagnosticsOverlay', () => { const store = new GptDiagnosticsStore(); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -604,4 +614,50 @@ describe('GptDiagnosticsOverlay', () => { expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); overlay.destroy(); }); + + it('cancels a pending mount frame on destroy and suppresses a hostile late callback', () => { + const frames: Array<() => void> = []; + const cancel = vi.fn(); + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return cancel; + }, + }); + + overlay.destroy(); + frames[0]?.(); + + expect(cancel).toHaveBeenCalledOnce(); + expect(frames).toHaveLength(1); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + }); + + it('runs one scheduled mount callback at most once', () => { + const frames: Array<() => void> = []; + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return vi.fn(); + }, + }); + + frames[0]?.(); + frames[0]?.(); + + expect(frames).toHaveLength(2); + overlay.destroy(); + }); + + it('isolates a hostile frame cancellation during destroy', () => { + const cancel = vi.fn(() => { + throw new Error('cancel failed'); + }); + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: () => cancel, + }); + + expect(() => overlay.destroy()).not.toThrow(); + expect(cancel).toHaveBeenCalledOnce(); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts new file mode 100644 index 000000000..74423544c --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts @@ -0,0 +1,468 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + GptDiagnosticsDataApiController, + type GptDiagnosticsPresentationFactory, +} from '../../../src/integrations/gpt_diagnostics/data_api'; +import { GPT_DIAGNOSTICS_HOST_ID } from '../../../src/integrations/gpt_diagnostics/overlay'; +import { + createDiagnosticsPresentationIntegrationRegistration, + createRenderTracePresentation, +} from '../../../src/integrations/gpt_diagnostics/presentation'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; +import { createRenderTraceStore } from '../../../src/core/trace'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); +let frames: Array<() => void> = []; + +beforeEach(() => { + document.body.replaceChildren(); + frames = []; + vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete'); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const frame = () => callback(0); + frames.push(frame); + return frames.length; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.replaceChildren(); +}); + +function drainFrames(): void { + let count = 0; + while (frames.length > 0 && count < 16) { + frames.shift()?.(); + count += 1; + } + if (frames.length > 0) throw new Error('Diagnostics presentation did not quiesce'); +} + +function presentationInterfaces(runtimeDocument: unknown) { + return Object.freeze({ + 'runtime.v1': Object.freeze({ + boot: () => + Object.freeze({ + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: true, + gpt: Object.freeze({ active: false }), + }), + }), + document: runtimeDocument, + }), + 'trace.presentation.v1': Object.freeze({ + attachPresentation: vi.fn(() => vi.fn()), + }), + }); +} + +function preparePresentation(runtimeDocument: unknown): PreparedIntegration { + return createDiagnosticsPresentationIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: Object.freeze({}), + interfaces: presentationInterfaces(runtimeDocument), + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; +} + +describe('deferred GPT diagnostics presentation integration', () => { + it('binds a foreign-realm slot mutation and renders its badge through the registration', async () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const foreignDocument = frame.contentDocument; + const foreignWindow = frame.contentWindow; + if (!foreignDocument || !foreignWindow) throw new Error('Expected an iframe document realm'); + const foreignRealm = foreignWindow as Window & typeof globalThis; + vi.spyOn(foreignDocument, 'readyState', 'get').mockReturnValue('complete'); + Object.defineProperty(foreignWindow, 'CSS', { + configurable: true, + value: Object.freeze({ + escape: (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '\\$&'), + }), + }); + const replaceChildren = vi.spyOn(foreignRealm.Element.prototype, 'replaceChildren'); + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const slotToken = Object.freeze({ + getAdUnitPath: () => '/foreign/slot', + getSlotElementId: () => 'foreign-mutation-slot', + }); + store.recordSlotRequested(slotToken, 1); + const controller = new GptDiagnosticsDataApiController(store, { + location: foreignWindow.location, + schedule: (callback) => { + callback(); + return () => undefined; + }, + }); + const releases: Array<() => void> = []; + const prepared = createDiagnosticsPresentationIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: Object.freeze({}), + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({ + boot: () => + Object.freeze({ + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: true }), + }), + }), + document: foreignDocument, + }), + 'trace.presentation.v1': Object.freeze({ + attachPresentation: vi.fn(() => vi.fn()), + }), + 'gpt_diag.v1': Object.freeze({ + api: controller.api, + attachPresentation: (factory: GptDiagnosticsPresentationFactory) => + controller.attachPresentation(factory), + }), + }), + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (release: () => void) => releases.push(release), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + drainFrames(); + expect(controller.api.snapshot().slots[0]?.binding).toEqual({ + status: 'unbound', + reason: 'missing_element', + }); + + const foreignSlot = foreignDocument.createElement('div'); + foreignSlot.id = 'foreign-mutation-slot'; + vi.spyOn(foreignSlot, 'getBoundingClientRect').mockReturnValue({ + bottom: 260, + height: 250, + left: 10, + right: 310, + top: 10, + width: 300, + x: 10, + y: 10, + toJSON: () => ({}), + } as DOMRect); + expect(foreignSlot).toBeInstanceOf(foreignRealm.Element); + expect(foreignSlot).not.toBeInstanceOf(window.Element); + foreignDocument.body.append(foreignSlot); + + await vi.waitFor(() => { + drainFrames(); + expect(controller.api.snapshot().slots[0]?.binding).toEqual({ status: 'bound' }); + }); + const badgeRenderCount = (): number => + replaceChildren.mock.calls.filter((nodes) => + nodes.some( + (node) => node instanceof foreignRealm.Element && node.classList.contains('tsgd-badge') + ) + ).length; + expect(badgeRenderCount()).toBeGreaterThan(0); + + const renderedBeforeDispose = badgeRenderCount(); + releases.reverse().forEach((release) => release()); + expect(replaceChildren.mock.calls[replaceChildren.mock.calls.length - 1]).toEqual([]); + foreignSlot.remove(); + await Promise.resolve(); + drainFrames(); + expect(badgeRenderCount()).toBe(renderedBeforeDispose); + controller.destroy(); + frame.remove(); + }); + + it('accepts a valid foreign-realm Document at the registration boundary', () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const foreignDocument = frame.contentDocument; + const foreignWindow = frame.contentWindow; + if (!foreignDocument || !foreignWindow) throw new Error('Expected an iframe document realm'); + const foreignRealm = foreignWindow as Window & typeof globalThis; + expect(foreignDocument).not.toBeInstanceOf(window.Document); + expect(foreignDocument).toBeInstanceOf(foreignRealm.Document); + + expect(() => preparePresentation(foreignDocument)).not.toThrow(); + + frame.remove(); + }); + + it.each([ + ['plain record', Object.freeze({})], + [ + 'counterfeit realm', + Object.freeze({ defaultView: Object.freeze({ Document: class CounterfeitDocument {} }) }), + ], + [ + 'hostile defaultView', + Object.freeze( + Object.defineProperty({}, 'defaultView', { + get: () => { + throw new Error('hostile defaultView'); + }, + }) + ), + ], + ])('rejects a %s runtime Document candidate at the registration boundary', (_name, candidate) => { + expect(() => preparePresentation(candidate)).toThrow( + 'diagnostics presentation capability graph is malformed' + ); + }); + + it.each(['render trace', 'GPT'] as const)( + 'throws for an invalid %s presentation disposer so the deferred transaction rolls back', + (failedSurface) => { + const traceRelease = vi.fn(); + const gptRelease = vi.fn(); + const attachTrace = vi.fn(() => + failedSurface === 'render trace' ? (undefined as never) : traceRelease + ); + const attachGpt = vi.fn(() => (failedSurface === 'GPT' ? (undefined as never) : gptRelease)); + const releases: Array<() => void> = []; + const runtime = Object.freeze({ + boot: () => + Object.freeze({ + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: true, + gpt: Object.freeze({ active: true }), + }), + }), + document, + }); + const prepared = createDiagnosticsPresentationIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: Object.freeze({}), + interfaces: Object.freeze({ + 'runtime.v1': runtime, + 'trace.presentation.v1': Object.freeze({ + attachPresentation: attachTrace, + }), + 'gpt_diag.v1': Object.freeze({ + api: Object.freeze({}), + attachPresentation: attachGpt, + }), + }), + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + + expect(() => + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => releases.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ) + ).toThrow( + failedSurface === 'render trace' + ? 'render trace presentation disposer is unavailable' + : 'GPT diagnostics presentation disposer is unavailable' + ); + expect(attachTrace).toHaveBeenCalledOnce(); + expect(attachGpt).toHaveBeenCalledTimes(failedSurface === 'render trace' ? 0 : 1); + releases.reverse().forEach((release) => release()); + expect(traceRelease).toHaveBeenCalledTimes(failedSurface === 'GPT' ? 1 : 0); + expect(gptRelease).not.toHaveBeenCalled(); + } + ); + + it('uses the target document realm when stamping a render slot', () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const targetDocument = frame.contentDocument; + const targetWindow = frame.contentWindow; + if (!targetDocument || !targetWindow) throw new Error('Expected an iframe document realm'); + const targetRealm = targetWindow as Window & typeof globalThis; + const slot = targetDocument.createElement('div'); + slot.id = 'foreign-realm-slot'; + targetDocument.body.append(slot); + expect(slot).toBeInstanceOf(targetRealm.HTMLElement); + expect(slot).not.toBeInstanceOf(window.HTMLElement); + const renderTrace = createRenderTraceStore(); + renderTrace.record({ + slotId: slot.id, + elementId: slot.id, + path: 'auction', + rendered: true, + injected: true, + visible: true, + }); + + const detach = renderTrace.attachPresentation((source) => + createRenderTracePresentation(source, { document: targetDocument }) + ); + + expect(slot.getAttribute('data-ts-rendered')).toBe('true'); + expect(slot.querySelector('.ts-render-badge')).not.toBeNull(); + detach(); + expect(slot.getAttribute('data-ts-rendered')).toBeNull(); + renderTrace.dispose(); + frame.remove(); + }); + + it('replays and owns render-trace presentation without GPT diagnostics', () => { + const traceTasks: Array<() => void> = []; + const renderTrace = createRenderTraceStore({ + schedule: (callback) => { + traceTasks.push(callback); + return () => { + const index = traceTasks.indexOf(callback); + if (index >= 0) traceTasks.splice(index, 1); + }; + }, + }); + const diagnostics = renderTrace.diagnostics; + const slot = document.createElement('div'); + slot.id = 'render-overlay-only-slot'; + document.body.append(slot); + renderTrace.record({ + slotId: slot.id, + elementId: slot.id, + path: 'ssat', + rendered: true, + injected: true, + visible: true, + }); + const activationRelease: Array<() => void> = []; + const runtime = Object.freeze({ + boot: () => + Object.freeze({ + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: true, + gpt: Object.freeze({ active: false }), + }), + }), + document, + }); + const trace = Object.freeze({ + attachPresentation: renderTrace.attachPresentation, + }); + const prepared = createDiagnosticsPresentationIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: Object.freeze({}), + interfaces: Object.freeze({ + 'runtime.v1': runtime, + 'trace.presentation.v1': trace, + }), + signal: new AbortController().signal, + onDispose: vi.fn(), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + + expect(traceTasks).toEqual([]); + expect(slot.getAttributeNames().filter((name) => name.startsWith('data-ts-'))).toEqual([]); + expect(document.getElementById('ts-render-trace-panel')).toBeNull(); + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + + expect(traceTasks).toEqual([]); + expect(slot.getAttribute('data-ts-rendered')).toBe('true'); + expect(slot.querySelector('.ts-render-badge')).not.toBeNull(); + expect(document.getElementById('ts-render-trace-panel')?.textContent).toContain(slot.id); + expect(renderTrace.diagnostics).toBe(diagnostics); + + renderTrace.enrich(1, { bidder: 'later-bidder' }); + expect(traceTasks).toHaveLength(1); + activationRelease.reverse().forEach((release) => release()); + expect(traceTasks).toEqual([]); + expect(slot.getAttributeNames().filter((name) => name.startsWith('data-ts-'))).toEqual([]); + expect(slot.querySelector('.ts-render-badge')).toBeNull(); + expect(document.getElementById('ts-render-trace-panel')).toBeNull(); + expect(renderTrace.diagnostics).toBe(diagnostics); + renderTrace.dispose(); + }); + + it('owns all DOM presentation after activation and releases it without replacing the API', () => { + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const renderTrace = createRenderTraceStore(); + const controller = new GptDiagnosticsDataApiController(store, { + location: window.location, + schedule: (callback) => { + callback(); + return () => undefined; + }, + }); + const api = controller.api; + const data = Object.freeze({ + api, + attachPresentation: (factory: GptDiagnosticsPresentationFactory) => + controller.attachPresentation(factory), + }); + const preparationRelease: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + const prepared = createDiagnosticsPresentationIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: Object.freeze({}), + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({ + boot: () => + Object.freeze({ + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: true }), + }), + }), + document, + }), + 'trace.presentation.v1': Object.freeze({ + attachPresentation: renderTrace.attachPresentation, + }), + 'gpt_diag.v1': data, + }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => preparationRelease.push(callback), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + + expect(controller.api).toBe(api); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + expect(frames).toEqual([]); + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + drainFrames(); + + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).not.toBeNull(); + expect(controller.api).toBe(api); + activationRelease.reverse().forEach((release) => release()); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + expect(controller.api).toBe(api); + + preparationRelease.reverse().forEach((release) => release()); + controller.destroy(); + renderTrace.dispose(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts index 4c6721f3a..965c095a2 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts @@ -9,7 +9,6 @@ import { MAX_DIAGNOSTIC_SLOTS, MAX_REQUEST_CYCLES_PER_SLOT, MAX_TRUSTED_SERVER_ASSOCIATIONS, - REQUEST_PATH_ATTRIBUTION_WINDOW_MS, TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS, type GptDiagnosticsSlotLike, } from '../../../src/integrations/gpt_diagnostics/store'; @@ -29,32 +28,6 @@ function associateSlot( store.recordTrustedServerOpportunity(slot, auctionSlotId, 'renderable_candidate'); } -function recordCompletedAttempts( - store: GptDiagnosticsStore, - count: number, - prefix: string -): number[] { - const attemptIds: number[] = []; - let remaining = count; - - for (let slotIndex = 0; remaining > 0; slotIndex += 1) { - const slot = fakeSlot(`${prefix}-slot-${slotIndex}`); - const auctionSlotId = `${prefix}-auction-${slotIndex}`; - associateSlot(store, slot, auctionSlotId); - const cycles = Math.min(MAX_REQUEST_CYCLES_PER_SLOT, remaining); - for (let cycleIndex = 0; cycleIndex < cycles; cycleIndex += 1) { - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest(auctionSlotId); - expect(attemptId).toEqual(expect.any(Number)); - attemptIds.push(attemptId!); - store.recordTrustedServerCreativeResponse(attemptId!); - remaining -= 1; - } - } - - return attemptIds; -} - function assertCoverageEquation(store: GptDiagnosticsStore): void { for (const counters of Object.values(store.snapshot().coverage)) { expect(counters.observed).toBe(counters.matched + counters.unmatched + counters.ambiguous); @@ -97,8 +70,8 @@ describe('GptDiagnosticsStore', () => { store.recordSlotVisibilityChanged(slot, 20); const snapshot = store.snapshot(); - const recordedSlot = snapshot.slots[0]; - const cycle = recordedSlot.requests[0]; + const recordedSlot = snapshot.slots[0]!; + const cycle = recordedSlot.requests[0]!; expect(snapshot.gptObserved).toBe(true); expect(recordedSlot).toMatchObject({ @@ -132,51 +105,20 @@ describe('GptDiagnosticsStore', () => { assertCoverageEquation(store); }); - it('matches the unique response-bearing load that arrives before render', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('early-load'); + it('uses adapter callback times even when buffered delivery occurs much later', () => { + const store = new GptDiagnosticsStore({ now: () => 9_999 }); + const slot = fakeSlot('buffered-slot'); - store.recordSlotRequested(slot); - now = 2; - store.recordSlotResponseReceived(slot); - now = 3; - store.recordSlotOnload(slot); - now = 4; - store.recordSlotRenderEnded(slot, { isEmpty: false }); + store.recordSlotRequested(slot, 10); + store.recordSlotResponseReceived(slot, 25); + store.recordSlotRenderEnded(slot, { isEmpty: false }, 30); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ - loadAtMs: 3, - loadObservedBeforeRender: true, - incompleteSequence: false, + expect(store.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestedAtMs: 10, + responseAtMs: 25, + renderAtMs: 30, + durations: { requestToResponseMs: 15, responseToRenderMs: 5, requestToRenderMs: 20 }, }); - expect(cycle.durations.renderToLoadMs).toBeUndefined(); - expect(store.snapshot().callbackIssues).not.toContainEqual( - expect.objectContaining({ kind: 'slotOnload', reason: 'invalid_event_order' }) - ); - }); - - it('keeps no-response loads unmatched and overlapping response-bearing loads ambiguous', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const missingResponse = fakeSlot('missing-load-response'); - store.recordSlotRequested(missingResponse); - store.recordSlotOnload(missingResponse); - const overlapping = fakeSlot('overlapping-load-response'); - now = 2; - store.recordSlotRequested(overlapping); - now = 3; - store.recordSlotResponseReceived(overlapping); - now = 4; - store.recordSlotRequested(overlapping); - now = 5; - store.recordSlotResponseReceived(overlapping); - now = 6; - store.recordSlotOnload(overlapping); - - expect(store.snapshot().coverage.slotOnload).toMatchObject({ unmatched: 1, ambiguous: 1 }); - assertCoverageEquation(store); }); it('matches load and viewability after a render with unknown fill state', () => { @@ -208,9 +150,9 @@ describe('GptDiagnosticsStore', () => { viewableAtMs: 8, durations: { renderToLoadMs: 2, renderToViewableMs: 5 }, }); - expect(emptyCycle.loadAtMs).toBe(8); - expect(emptyCycle.viewableAtMs).toBeUndefined(); - expect(store.snapshot().coverage.slotOnload).toMatchObject({ matched: 2, unmatched: 0 }); + expect(emptyCycle!.loadAtMs).toBeUndefined(); + expect(emptyCycle!.viewableAtMs).toBeUndefined(); + expect(store.snapshot().coverage.slotOnload).toMatchObject({ matched: 1, unmatched: 1 }); expect(store.snapshot().coverage.impressionViewable).toMatchObject({ matched: 1, unmatched: 1, @@ -241,10 +183,10 @@ describe('GptDiagnosticsStore', () => { .snapshot() .slots.map((slot) => slot.requests[0]); - expect(requestingCycle.incompleteSequence).toBe(false); - expect(requestingCycle.responseAtMs).toBeUndefined(); - expect(respondedCycle.incompleteSequence).toBe(false); - expect(respondedCycle.renderAtMs).toBeUndefined(); + expect(requestingCycle!.incompleteSequence).toBe(false); + expect(requestingCycle!.responseAtMs).toBeUndefined(); + expect(respondedCycle!.incompleteSequence).toBe(false); + expect(respondedCycle!.renderAtMs).toBeUndefined(); expect(emptyCycle).toMatchObject({ isEmpty: true, incompleteSequence: false }); }); @@ -259,7 +201,7 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRenderEnded(slot, { isEmpty: request === 1 }); } - expect(store.snapshot().slots[0].requests.map((cycle) => cycle.requestNumber)).toEqual([ + expect(store.snapshot().slots[0]!.requests.map((cycle) => cycle.requestNumber)).toEqual([ 1, 2, 3, ]); assertCoverageEquation(store); @@ -290,8 +232,8 @@ describe('GptDiagnosticsStore', () => { expect(() => store.recordSlotRequested(slot)).not.toThrow(); expect(store.snapshot().slots[0]).toMatchObject({ runtimeSlotNumber: 1 }); - expect(store.snapshot().slots[0].slotElementId).toBeUndefined(); - expect(store.snapshot().slots[0].adUnitPath).toBeUndefined(); + expect(store.snapshot().slots[0]!.slotElementId).toBeUndefined(); + expect(store.snapshot().slots[0]!.adUnitPath).toBeUndefined(); }); it('records callbacks without a request as unmatched issues', () => { @@ -304,7 +246,7 @@ describe('GptDiagnosticsStore', () => { store.recordImpressionViewable(slot); const snapshot = store.snapshot(); - expect(snapshot.slots[0].requests).toEqual([]); + expect(snapshot.slots[0]!.requests).toEqual([]); expect(snapshot.callbackIssues).toHaveLength(4); expect(snapshot.callbackIssues.every((issue) => issue.disposition === 'unmatched')).toBe(true); expect( @@ -324,11 +266,11 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRenderEnded(slot, { isEmpty: false }); const snapshot = store.snapshot(); - expect(snapshot.slots[0].requests).toHaveLength(2); - expect(snapshot.slots[0].requests.every((cycle) => cycle.responseAtMs === undefined)).toBe( + expect(snapshot.slots[0]!.requests).toHaveLength(2); + expect(snapshot.slots[0]!.requests.every((cycle) => cycle.responseAtMs === undefined)).toBe( true ); - expect(snapshot.slots[0].requests.every((cycle) => cycle.renderAtMs === undefined)).toBe(true); + expect(snapshot.slots[0]!.requests.every((cycle) => cycle.renderAtMs === undefined)).toBe(true); expect(snapshot.callbackIssues).toMatchObject([ { kind: 'slotResponseReceived', @@ -356,7 +298,7 @@ describe('GptDiagnosticsStore', () => { store.recordSlotResponseReceived(slot); const snapshot = store.snapshot(); - const cycle = snapshot.slots[0].requests[0]; + const cycle = snapshot.slots[0]!.requests[0]!; expect(cycle.incompleteSequence).toBe(true); expect(cycle.durations.requestToResponseMs).toBe(20); expect(cycle.durations.requestToRenderMs).toBe(10); @@ -388,10 +330,10 @@ describe('GptDiagnosticsStore', () => { let snapshot = store.snapshot(); expect(snapshot.slots).toHaveLength(MAX_DIAGNOSTIC_SLOTS); - expect(snapshot.slots[0].runtimeSlotNumber).toBe(2); + expect(snapshot.slots[0]!.runtimeSlotNumber).toBe(2); expect(snapshot.metadata.evictedSlots).toBe(1); - store.recordSlotResponseReceived(slots[0]); + store.recordSlotResponseReceived(slots[0]!); snapshot = store.snapshot(); expect(snapshot.callbackIssues[snapshot.callbackIssues.length - 1]).toMatchObject({ runtimeSlotNumber: 1, @@ -399,14 +341,14 @@ describe('GptDiagnosticsStore', () => { reason: 'evicted_slot', }); - const retainedSlot = slots[slots.length - 1]; + const retainedSlot = slots[slots.length - 1]!; for (let index = 0; index < MAX_REQUEST_CYCLES_PER_SLOT; index += 1) { store.recordSlotRequested(retainedSlot); } snapshot = store.snapshot(); - const retainedRecord = snapshot.slots[snapshot.slots.length - 1]; + const retainedRecord = snapshot.slots[snapshot.slots.length - 1]!; expect(retainedRecord.requests).toHaveLength(MAX_REQUEST_CYCLES_PER_SLOT); - expect(retainedRecord.requests[0].requestNumber).toBe(2); + expect(retainedRecord.requests[0]!.requestNumber).toBe(2); expect(snapshot.metadata.evictedRequestCycles).toBe(1); const issueSlot = fakeSlot('issues'); @@ -429,23 +371,23 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(retained); } - store.recordSlotVisibilityChanged(slots[0], 10); - store.recordSlotRequested(slots[MAX_DIAGNOSTIC_SLOTS]); + store.recordSlotVisibilityChanged(slots[0]!, 10); + store.recordSlotRequested(slots[MAX_DIAGNOSTIC_SLOTS]!); expect(store.snapshot().slots.some((slot) => slot.runtimeSlotNumber === 1)).toBe(true); expect(store.snapshot().slots.some((slot) => slot.runtimeSlotNumber === 2)).toBe(false); - store.recordSlotResponseReceived(slots[1]); - expect(last(store.snapshot().callbackIssues)).toMatchObject({ + store.recordSlotResponseReceived(slots[1]!); + expect(store.snapshot().callbackIssues.slice(-1)[0]).toMatchObject({ runtimeSlotNumber: 2, reason: 'evicted_slot', }); - store.recordSlotRequested(slots[1]); - store.recordSlotResponseReceived(slots[1]); + store.recordSlotRequested(slots[1]!); + store.recordSlotResponseReceived(slots[1]!); const reentered = store.snapshot().slots.find((slot) => slot.slotElementId === 'lru-1'); expect(reentered).toMatchObject({ runtimeSlotNumber: 66 }); expect(reentered?.requests[0]).toMatchObject({ requestNumber: 2 }); - expect(reentered?.requests[0].responseAtMs).toBeDefined(); + expect(reentered?.requests[0]!.responseAtMs).toBeDefined(); expect(store.snapshot().slots).toHaveLength(MAX_DIAGNOSTIC_SLOTS); expect(store.snapshot().metadata.evictedSlots).toBe(2); assertCoverageEquation(store); @@ -464,8 +406,8 @@ describe('GptDiagnosticsStore', () => { { runtimeSlotNumber: 1, slotElementId: 'first' }, { runtimeSlotNumber: 2, slotElementId: 'second' }, ]); - inputs[0].slotElementId = 'changed'; - expect(store.bindingInputs()[0].slotElementId).toBe('first'); + inputs[0]!.slotElementId = 'changed'; + expect(store.bindingInputs()[0]!.slotElementId).toBe('first'); }); it('coalesces notifications and isolates throwing subscribers', () => { @@ -493,1340 +435,25 @@ describe('GptDiagnosticsStore', () => { expect(goodListener).toHaveBeenCalledTimes(1); }); - it('retains the Ad Manager identifiers GPT reported for the delivered ad', () => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('ad-slot-identity'); - - store.recordSlotRequested(slot); - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { - isEmpty: false, - adManager: { - lineItemId: 6543210987, - creativeId: 1234567890, - campaignId: 2345678901, - advertiserId: 3456789012, - }, - }); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.adManager, 'should keep every reported identifier').toEqual({ - lineItemId: 6543210987, - creativeId: 1234567890, - campaignId: 2345678901, - advertiserId: 3456789012, - }); - expect(cycle.responseClass).toBe('reservation'); - }); - - it('separates a fill without Ad Manager identifiers from a reservation', () => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('ad-slot-default'); - - store.recordSlotRequested(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.responseClass).toBe('unclassified_non_empty'); - expect(cycle.adManager).toBeUndefined(); - }); - - it.each([ - { - name: 'a direct renderable candidate', - direct: 'renderable_candidate', - prebid: false, - publisher: false, - expectedPath: 'trusted_server_direct', - expectedOpportunity: 'renderable_candidate', - }, - { - name: 'a direct unrenderable candidate', - direct: 'unrenderable_candidate', - prebid: false, - publisher: false, - expectedPath: 'trusted_server_direct', - expectedOpportunity: 'unrenderable_candidate', - }, - { - name: 'a direct request without a candidate', - direct: 'no_candidate', - prebid: false, - publisher: false, - expectedPath: 'trusted_server_direct', - expectedOpportunity: 'no_candidate', - }, - { - name: 'a Prebid refresh', - direct: undefined, - prebid: true, - publisher: false, - expectedPath: 'prebid_refresh', - expectedOpportunity: undefined, - }, - { - name: 'competing direct and Prebid evidence', - direct: 'renderable_candidate', - prebid: true, - publisher: false, - expectedPath: 'competing', - expectedOpportunity: 'renderable_candidate', - }, - { - name: 'an unattributed request', - direct: undefined, - prebid: false, - publisher: false, - expectedPath: 'unattributed', - expectedOpportunity: undefined, - }, - { - name: 'a publisher refresh', - direct: undefined, - prebid: false, - publisher: true, - expectedPath: 'publisher_refresh', - expectedOpportunity: undefined, - }, - { - name: 'competing Prebid and publisher evidence', - direct: undefined, - prebid: true, - publisher: true, - expectedPath: 'competing', - expectedOpportunity: undefined, - }, - { - name: 'competing all source evidence', - direct: 'renderable_candidate', - prebid: true, - publisher: true, - expectedPath: 'competing', - expectedOpportunity: 'renderable_candidate', - }, - ] as const)( - 'attributes $name without inferring demand ownership', - ({ direct, prebid, publisher, expectedPath, expectedOpportunity }) => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('path-slot'); - - if (direct !== undefined) { - store.recordTrustedServerOpportunity(slot, 'auction-slot', direct); - } - if (prebid) store.recordPrebidRefresh([slot]); - if (publisher) store.recordPublisherRefresh([slot]); - store.recordSlotRequested(slot); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.requestPath).toBe(expectedPath); - expect(cycle.trustedServerOpportunity).toBe(expectedOpportunity); - } - ); - - it('consumes direct and Prebid markers exactly once', () => { - let now = 10; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('one-shot'); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - store.recordPrebidRefresh([slot]); - store.recordSlotRequested(slot); - now = 11; - store.recordSlotRequested(slot); - - const cycles = store.snapshot().slots[0].requests; - expect(cycles).toMatchObject([ - { - requestPath: 'competing', - trustedServerOpportunity: 'renderable_candidate', - }, - { requestPath: 'unattributed' }, - ]); - expect(cycles[1].trustedServerOpportunity).toBeUndefined(); - }); - - it('consumes a combined request intent with independent source facts', () => { - let now = 10; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('intent'); - - store.recordTrustedServerOpportunity( - slot, - 'auction-slot', - 'renderable_candidate', - ' auction-123 ' - ); - now = 20; - store.recordPrebidRefresh([slot]); - now = 30; - store.recordPublisherRefresh([slot]); - now = 34; - store.recordSlotRequested(slot); - now = 35; - store.recordSlotRequested(slot); - - const cycles = store.snapshot().slots[0].requests; - expect(cycles).toMatchObject([ - { - requestPath: 'competing', - requestIntentId: 1, - trustedServerOpportunity: 'renderable_candidate', - trustedServerAuctionId: 'auction-123', - opportunityToRequestMs: 24, - }, - { requestPath: 'unattributed' }, - ]); - expect(cycles[1].requestIntentId).toBeUndefined(); - expect(deferred, 'source evidence must not schedule deferred work').toHaveLength(0); - }); - - it('keeps repeated source evidence single-source and increments consumed intent IDs', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const first = fakeSlot('repeat-intent-first'); - const second = fakeSlot('repeat-intent-second'); - store.recordPublisherRefresh([first]); - now = 2; - store.recordPublisherRefresh([first]); - store.recordSlotRequested(first); - now = 3; - store.recordTrustedServerOpportunity(second, 'second-auction', 'no_candidate'); - store.recordPublisherRefresh([second]); - store.recordSlotRequested(second); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'publisher_refresh', - requestIntentId: 1, - }); - expect(store.snapshot().slots[1].requests[0]).toMatchObject({ - requestPath: 'competing', - requestIntentId: 2, - }); - }); - - it('expires repeated source evidence lazily without scheduling timer work', () => { - let now = 0; - const deferred: Array<{ callback: () => void; delayMs: number }> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback, delayMs) => deferred.push({ callback, delayMs }), - }); - const consumed = fakeSlot('lazy-expiry-consumed'); - const expired = fakeSlot('lazy-expiry-expired'); - - for (let observation = 0; observation < 1_000; observation += 1) { - now = observation; - store.recordPublisherRefresh([consumed, expired]); - } - - expect(deferred, 'a refresh burst must not queue deferred work').toHaveLength(0); - - // The window runs from the newest observation, at t = 999. - now = 999 + REQUEST_PATH_ATTRIBUTION_WINDOW_MS - 1; - store.recordSlotRequested(consumed); - now += 1; - store.recordSlotRequested(expired); - - expect(store.snapshot().slots[0].requests[0].requestPath).toBe('publisher_refresh'); - expect(store.snapshot().slots[1].requests[0].requestPath).toBe('unattributed'); - expect(deferred, 'expiry must stay free of deferred work').toHaveLength(0); - }); - - it('replaces a fully expired intent instead of reviving its intent ID', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('expired-intent-replacement'); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate', 'stale'); - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS; - store.recordPublisherRefresh([slot]); - store.recordSlotRequested(slot); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ requestPath: 'publisher_refresh', requestIntentId: 2 }); - expect( - cycle.trustedServerOpportunity, - 'expired direct evidence must not survive' - ).toBeUndefined(); - expect(cycle.trustedServerAuctionId).toBeUndefined(); - expect(deferred).toHaveLength(0); - }); - - it('derives a replacement from the most recent earlier filled render', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement'); - - store.recordSlotRequested(slot); - now = 2; - store.recordSlotResponseReceived(slot); - now = 3; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 20; - store.recordSlotRequested(slot); - now = 21; - store.recordSlotResponseReceived(slot); - now = 22; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - replacedRequestNumber: 1, - previousRenderToRequestMs: 17, - previousCreativeId: 101, - creativeChanged: true, - }); - }); - - it('compares primary and source-agnostic GPT creative identities for replacements', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement-fallback-creative'); - store.recordSlotRequested(slot); - now = 2; - store.recordSlotResponseReceived(slot); - now = 3; - store.recordSlotRenderEnded(slot, { - isEmpty: false, - adManager: { sourceAgnosticCreativeId: 101 }, - }); - now = 4; - store.recordSlotRequested(slot); - now = 5; - store.recordSlotResponseReceived(slot); - now = 6; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - previousCreativeId: 101, - creativeChanged: false, - }); - }); - - it('uses the latest earlier filled render while ignoring empty renders', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement-most-recent-filled'); - - store.recordSlotRequested(slot); - now = 2; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 3; - store.recordSlotRequested(slot); - now = 4; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - now = 5; - store.recordSlotRequested(slot); - now = 6; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - now = 7; - store.recordSlotRequested(slot); - now = 8; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 303 } }); - - const requests = store.snapshot().slots[0].requests; - expect(requests[2].replacedRequestNumber).toBeUndefined(); - expect(requests[3]).toMatchObject({ - replacedRequestNumber: 2, - previousRenderToRequestMs: 3, - previousCreativeId: 202, - creativeChanged: true, - }); - }); - - it('reports one-sided creative IDs without claiming a creative change', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const previousOnly = fakeSlot('replacement-previous-id-only'); - const currentOnly = fakeSlot('replacement-current-id-only'); - - store.recordSlotRequested(previousOnly); - now = 2; - store.recordSlotRenderEnded(previousOnly, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 3; - store.recordSlotRequested(previousOnly); - now = 4; - store.recordSlotRenderEnded(previousOnly, { isEmpty: false }); - - store.recordSlotRequested(currentOnly); - now = 5; - store.recordSlotRenderEnded(currentOnly, { isEmpty: false }); - now = 6; - store.recordSlotRequested(currentOnly); - now = 7; - store.recordSlotRenderEnded(currentOnly, { isEmpty: false, adManager: { creativeId: 202 } }); - - const [previousOnlyCycle] = store.snapshot().slots[0].requests.slice(-1); - const [currentOnlyCycle] = store.snapshot().slots[1].requests.slice(-1); - expect(previousOnlyCycle).toMatchObject({ replacedRequestNumber: 1, previousCreativeId: 101 }); - expect(previousOnlyCycle.creativeChanged).toBeUndefined(); - expect(currentOnlyCycle).toMatchObject({ replacedRequestNumber: 1 }); - expect(currentOnlyCycle.previousCreativeId).toBeUndefined(); - expect(currentOnlyCycle.creativeChanged).toBeUndefined(); - }); - - it('does not infer replacements once the earlier filled cycle has been evicted', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement-evicted'); - - store.recordSlotRequested(slot); - now += 1; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - // Complete every filler cycle so the eviction pushes the only filled render - // out of retention and the final render still matches exactly one cycle. - for (let index = 0; index < MAX_REQUEST_CYCLES_PER_SLOT; index += 1) { - now += 1; - store.recordSlotRequested(slot); - now += 1; - store.recordSlotResponseReceived(slot); - now += 1; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - } - now += 1; - store.recordSlotRequested(slot); - now += 1; - store.recordSlotResponseReceived(slot); - now += 1; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - - const requests = store.snapshot().slots[0].requests; - expect( - requests.some((cycle) => cycle.adManager?.creativeId === 101), - 'the earlier filled cycle should have been evicted' - ).toBe(false); - const latestCycle = last(requests)!; - expect(latestCycle.renderAtMs, 'the final render must have been matched').toBeDefined(); - expect(latestCycle.adManager?.creativeId).toBe(202); - expect(latestCycle.replacedRequestNumber).toBeUndefined(); - expect(latestCycle.previousRenderToRequestMs).toBeUndefined(); - expect(latestCycle.previousCreativeId).toBeUndefined(); - }); - - it('keeps Trusted Server and publisher source evidence separate from replacement facts', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('replacement-source-evidence'); - - store.recordSlotRequested(slot); - now = 2; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 3; - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - store.recordPublisherRefresh([slot]); - store.recordSlotRequested(slot); - now = 4; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - requestPath: 'competing', - replacedRequestNumber: 1, - previousCreativeId: 101, - creativeChanged: true, - }); - }); - - it('expires request-path markers at the five-second boundary without waiting for timers', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const beforeBoundary = fakeSlot('before-boundary'); - const atBoundary = fakeSlot('at-boundary'); - - for (const slot of [beforeBoundary, atBoundary]) { - store.recordTrustedServerOpportunity( - slot, - `auction-${slot.getSlotElementId?.()}`, - 'no_candidate' - ); - store.recordPrebidRefresh([slot]); - } - - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS - 1; - store.recordSlotRequested(beforeBoundary); - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS; - store.recordSlotRequested(atBoundary); - - const [before, expired] = store.snapshot().slots.map((slot) => slot.requests[0]); - expect(before).toMatchObject({ - requestPath: 'competing', - trustedServerOpportunity: 'no_candidate', - }); - expect(expired).toMatchObject({ requestPath: 'unattributed' }); - expect(expired.trustedServerOpportunity).toBeUndefined(); - expect(deferred, 'the boundary must be enforced without marker timers').toHaveLength(0); - }); - - it('keeps the newest evidence when a source is re-observed inside the window', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('re-observed-source'); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - store.recordPrebidRefresh([slot]); - now = 100; - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'unrenderable_candidate'); - store.recordPrebidRefresh([slot]); - store.recordSlotRequested(slot); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'competing', - requestIntentId: 1, - trustedServerOpportunity: 'unrenderable_candidate', - }); - expect(deferred).toHaveLength(0); - }); - - it('expires sources independently and replaces Trusted Server auction metadata', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('independent-expiry'); - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate', 'old'); - now = 1; - store.recordPrebidRefresh([slot]); - now = 2; - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'no_candidate'); - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS + 1; - store.recordSlotRequested(slot); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'trusted_server_direct', - trustedServerOpportunity: 'no_candidate', - }); - expect(store.snapshot().slots[0].requests[0].trustedServerAuctionId).toBeUndefined(); - }); - - it('uses replacement Trusted Server evidence for latency and removes an unconsumed final source', () => { - let now = 0; - const deferred: Array<() => void> = []; + it('announces correctness commits synchronously while coalescing presentation work', () => { + const scheduled: Array<() => void> = []; const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), + now: () => 1, + schedule: (callback) => scheduled.push(callback), }); - const repeated = fakeSlot('repeated-trusted-server-evidence'); - const unconsumed = fakeSlot('unconsumed-trusted-server-evidence'); + const commitListener = vi.fn(); + const presentationListener = vi.fn(); + store.subscribeCommits(commitListener); + store.subscribe(presentationListener); - store.recordTrustedServerOpportunity(repeated, 'auction-slot', 'renderable_candidate'); - now = 40; - store.recordTrustedServerOpportunity(repeated, 'auction-slot', 'no_candidate'); - now = 50; - store.recordSlotRequested(repeated); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'trusted_server_direct', - trustedServerOpportunity: 'no_candidate', - opportunityToRequestMs: 10, - }); + store.markGptObserved(); + store.recordSlotRequested(fakeSlot('commit-membership')); - now = 60; - store.recordTrustedServerOpportunity(unconsumed, 'other-auction-slot', 'no_candidate'); - now += REQUEST_PATH_ATTRIBUTION_WINDOW_MS; - store.recordSlotRequested(unconsumed); - - const unconsumedCycle = store.snapshot().slots[1].requests[0]; - expect(unconsumedCycle.requestPath).toBe('unattributed'); - expect(unconsumedCycle.requestIntentId).toBeUndefined(); - }); - - it('retains only valid bounded auction IDs without dropping Trusted Server intent', () => { - const valid = 'a'.repeat(256); - const cases: Array<[unknown, string | undefined]> = [ - [valid, valid], - ['', undefined], - [' ', undefined], - [123, undefined], - ['é'.repeat(129), undefined], - ]; - for (const [auctionId, expected] of cases) { - const store = new GptDiagnosticsStore({ now: () => 1, defer: () => undefined }); - const slot = fakeSlot(`auction-id-${String(auctionId).length}`); - store.recordTrustedServerOpportunity( - slot, - 'auction-slot', - 'renderable_candidate', - auctionId as string - ); - store.recordSlotRequested(slot); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.requestPath).toBe('trusted_server_direct'); - expect(cycle.trustedServerAuctionId).toBe(expected); - } - }); - - it('does not mutate an open request cycle when a later direct marker arrives', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('open-cycle'); - - store.recordSlotRequested(slot); - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - - const openCycle = store.snapshot().slots[0].requests[0]; - expect(openCycle).toMatchObject({ requestPath: 'unattributed' }); - expect(openCycle.trustedServerOpportunity).toBeUndefined(); - - store.recordSlotRequested(slot); - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - requestPath: 'trusted_server_direct', - trustedServerOpportunity: 'renderable_candidate', - }); - }); - - it('ignores malformed diagnostic marker inputs without throwing', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('valid-marker'); - - expect(() => - store.recordTrustedServerOpportunity(null as never, 'auction-slot', 'renderable_candidate') - ).not.toThrow(); - expect(() => - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'invalid' as never) - ).not.toThrow(); - expect(() => store.recordPrebidRefresh(null as never)).not.toThrow(); - expect(() => store.recordPrebidRefresh([null, 1, slot] as never)).not.toThrow(); - - store.recordSlotRequested(slot); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ requestPath: 'prebid_refresh' }); - expect(cycle.trustedServerOpportunity).toBeUndefined(); - }); - - it.each(['renderable_candidate', 'unrenderable_candidate'] as const)( - 'moves an explicit non-empty %s to unconfirmed after one deferred notification', - (opportunity) => { - let now = 10; - const deferred: Array<{ callback: () => void; delayMs: number }> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - schedule: (callback) => callback(), - defer: (callback, delayMs) => deferred.push({ callback, delayMs }), - }); - const listener = vi.fn(); - const slot = fakeSlot(`delivery-${opportunity}`); - store.subscribe(listener); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', opportunity); - store.recordSlotRequested(slot); - expect(deferred, 'recording intent must not defer work').toHaveLength(0); - listener.mockClear(); - - now = 30; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('pending'); - expect(deferred).toHaveLength(1); - expect(deferred[0].delayMs).toBe(TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS); - expect(listener).toHaveBeenCalledTimes(1); - - now = 30 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - deferred[0].callback(); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('candidate_unconfirmed'); - expect(listener).toHaveBeenCalledTimes(2); - expect(deferred).toHaveLength(1); - } - ); - - it.each([ - { - name: 'an explicit no-candidate fill', - opportunity: 'no_candidate', - renderFacts: { isEmpty: false }, - expected: 'no_candidate', - }, - { - name: 'a fill without a direct opportunity', - opportunity: undefined, - renderFacts: { isEmpty: false }, - expected: 'unknown', - }, - { - name: 'a render with omitted fill state', - opportunity: 'renderable_candidate', - renderFacts: {}, - expected: 'unknown', - }, - { - name: 'an empty render', - opportunity: 'renderable_candidate', - renderFacts: { isEmpty: true }, - expected: 'not_applicable', - }, - { - name: 'a pre-render request', - opportunity: 'renderable_candidate', - renderFacts: undefined, - expected: 'not_applicable', - }, - ] as const)( - 'derives $name from observed evidence only', - ({ opportunity, renderFacts, expected }) => { - let now = 10; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('delivery-state'); - - if (opportunity === undefined) { - store.recordPrebidRefresh([slot]); - } else { - store.recordTrustedServerOpportunity(slot, 'auction-slot', opportunity); - } - store.recordSlotRequested(slot); - deferred.shift()?.(); - now = 30; - if (renderFacts !== undefined) store.recordSlotRenderEnded(slot, renderFacts); - - expect(store.snapshot().slots[0].requests[0].delivery).toBe(expected); - expect(deferred, 'should not schedule an attribution-boundary notification').toHaveLength(0); - now = 30 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - expect(store.snapshot().slots[0].requests[0].delivery).toBe(expected); - } - ); - - it.each([ - { name: 'omitted fill state', facts: {}, expected: undefined }, - { name: 'an empty render', facts: { isEmpty: true }, expected: 'empty' }, - { - name: 'an explicit backfill', - facts: { isEmpty: false, isBackfill: true }, - expected: 'backfill', - }, - { - name: 'an explicit reservation', - facts: { isEmpty: false, adManager: { lineItemId: 123 } }, - expected: 'reservation', - }, - { - name: 'a source-agnostic identity confirmed as non-backfill', - facts: { - isEmpty: false, - isBackfill: false, - adManager: { sourceAgnosticLineItemId: 123 }, - }, - expected: 'reservation', - }, - { - name: 'a source-agnostic identity without a backfill fact', - facts: { isEmpty: false, adManager: { sourceAgnosticLineItemId: 123 } }, - expected: 'unclassified_non_empty', - }, - { - name: 'an otherwise unclassified non-empty render', - facts: { isEmpty: false }, - expected: 'unclassified_non_empty', - }, - ] as const)('classifies $name only from explicit render facts', ({ facts, expected }) => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('response-class'); - - store.recordSlotRequested(slot); - store.recordSlotRenderEnded(slot, facts); - - expect(store.snapshot().slots[0].requests[0].responseClass).toBe(expected); - }); - - it('correlates a creative request and response to the selected request cycle', () => { - let now = 10; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('creative-selected'); - associateSlot(store, slot, 'auction-selected'); - store.recordSlotRequested(slot); - - now = 20; - const attemptId = store.recordTrustedServerCreativeRequest('auction-selected'); - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 20, - delivery: 'trusted_server_selected', - }); - - now = 25; - store.recordTrustedServerCreativeResponse(attemptId!); - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 20, - trustedServerCreativeResponseAtMs: 25, - delivery: 'trusted_server_response_sent', - }); - }); - - it('accepts late positive creative evidence after the candidate observation timeout', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('late-positive'); - associateSlot(store, slot, 'auction-late'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - now = 1 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - expect(store.snapshot().slots[0].requests[0].delivery).toBe('candidate_unconfirmed'); - - const attemptId = store.recordTrustedServerCreativeRequest('auction-late'); - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_selected'); - now += 1; - store.recordTrustedServerCreativeResponse(attemptId!); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_response_sent'); - }); - - it('keeps the first request timestamp and live ID across duplicate creative requests', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('creative-retry'); - associateSlot(store, slot, 'auction-retry'); - store.recordSlotRequested(slot); - - now = 5; - const firstId = store.recordTrustedServerCreativeRequest('auction-retry'); - now = 9; - const duplicateId = store.recordTrustedServerCreativeRequest('auction-retry'); - - expect(duplicateId).toBe(firstId); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(5); - }); - - it('records each safe creative failure once in first-observed order and can later succeed', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('creative-failures'); - associateSlot(store, slot, 'auction-failures'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-failures')!; - - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - store.recordTrustedServerCreativeFailure(attemptId, 'missing_render_source'); - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - store.recordTrustedServerCreativeFailure(attemptId, 'invalid_cache_payload'); - store.recordTrustedServerCreativeFailure(attemptId, 'response_post_failed'); - store.recordTrustedServerCreativeFailure(attemptId, 'unsafe_runtime_value' as never); - - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([ - 'cache_fetch_failed', - 'missing_render_source', - 'invalid_cache_payload', - 'response_post_failed', - ]); - expect(store.snapshot().attributionIssues).toEqual([]); - - now = 1; - store.recordTrustedServerCreativeResponse(attemptId); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_response_sent'); - }); - - it('keeps an asynchronous response on its originating cycle after a newer refresh', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('async-origin'); - associateSlot(store, slot, 'auction-async'); - store.recordSlotRequested(slot); - const firstId = store.recordTrustedServerCreativeRequest('auction-async')!; - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - now = 2; - store.recordSlotRequested(slot); - now = 3; - store.recordTrustedServerCreativeResponse(firstId); - - const [first, second] = store.snapshot().slots[0].requests; - expect(first).toMatchObject({ - trustedServerCreativeResponseAtMs: 3, - delivery: 'trusted_server_response_sent', - }); - expect(second.trustedServerCreativeResponseAtMs).toBeUndefined(); - }); - - it('provisionally attaches an initial pre-render creative request', () => { - let now = 10; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('provisional'); - associateSlot(store, slot, 'auction-provisional'); - store.recordSlotRequested(slot); - - now = 11; - const attemptId = store.recordTrustedServerCreativeRequest('auction-provisional'); - expect(attemptId).toEqual(expect.any(Number)); - now = 12; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 11, - isEmpty: false, - delivery: 'trusted_server_selected', - }); - expect(store.snapshot().attributionIssues).toEqual([]); - }); - - it('rejects an ambiguous pre-render request when an earlier non-empty cycle is retained', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('ambiguous-creative'); - associateSlot(store, slot, 'auction-ambiguous'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - now = 2; - store.recordSlotRequested(slot); - - now = 3; - expect(store.recordTrustedServerCreativeRequest('auction-ambiguous')).toBeUndefined(); - expect(store.snapshot().slots[0].requests[1].trustedServerCreativeRequestAtMs).toBeUndefined(); - expect(store.snapshot().attributionIssues).toEqual([ - expect.objectContaining({ - reason: 'creative_request_ambiguous_cycle', - runtimeSlotNumber: 1, - slotElementId: 'ambiguous-creative', - }), - ]); - }); - - it('accepts positive creative evidence when GPT omitted isEmpty', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('unknown-fill-positive'); - associateSlot(store, slot, 'auction-unknown-fill'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, {}); - now = 2; - - expect(store.recordTrustedServerCreativeRequest('auction-unknown-fill')).toEqual( - expect.any(Number) - ); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_selected'); - }); - - it('rejects explicit empty cycles and never falls back to an older compatible cycle', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('empty-current'); - associateSlot(store, slot, 'auction-empty-current'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - now = 2; - store.recordSlotRequested(slot); - now = 3; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - - expect(store.recordTrustedServerCreativeRequest('auction-empty-current')).toBeUndefined(); - const [older, current] = store.snapshot().slots[0].requests; - expect(older.trustedServerCreativeRequestAtMs).toBeUndefined(); - expect(current.trustedServerCreativeRequestAtMs).toBeUndefined(); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_request_without_cycle'); - }); - - it('preserves provisional evidence and reports when the cycle later renders empty', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('provisional-empty'); - associateSlot(store, slot, 'auction-provisional-empty'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-provisional-empty'); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(0); - expect(store.snapshot().attributionIssues).toEqual([ - expect.objectContaining({ - reason: 'creative_request_on_empty_cycle', - runtimeSlotNumber: 1, - slotElementId: 'provisional-empty', - }), - ]); - - // The attempt is dead once its cycle rendered empty, so a late response - // cannot claim a Trusted Server delivery against that empty render. - store.recordTrustedServerCreativeResponse(attemptId!); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.trustedServerCreativeResponseAtMs).toBeUndefined(); - expect(cycle.delivery, 'an empty cycle must not report a markup response').toBe( - 'trusted_server_selected' - ); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_evicted'); - }); - - it('preserves provisional evidence when the render omits isEmpty', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('provisional-unknown-fill'); - associateSlot(store, slot, 'auction-provisional-unknown-fill'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-provisional-unknown-fill'); - - now = 1; - store.recordSlotRenderEnded(slot, {}); - - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 0, - delivery: 'trusted_server_selected', - }); - expect(store.snapshot().attributionIssues).toEqual([]); - }); - - it('admits a request at the cycle-age boundary and rejects only after it', () => { - let boundaryNow = 0; - const boundaryStore = new GptDiagnosticsStore({ - now: () => boundaryNow, - defer: () => undefined, - }); - const boundarySlot = fakeSlot('cycle-boundary'); - associateSlot(boundaryStore, boundarySlot, 'auction-boundary'); - boundaryStore.recordSlotRequested(boundarySlot); - boundaryNow = CREATIVE_ATTEMPT_WINDOW_MS; - expect(boundaryStore.recordTrustedServerCreativeRequest('auction-boundary')).toEqual( - expect.any(Number) - ); - - let lateNow = 0; - const lateStore = new GptDiagnosticsStore({ now: () => lateNow, defer: () => undefined }); - const lateSlot = fakeSlot('cycle-too-old'); - associateSlot(lateStore, lateSlot, 'auction-too-old'); - lateStore.recordSlotRequested(lateSlot); - lateNow = CREATIVE_ATTEMPT_WINDOW_MS + 1; - expect(lateStore.recordTrustedServerCreativeRequest('auction-too-old')).toBeUndefined(); - expect(last(lateStore.snapshot().attributionIssues)?.reason).toBe( - 'creative_request_without_cycle' - ); - }); - - it('distinguishes missing slot associations from known slots without a request cycle', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const associated = fakeSlot('associated-no-cycle'); - associateSlot(store, associated, 'auction-no-cycle'); - - expect(store.recordTrustedServerCreativeRequest('auction-unknown')).toBeUndefined(); - expect(store.recordTrustedServerCreativeRequest('')).toBeUndefined(); - expect(store.recordTrustedServerCreativeRequest('auction-no-cycle')).toBeUndefined(); - - const issues = store.snapshot().attributionIssues; - expect(issues.map((issue) => issue.reason)).toEqual([ - 'creative_request_without_slot', - 'creative_request_without_slot', - 'creative_request_without_cycle', - ]); - expect(issues[0].runtimeSlotNumber).toBeUndefined(); - expect(issues[0].slotElementId).toBeUndefined(); - expect(issues[2].slotElementId).toBe('associated-no-cycle'); - }); - - it('expires attempts at 30 seconds without replacement or late mutation', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('attempt-expiry'); - associateSlot(store, slot, 'auction-expiry'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-expiry')!; - - now = CREATIVE_ATTEMPT_WINDOW_MS - 1; - expect(store.recordTrustedServerCreativeRequest('auction-expiry')).toBe(attemptId); - now = CREATIVE_ATTEMPT_WINDOW_MS; - expect(store.recordTrustedServerCreativeRequest('auction-expiry')).toBeUndefined(); - store.recordTrustedServerCreativeResponse(attemptId); - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ trustedServerCreativeRequestAtMs: 0 }); - expect(cycle.trustedServerCreativeResponseAtMs).toBeUndefined(); - expect(cycle.trustedServerCreativeFailures).toBeUndefined(); - expect(store.snapshot().attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_expired', - 'creative_attempt_expired', - 'creative_attempt_expired', - ]); - }); - - it('reuses a live attempt after the cycle ages out and expires from creative-request time', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('delayed-attempt-expiry'); - associateSlot(store, slot, 'auction-delayed-attempt-expiry'); - store.recordSlotRequested(slot); - - now = 20_000; - const attemptId = store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry'); - expect(attemptId).toEqual(expect.any(Number)); - - now = CREATIVE_ATTEMPT_WINDOW_MS + 1; - expect(store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry')).toBe( - attemptId - ); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(20_000); - - now = 20_000 + CREATIVE_ATTEMPT_WINDOW_MS - 1; - expect(store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry')).toBe( - attemptId - ); - now = 20_000 + CREATIVE_ATTEMPT_WINDOW_MS; - expect( - store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry') - ).toBeUndefined(); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_expired'); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(20_000); - }); - - it('reports unknown IDs and invalidates live attempts on cycle and slot eviction', () => { - const now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - store.recordTrustedServerCreativeResponse(999_999); - - const shiftedSlot = fakeSlot('shifted-attempt'); - associateSlot(store, shiftedSlot, 'auction-shifted'); - store.recordSlotRequested(shiftedSlot); - const shiftedId = store.recordTrustedServerCreativeRequest('auction-shifted')!; - for (let index = 0; index < MAX_REQUEST_CYCLES_PER_SLOT; index += 1) { - store.recordSlotRequested(shiftedSlot); - } - store.recordTrustedServerCreativeResponse(shiftedId); - - const evictedSlot = fakeSlot('lru-attempt'); - associateSlot(store, evictedSlot, 'auction-lru-attempt'); - store.recordSlotRequested(evictedSlot); - const evictedId = store.recordTrustedServerCreativeRequest('auction-lru-attempt')!; - for (let index = 0; index < MAX_DIAGNOSTIC_SLOTS; index += 1) { - store.recordSlotRequested(fakeSlot(`attempt-lru-filler-${index}`)); - } - store.recordTrustedServerCreativeFailure(evictedId, 'response_post_failed'); - - expect(store.snapshot().attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_unknown', - 'creative_attempt_evicted', - 'creative_attempt_evicted', - ]); - }); - - it('treats duplicate writers against a completed attempt as idempotent', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('completed-attempt'); - associateSlot(store, slot, 'auction-completed'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-completed')!; - now = 1; - store.recordTrustedServerCreativeResponse(attemptId); - const completed = store.snapshot(); - - now = 2; - expect(store.recordTrustedServerCreativeRequest('auction-completed')).toBeUndefined(); - store.recordTrustedServerCreativeResponse(attemptId); - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - - expect(store.snapshot().slots[0].requests[0]).toEqual(completed.slots[0].requests[0]); - expect(store.snapshot().attributionIssues).toEqual([]); - }); - - it('does not replace a completed current-cycle attempt after its tombstone is reclaimed', () => { - const now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const sentinelSlot = fakeSlot('completed-current-cycle'); - associateSlot(store, sentinelSlot, 'auction-completed-current-cycle'); - store.recordSlotRequested(sentinelSlot); - const sentinelId = store.recordTrustedServerCreativeRequest('auction-completed-current-cycle')!; - store.recordTrustedServerCreativeResponse(sentinelId); - - recordCompletedAttempts(store, MAX_CREATIVE_ATTEMPTS - 1, 'completed-current-cycle-fill'); - const replacementSlot = fakeSlot('completed-current-cycle-replacement'); - associateSlot(store, replacementSlot, 'auction-completed-current-cycle-replacement'); - store.recordSlotRequested(replacementSlot); - expect( - store.recordTrustedServerCreativeRequest('auction-completed-current-cycle-replacement') - ).toEqual(expect.any(Number)); - - expect( - store.recordTrustedServerCreativeRequest('auction-completed-current-cycle') - ).toBeUndefined(); - expect( - store.recordTrustedServerCreativeRequest('auction-completed-current-cycle') - ).toBeUndefined(); - expect(store.snapshot().attributionIssues).toEqual([]); - expect( - store.snapshot().slots.find((slot) => slot.slotElementId === 'completed-current-cycle') - ?.requests[0] - ).toMatchObject({ - trustedServerCreativeRequestAtMs: 0, - trustedServerCreativeResponseAtMs: 0, - }); - }); - - it('does not replace an expired current-cycle attempt after its tombstone is reclaimed', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const sentinelSlot = fakeSlot('expired-current-cycle'); - associateSlot(store, sentinelSlot, 'auction-expired-current-cycle'); - store.recordSlotRequested(sentinelSlot); - expect(store.recordTrustedServerCreativeRequest('auction-expired-current-cycle')).toEqual( - expect.any(Number) - ); - - now = CREATIVE_ATTEMPT_WINDOW_MS; - recordCompletedAttempts(store, MAX_CREATIVE_ATTEMPTS - 1, 'expired-current-cycle-fill'); - const replacementSlot = fakeSlot('expired-current-cycle-replacement'); - associateSlot(store, replacementSlot, 'auction-expired-current-cycle-replacement'); - store.recordSlotRequested(replacementSlot); - expect( - store.recordTrustedServerCreativeRequest('auction-expired-current-cycle-replacement') - ).toEqual(expect.any(Number)); - - expect( - store.recordTrustedServerCreativeRequest('auction-expired-current-cycle') - ).toBeUndefined(); - expect( - store.recordTrustedServerCreativeRequest('auction-expired-current-cycle') - ).toBeUndefined(); - expect(store.snapshot().attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_unknown', - 'creative_attempt_unknown', - ]); - expect( - store.snapshot().slots.find((slot) => slot.slotElementId === 'expired-current-cycle') - ?.requests[0] - ).toMatchObject({ trustedServerCreativeRequestAtMs: 0 }); - }); - - it('never evicts a live attempt at capacity and lets an unassigned duplicate retry', () => { - let now = 100; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const liveIds: number[] = []; - let created = 0; - - for (let slotIndex = 0; created < MAX_CREATIVE_ATTEMPTS; slotIndex += 1) { - const slot = fakeSlot(`live-capacity-${slotIndex}`); - const auctionSlotId = `auction-live-capacity-${slotIndex}`; - associateSlot(store, slot, auctionSlotId); - const cycles = Math.min(MAX_REQUEST_CYCLES_PER_SLOT, MAX_CREATIVE_ATTEMPTS - created); - for (let cycleIndex = 0; cycleIndex < cycles; cycleIndex += 1) { - store.recordSlotRequested(slot); - liveIds.push(store.recordTrustedServerCreativeRequest(auctionSlotId)!); - created += 1; - } - } - - const rejectedSlot = fakeSlot('live-capacity-rejected'); - associateSlot(store, rejectedSlot, 'auction-live-capacity-rejected'); - store.recordSlotRequested(rejectedSlot); - expect( - store.recordTrustedServerCreativeRequest('auction-live-capacity-rejected') - ).toBeUndefined(); - expect(last(store.snapshot().slots)?.requests[0].trustedServerCreativeRequestAtMs).toBe(100); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_capacity'); - - now = 250; - store.recordTrustedServerCreativeResponse(liveIds[0]); - const retriedId = store.recordTrustedServerCreativeRequest('auction-live-capacity-rejected'); - expect(retriedId).toEqual(expect.any(Number)); - expect(retriedId).not.toBe(liveIds[0]); - expect(last(store.snapshot().slots)?.requests[0].trustedServerCreativeRequestAtMs).toBe(100); - store.recordTrustedServerCreativeResponse(liveIds[1]); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_capacity'); - }); - - it('does not create an already-expired attempt when a capacity retry reaches its boundary', () => { - let now = 100; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - let created = 0; - - for (let slotIndex = 0; created < MAX_CREATIVE_ATTEMPTS; slotIndex += 1) { - const slot = fakeSlot(`boundary-capacity-${slotIndex}`); - const auctionSlotId = `auction-boundary-capacity-${slotIndex}`; - associateSlot(store, slot, auctionSlotId); - const cycles = Math.min(MAX_REQUEST_CYCLES_PER_SLOT, MAX_CREATIVE_ATTEMPTS - created); - for (let cycleIndex = 0; cycleIndex < cycles; cycleIndex += 1) { - store.recordSlotRequested(slot); - expect(store.recordTrustedServerCreativeRequest(auctionSlotId)).toEqual(expect.any(Number)); - created += 1; - } - } - - const rejectedSlot = fakeSlot('boundary-capacity-rejected'); - const rejectedAuctionSlotId = 'auction-boundary-capacity-rejected'; - associateSlot(store, rejectedSlot, rejectedAuctionSlotId); - store.recordSlotRequested(rejectedSlot); - expect(store.recordTrustedServerCreativeRequest(rejectedAuctionSlotId)).toBeUndefined(); - - now += CREATIVE_ATTEMPT_WINDOW_MS; - expect(store.recordTrustedServerCreativeRequest(rejectedAuctionSlotId)).toBeUndefined(); - const snapshot = store.snapshot(); - const rejectedCycle = snapshot.slots.find( - (slot) => slot.slotElementId === 'boundary-capacity-rejected' - )?.requests[0]; - expect(rejectedCycle?.trustedServerCreativeRequestAtMs).toBe(100); - expect(snapshot.attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_capacity', - 'creative_attempt_expired', - ]); - }); - - it('bounds attribution issues separately without changing callback coverage', () => { - const store = new GptDiagnosticsStore({ now: () => 1, defer: () => undefined }); - const beforeCoverage = store.snapshot().coverage; - - for (let index = 0; index < MAX_ATTRIBUTION_ISSUES + 1; index += 1) { - store.recordTrustedServerCreativeResponse(10_000 + index); - } - - const snapshot = store.snapshot(); - expect(snapshot.attributionIssues).toHaveLength(MAX_ATTRIBUTION_ISSUES); - expect(snapshot.metadata.droppedAttributionIssues).toBe(1); - expect(snapshot.callbackIssues).toEqual([]); - expect(snapshot.metadata.droppedCallbacks).toBe(0); - expect(snapshot.coverage).toEqual(beforeCoverage); - assertCoverageEquation(store); - }); - - it('returns detached creative evidence and never exports attempt bookkeeping', () => { - const now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('detached-creative'); - associateSlot(store, slot, 'auction-detached-creative'); - store.recordSlotRequested(slot); - store.recordSlotRenderEnded(slot, { - isEmpty: false, - adManager: { creativeId: 123, yieldGroupIds: [11], companyIds: [22] }, - }); - const attemptId = store.recordTrustedServerCreativeRequest('auction-detached-creative')!; - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - store.recordTrustedServerCreativeResponse(999_999); - - const first = store.snapshot(); - first.slots[0].requests[0].trustedServerCreativeFailures!.push('response_post_failed'); - first.slots[0].requests[0].adManager!.yieldGroupIds!.push(33); - first.slots[0].requests[0].adManager!.companyIds!.push(44); - first.attributionIssues[0].reason = 'creative_attempt_capacity'; - - const second = store.snapshot(); - expect(second.slots[0].requests[0].trustedServerCreativeFailures).toEqual([ - 'cache_fetch_failed', - ]); - expect(second.slots[0].requests[0].adManager).toMatchObject({ - creativeId: 123, - yieldGroupIds: [11], - companyIds: [22], - }); - expect(second.attributionIssues[0].reason).toBe('creative_attempt_unknown'); - const serializedCycle = JSON.stringify(second.slots[0].requests[0]); - expect(serializedCycle).not.toMatch( - /"(?:id|status|expiresAtMs|provisionalBeforeRender|auctionSlotId|attemptId|attemptStatus)"\s*:/ - ); + expect(commitListener).toHaveBeenCalledTimes(2); + expect(presentationListener).not.toHaveBeenCalled(); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(presentationListener).toHaveBeenCalledOnce(); }); it('returns detached snapshot data', () => { @@ -1835,11 +462,11 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(slot); const first = store.snapshot(); - first.slots[0].requests[0].requestNumber = 999; + first.slots[0]!.requests[0]!.requestNumber = 999; first.coverage.slotRequested.matched = 999; const second = store.snapshot(); - expect(second.slots[0].requests[0].requestNumber).toBe(1); + expect(second.slots[0]!.requests[0]!.requestNumber).toBe(1); expect(second.coverage.slotRequested.matched).toBe(1); }); it('ignores malformed publisher refresh inputs without recording intent', () => { @@ -1851,7 +478,7 @@ describe('GptDiagnosticsStore', () => { expect(() => store.recordPublisherRefresh([null, 7, undefined, slot] as never)).not.toThrow(); store.recordSlotRequested(slot); - expect(store.snapshot().slots[0].requests[0].requestPath).toBe('publisher_refresh'); + expect(store.snapshot().slots[0]!.requests[0]!.requestPath).toBe('publisher_refresh'); expect(store.snapshot().slots).toHaveLength(1); }); @@ -1911,7 +538,7 @@ describe('GptDiagnosticsStore', () => { now = 1; record(store, slot); - expect(store.snapshot().slots[0].requests[0].incompleteSequence).toBe(true); + expect(store.snapshot().slots[0]!.requests[0]!.incompleteSequence).toBe(true); expect(store.snapshot().callbackIssues).toContainEqual( expect.objectContaining({ kind, disposition: 'matched', reason: 'invalid_event_order' }) ); @@ -1928,8 +555,8 @@ describe('GptDiagnosticsStore', () => { store.recordSlotVisibilityChanged(slot, percentage); const snapshot = store.snapshot(); - expect(snapshot.slots[0].currentVisibilityPercentage).toBeUndefined(); - expect(snapshot.slots[0].maximumVisibilityPercentage).toBeUndefined(); + expect(snapshot.slots[0]!.currentVisibilityPercentage).toBeUndefined(); + expect(snapshot.slots[0]!.maximumVisibilityPercentage).toBeUndefined(); expect(snapshot.callbackIssues).toContainEqual( expect.objectContaining({ kind: 'slotVisibilityChanged', @@ -1949,7 +576,7 @@ describe('GptDiagnosticsStore', () => { store.recordTrustedServerCreativeFailure(4242, 'cache_fetch_failed'); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toBeUndefined(); + expect(store.snapshot().slots[0]!.requests[0]!.trustedServerCreativeFailures).toBeUndefined(); expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_unknown'); }); @@ -1983,7 +610,7 @@ describe('GptDiagnosticsStore', () => { deferred.shift()!.callback(); expect(deferred, 'no boundary remains once every candidate crossed it').toHaveLength(0); for (const slot of store.snapshot().slots) { - expect(slot.requests[0].delivery).toBe('candidate_unconfirmed'); + expect(slot.requests[0]!.delivery).toBe('candidate_unconfirmed'); } }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts index 6b3103060..6e36df096 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts @@ -11,7 +11,6 @@ import type { GptDiagnosticsRequestPath, GptDiagnosticsResponseClass, GptDiagnosticsSlotExport, - TsjsApi, } from '../../../src/core/types'; describe('GPT diagnostics public types', () => { @@ -48,31 +47,6 @@ describe('GPT diagnostics public types', () => { expectTypeOf(readOnlyApi).toEqualTypeOf(); }); - it('accepts legacy V1 snapshots without attribution evidence', () => { - const legacySnapshot: GptDiagnosticsExportV1 = { - version: 1, - capturedAt: '2026-08-04T00:00:00.000Z', - page: { origin: 'https://example.com', pathname: '/' }, - slots: [], - callbackIssues: [], - coverage: { - slotRequested: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotResponseReceived: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotRenderEnded: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotOnload: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - impressionViewable: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotVisibilityChanged: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - }, - metadata: { - droppedCallbacks: 0, - evictedSlots: 0, - evictedRequestCycles: 0, - }, - }; - - expect(legacySnapshot.version).toBe(1); - }); - it('keeps evidence writers off the operator API and on the internal channel', () => { expectTypeOf().toEqualTypeOf< 'snapshot' | 'export' | 'subscribe' | 'show' | 'hide' @@ -84,10 +58,6 @@ describe('GPT diagnostics public types', () => { | 'recordTrustedServerCreativeResponse' | 'recordTrustedServerCreativeFailure' >(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf< - GptDiagnosticsRecorder | undefined - >(); }); it('represents the versioned allowlist schema', () => { @@ -175,11 +145,9 @@ describe('GPT diagnostics public types', () => { expectTypeOf(evidenceCycle.requestIntentId).toEqualTypeOf(); expectTypeOf(evidenceCycle.trustedServerAuctionId).toEqualTypeOf(); expectTypeOf(evidenceSnapshot.attributionIssues).toEqualTypeOf< - GptDiagnosticsAttributionIssue[] | undefined - >(); - expectTypeOf(evidenceSnapshot.metadata.droppedAttributionIssues).toEqualTypeOf< - number | undefined + GptDiagnosticsAttributionIssue[] >(); + expectTypeOf(evidenceSnapshot.metadata.droppedAttributionIssues).toEqualTypeOf(); expectTypeOf().toEqualTypeOf< | 'creative_request_without_slot' | 'creative_request_without_cycle' diff --git a/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts b/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts new file mode 100644 index 000000000..f829d89f0 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; +import { createDidomiIntegrationRegistration } from '../../src/integrations/didomi/module'; +import { createGoogleTagManagerIntegrationRegistration } from '../../src/integrations/google_tag_manager/module'; +import { createLockrIntegrationRegistration } from '../../src/integrations/lockr/module'; +import { createOsanoIntegrationRegistration } from '../../src/integrations/osano/module'; +import { createPermutiveIntegrationRegistration } from '../../src/integrations/permutive/module'; +import { createSourcepointIntegrationRegistration } from '../../src/integrations/sourcepoint/module'; +import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; +import { + createIntegrationRegistry, + type IntegrationRegistration, +} from '../../src/kernel/integration_registry'; +import { RELEASE_CATALOG } from '../../src/kernel/release_catalog'; + +const RELEASE_ID = 'a'.repeat(64); +const CRITICAL_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; +const registrations: ReadonlyArray< + readonly [string, (release: string) => IntegrationRegistration] +> = Object.freeze([ + ['datadome', createDataDomeIntegrationRegistration] as const, + ['didomi', createDidomiIntegrationRegistration] as const, + ['google_tag_manager', createGoogleTagManagerIntegrationRegistration] as const, + ['lockr', createLockrIntegrationRegistration] as const, + ['osano_consent', createOsanoIntegrationRegistration] as const, + ['permutive_context', createPermutiveIntegrationRegistration] as const, + ['sourcepoint_consent', createSourcepointIntegrationRegistration] as const, + ['testlight', createTestlightIntegrationRegistration] as const, +]); +const configFor = (id: string): unknown => { + if (id === 'didomi') return Object.freeze({ proxyPath: '/integrations/didomi/sdk' }); + if (id === 'sourcepoint_consent') return Object.freeze({ rewriteSdk: true }); + return undefined; +}; + +function catalogFor(ids: readonly string[]) { + return Object.freeze( + ids.map((id) => { + const entry = RELEASE_CATALOG.find((candidate) => candidate.id === id); + if (!entry) throw new TypeError(`Missing release catalog row: ${id}`); + return Object.freeze({ + id: entry.id, + phase: entry.phase, + trigger: entry.trigger, + consumes: Object.freeze([...entry.consumes]), + provides: Object.freeze([...entry.provides]), + }); + }) + ); +} + +function runtimeCapability() { + return Object.freeze({ + document, + enqueue: (callback: () => void) => { + callback(); + return true; + }, + registerAuctionContext: () => () => undefined, + }); +} + +describe('remaining integration lifecycle modules', () => { + it('activates the provider-owned maximal lifecycle set without foreign runtime authority', async () => { + const order: string[] = []; + const ids = Object.freeze(registrations.map(([id]) => id)); + const foreignActivations = new Map>(); + const foreignStarts = new Map>(); + const interfaces = Object.freeze( + Object.fromEntries( + ids.map((id) => { + const activate = vi.fn(() => vi.fn()); + const start = vi.fn(); + foreignActivations.set(id, activate); + foreignStarts.set(id, start); + return [id, Object.freeze({ activate, start })]; + }) + ) + ); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: CRITICAL_SRC, + integrations: ids.map((id) => ({ id, phase: 'critical' as const })), + }, + releaseId: RELEASE_ID, + knownIntegrationIds: ids, + catalog: catalogFor(ids), + startedAtMs: 0, + now: () => 0, + runtimeCapability: runtimeCapability(), + getBindings: (id) => ({ config: configFor(id), interfaces }), + }); + for (const [, createRegistration] of registrations) { + expect(registry.register(createRegistration(RELEASE_ID))).toBe(true); + } + + const result = await registry.install({ + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'publish', 'drain']); + for (const id of ids) { + expect(foreignActivations.get(id)).not.toHaveBeenCalled(); + expect(foreignStarts.get(id)).not.toHaveBeenCalled(); + } + if (result.state === 'kernel') result.dispose(); + }); + + it.each(registrations)( + '%s runs alone without cross-integration authority', + async (id, create) => { + const activate = vi.fn(() => vi.fn()); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: CRITICAL_SRC, + integrations: [{ id, phase: 'critical' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze([id]), + catalog: catalogFor([id]), + startedAtMs: 0, + now: () => 0, + runtimeCapability: runtimeCapability(), + getBindings: () => ({ + config: configFor(id), + interfaces: Object.freeze({ [id]: Object.freeze({ activate, start }) }), + }), + }); + registry.register(create(RELEASE_ID)); + + await expect( + registry.install({ activateCore: vi.fn(), publish: vi.fn(), drainPreload: vi.fn() }) + ).resolves.toMatchObject({ state: 'kernel' }); + expect(activate).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + registry.dispose(); + } + ); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts b/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts new file mode 100644 index 000000000..2581fbd7c --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createLockrRuntime } from '../../../src/integrations/lockr/module'; + +describe('transactional Lockr integration module', () => { + afterEach(() => vi.useRealTimers()); + + it('rewrites a later initialized SDK once and compare-restores its host', async () => { + vi.useFakeTimers(); + const state: { sdk?: { host: string } } = {}; + const resetGuard = vi.fn(); + const runtime = createLockrRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => state.sdk, + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard, + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + await vi.advanceTimersByTimeAsync(49); + const sdk = { host: 'https://identity.loc.kr' }; + state.sdk = sdk; + await vi.advanceTimersByTimeAsync(1); + + expect(sdk.host).toBe('https://news.example/integrations/lockr/api'); + sdk.host = 'https://publisher.example/replacement'; + release(); + expect(sdk.host).toBe('https://publisher.example/replacement'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); + + it('stops after 50 readiness checks and owns no later timer', async () => { + vi.useFakeTimers(); + const timedOut = vi.fn(); + const setTimeout = vi.fn((callback: () => void, delay: number) => + window.setTimeout(callback, delay) + ); + const runtime = createLockrRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => undefined, + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard: vi.fn(), + setTimeout, + started: vi.fn(), + timedOut, + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + + await vi.advanceTimersByTimeAsync(2_500); + + expect(setTimeout).toHaveBeenCalledTimes(49); + expect(timedOut).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + release(); + }); + + it('cancels readiness work on disposal before the SDK appears', async () => { + vi.useFakeTimers(); + const sdk = { host: 'https://identity.loc.kr' }; + let available = false; + const runtime = createLockrRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => (available ? sdk : undefined), + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard: vi.fn(), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + release(); + available = true; + + await vi.runAllTimersAsync(); + + expect(sdk.host).toBe('https://identity.loc.kr'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('isolates a hostile timer release from SDK and guard cleanup', () => { + const sdk = { host: 'https://identity.loc.kr' }; + let sdkAvailable = false; + const clearTimeout = vi.fn(() => { + throw new Error('publisher clearTimeout failed'); + }); + const resetGuard = vi.fn(() => { + throw new Error('publisher guard reset failed'); + }); + const runtime = createLockrRuntime({ + clearTimeout, + getSdk: () => (sdkAvailable ? sdk : undefined), + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard, + setTimeout: (callback) => { + sdkAvailable = true; + callback(); + return 17; + }, + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + + expect(sdk.host).toBe('https://news.example/integrations/lockr/api'); + expect(() => release()).not.toThrow(); + expect(() => release()).not.toThrow(); + + expect(clearTimeout).toHaveBeenCalledOnce(); + expect(sdk.host).toBe('https://identity.loc.kr'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts b/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts index 891fd5540..d7f2892ae 100644 --- a/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + disposeOsanoConsentMirror, initializeOsanoConsentMirror, mirrorOsanoConsent, - resetOsanoConsentMirrorForTest, } from '../../../src/integrations/osano'; type TestWindow = Window & { @@ -24,7 +24,7 @@ type UspCallback = (data?: { uspString?: string }, success?: boolean) => void; function clearAllCookies(): void { document.cookie.split(';').forEach((cookie) => { - const name = cookie.split('=')[0].trim(); + const name = cookie.split('=')[0]?.trim() ?? ''; if (name) document.cookie = `${name}=; path=/; Max-Age=0`; }); } @@ -80,7 +80,7 @@ function setOsanoStub(): Record void> { describe('integrations/osano consent mirror', () => { beforeEach(() => { - resetOsanoConsentMirrorForTest(); + disposeOsanoConsentMirror(); clearAllCookies(); delete (window as TestWindow).Osano; delete (window as TestWindow).__uspapi; @@ -90,7 +90,7 @@ describe('integrations/osano consent mirror', () => { afterEach(() => { vi.useRealTimers(); - resetOsanoConsentMirrorForTest(); + disposeOsanoConsentMirror(); clearAllCookies(); delete (window as TestWindow).Osano; delete (window as TestWindow).__uspapi; @@ -436,4 +436,33 @@ describe('integrations/osano consent mirror', () => { expect(listeners['osano-cm-consent-saved']).toEqual(expect.any(Function)); expect(getCookie('us_privacy')).toBe('1YN-'); }); + + it('cancels in-flight API timeouts and makes late callbacks inert on disposal', async () => { + vi.useFakeTimers(); + const callbacks = setControlledUspApi(); + const pending = mirrorOsanoConsent(); + + expect(vi.getTimerCount()).toBe(1); + disposeOsanoConsentMirror(); + expect(vi.getTimerCount()).toBe(0); + await expect(pending).resolves.toBe(false); + + callbacks[0]?.({ uspString: 'late-consent' }, true); + await Promise.resolve(); + expect(getCookie('us_privacy')).toBeUndefined(); + expect(getCookie(MARKER_COOKIE)).toBeUndefined(); + }); + + it('does not retain Osano listeners when the vendor exposes no removal API', async () => { + vi.useFakeTimers(); + const addEventListener = vi.fn(); + (window as TestWindow).Osano = { cm: { addEventListener } }; + + initializeOsanoConsentMirror(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(addEventListener).not.toHaveBeenCalled(); + disposeOsanoConsentMirror(); + expect(vi.getTimerCount()).toBe(0); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/osano/module.test.ts b/crates/trusted-server-js/lib/test/integrations/osano/module.test.ts new file mode 100644 index 000000000..0c1dad220 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/osano/module.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createOsanoRuntime } from '../../../src/integrations/osano/module'; + +describe('transactional Osano integration module', () => { + it('keeps activation reversible and starts the consent mirror once after commit', () => { + const initialize = vi.fn(); + const reset = vi.fn(); + const runtime = createOsanoRuntime({ initialize, reset }); + + const release = runtime.activate(undefined); + + expect(initialize).not.toHaveBeenCalled(); + runtime.start(undefined); + runtime.start(undefined); + expect(initialize).toHaveBeenCalledOnce(); + release(); + release(); + expect(reset).toHaveBeenCalledOnce(); + }); + + it('resets partial consent ownership when startup throws', () => { + const reset = vi.fn(); + const runtime = createOsanoRuntime({ + initialize: () => { + throw new Error('listener failed'); + }, + reset, + }); + const release = runtime.activate(undefined); + + expect(() => runtime.start(undefined)).toThrow('listener failed'); + release(); + + expect(reset).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts b/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts new file mode 100644 index 000000000..3408ac40d --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createPermutiveRuntime } from '../../../src/integrations/permutive/module'; + +describe('transactional Permutive integration module', () => { + afterEach(() => vi.useRealTimers()); + + it('registers one disposable auction-context contributor during activation', () => { + const order: string[] = []; + let contributor: (() => Readonly> | undefined) | undefined; + const runtime = createPermutiveRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => undefined, + getSegments: () => ['11', '22'], + installGuard: () => order.push('guard:install'), + location: { host: 'news.example', protocol: 'https:' }, + registerContext: (candidate) => { + contributor = candidate; + order.push('context:register'); + return () => order.push('context:release'); + }, + resetGuard: () => order.push('guard:reset'), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + + const release = runtime.activate(undefined); + + expect(contributor?.()).toEqual({ permutive_segments: ['11', '22'] }); + expect(order).toEqual(['guard:install', 'context:register']); + release(); + release(); + expect(order).toEqual(['guard:install', 'context:register', 'context:release', 'guard:reset']); + }); + + it('bounds a context-service segment snapshot even when an injected reader overproduces', () => { + let contributor: (() => Readonly> | undefined) | undefined; + const runtime = createPermutiveRuntime({ + getSegments: () => Array.from({ length: 101 }, (_, index) => `${index}`), + installGuard: vi.fn(), + registerContext: (candidate) => { + contributor = candidate; + return vi.fn(); + }, + resetGuard: vi.fn(), + }); + + const release = runtime.activate(undefined); + const snapshot = contributor?.() as { readonly permutive_segments?: readonly string[] }; + + expect(snapshot.permutive_segments).toHaveLength(100); + expect(Object.isFrozen(snapshot.permutive_segments)).toBe(true); + release(); + }); + + it('rewrites a later SDK config and compare-restores every owned field', async () => { + vi.useFakeTimers(); + const config = { + apiHost: 'api.permutive.com', + apiProtocol: 'https', + cdnBaseUrl: 'cdn.permutive.com', + cdnProtocol: 'https', + secureSignalsApiHost: 'signals.permutive.com', + segmentSyncApiHost: 'sync.permutive.com', + }; + let available = false; + const runtime = createPermutiveRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => (available ? { config } : undefined), + getSegments: () => [], + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + registerContext: () => vi.fn(), + resetGuard: vi.fn(), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + available = true; + await vi.advanceTimersByTimeAsync(50); + + expect(config).toEqual({ + apiHost: 'news.example/integrations/permutive/api', + apiProtocol: 'https', + cdnBaseUrl: 'news.example/integrations/permutive/cdn', + cdnProtocol: 'https', + secureSignalsApiHost: 'news.example/integrations/permutive/secure-signal', + segmentSyncApiHost: 'news.example/integrations/permutive/sync', + }); + config.apiHost = 'publisher.example/replacement'; + release(); + expect(config).toEqual({ + apiHost: 'publisher.example/replacement', + apiProtocol: 'https', + cdnBaseUrl: 'cdn.permutive.com', + cdnProtocol: 'https', + secureSignalsApiHost: 'signals.permutive.com', + segmentSyncApiHost: 'sync.permutive.com', + }); + }); + + it('rolls back the guard when context registration is refused', () => { + const resetGuard = vi.fn(); + const runtime = createPermutiveRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => undefined, + getSegments: () => [], + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + registerContext: () => undefined, + resetGuard, + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + + expect(() => runtime.activate(undefined)).toThrowError('Permutive context registration failed'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts b/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts new file mode 100644 index 000000000..2fcd181ca --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts @@ -0,0 +1,288 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import type { IntegrationRegistration } from '../../src/kernel/integration_registry'; +import { + MAX_CRITICAL_MODULES, + MAX_MANIFEST_MODULES, + RELEASE_CATALOG, + selectReleaseCatalog, +} from '../../src/kernel/release_catalog'; + +const RELEASE_ID = 'a'.repeat(64); +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +const EXPECTED_CATALOG = Object.freeze([ + [ + 'render_runtime', + 'critical', + 'always', + ['runtime.v1'], + [ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + ], + ], + [ + 'aps', + 'critical', + 'integration:aps', + ['runtime.v1', 'slots.v1', 'render.v1', 'messages.v1', 'trace.v1'], + ['aps.v1'], + ], + ['creative', 'critical', 'creative_guard', ['runtime.v1'], []], + ['datadome', 'critical', 'integration:datadome', ['runtime.v1'], []], + ['didomi', 'critical', 'integration:didomi', ['runtime.v1'], []], + ['google_tag_manager', 'critical', 'integration:google_tag_manager', ['runtime.v1'], []], + [ + 'gpt', + 'critical', + 'integration:gpt', + ['runtime.v1', 'slots.v1', 'auction.v1', 'render.v1', 'messages.v1', 'trace.v1'], + ['gpt.v1', 'gpt.events.v1', 'pbs_cache.baseline.v1'], + ], + [ + 'gpt_diagnostics', + 'critical', + 'gpt_diagnostics_active', + ['runtime.v1', 'gpt.events.v1'], + ['gpt_diag.v1'], + ], + ['lockr', 'critical', 'integration:lockr', ['runtime.v1'], []], + ['osano_consent', 'critical', 'integration:osano', ['runtime.v1'], ['osano_consent.v1']], + [ + 'permutive_context', + 'critical', + 'integration:permutive', + ['runtime.v1'], + ['permutive_context.v1'], + ], + [ + 'sourcepoint_consent', + 'critical', + 'integration:sourcepoint', + ['runtime.v1'], + ['sourcepoint_consent.v1'], + ], + [ + 'prebid', + 'critical', + 'integration:prebid', + ['runtime.v1', 'slots.v1', 'render.v1', 'messages.v1', 'aps.v1?aps'], + ['prebid.v1'], + ], + ['testlight', 'critical', 'integration:testlight', ['runtime.v1'], []], + [ + 'diagnostics_presentation', + 'deferred', + 'diagnostics_presentation', + ['runtime.v1', 'trace.presentation.v1', 'gpt_diag.v1?gpt_diagnostics_active'], + [], + ], + [ + 'gpt_later', + 'deferred', + 'integration:gpt', + ['runtime.v1', 'slots.v1', 'auction.v1', 'render.v1', 'gpt.v1', 'trace.v1'], + [], + ], + ['osano_lifecycle', 'deferred', 'integration:osano', ['runtime.v1', 'osano_consent.v1'], []], + [ + 'permutive_lifecycle', + 'deferred', + 'integration:permutive', + ['runtime.v1', 'permutive_context.v1'], + [], + ], + [ + 'prebid_later', + 'deferred', + 'prebid_and_gpt', + ['runtime.v1', 'slots.v1', 'gpt.v1', 'prebid.v1'], + [], + ], + [ + 'sourcepoint_lifecycle', + 'deferred', + 'integration:sourcepoint', + ['runtime.v1', 'sourcepoint_consent.v1'], + [], + ], +] as const); + +const DEFERRED_FACTORIES = Object.freeze([ + [ + 'diagnostics_presentation', + '../../src/integrations/gpt_diagnostics/presentation', + 'createDiagnosticsPresentationIntegrationRegistration', + ], + ['gpt_later', '../../src/integrations/gpt/later', 'createGptLaterIntegrationRegistration'], + [ + 'osano_lifecycle', + '../../src/integrations/osano/lifecycle', + 'createOsanoLifecycleIntegrationRegistration', + ], + [ + 'permutive_lifecycle', + '../../src/integrations/permutive/lifecycle', + 'createPermutiveLifecycleIntegrationRegistration', + ], + [ + 'prebid_later', + '../../src/integrations/prebid/later', + 'createPrebidLaterIntegrationRegistration', + ], + [ + 'sourcepoint_lifecycle', + '../../src/integrations/sourcepoint/lifecycle', + 'createSourcepointLifecycleIntegrationRegistration', + ], +] as const); + +function selectedIds(selection: Parameters[0]): readonly string[] { + return selectReleaseCatalog(selection).map(({ id }) => id); +} + +function transitiveSources(entry: string): ReadonlySet { + const visited = new Set(); + const visit = (relative: string): void => { + const normalized = relative.split('\\').join('/'); + if (visited.has(normalized)) return; + visited.add(normalized); + const source = fs.readFileSync(path.join(packageRoot, normalized), 'utf8'); + const expression = /(?:import|export)\s+(?:type\s+)?(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g; + for (const match of source.matchAll(expression)) { + const request = match[1]; + if (!request?.startsWith('.')) continue; + const base = path.posix.normalize(path.posix.join(path.posix.dirname(normalized), request)); + const candidates = [`${base}.ts`, `${base}.tsx`, path.posix.join(base, 'index.ts')]; + const next = candidates.find((candidate) => fs.existsSync(path.join(packageRoot, candidate))); + if (next) visit(next); + } + }; + visit(entry); + return visited; +} + +describe('canonical critical and deferred product slices', () => { + it('maps every spec catalog row exactly once with exact phase, predicate, and capabilities', () => { + expect(RELEASE_CATALOG).toHaveLength(MAX_MANIFEST_MODULES); + expect(MAX_MANIFEST_MODULES).toBe(20); + expect(MAX_CRITICAL_MODULES).toBe(14); + expect(new Set(RELEASE_CATALOG.map(({ id }) => id))).toHaveLength(20); + expect( + RELEASE_CATALOG.map(({ id, phase, include, consumes, provides }) => [ + id, + phase, + include, + [...consumes], + [...provides], + ]) + ).toEqual(EXPECTED_CATALOG); + expect(RELEASE_CATALOG.every(({ obligation }) => obligation.trim().length > 0)).toBe(true); + expect(RELEASE_CATALOG.slice(0, 14).every(({ trigger }) => trigger === null)).toBe(true); + expect( + RELEASE_CATALOG.slice(14).every( + ({ trigger, provides }) => trigger === 'first_display_or_idle' && provides.length === 0 + ) + ).toBe(true); + }); + + it('selects every server-owned inclusion predicate without phase overrides', () => { + expect(selectedIds({ integrations: [] })).toEqual(['render_runtime']); + expect( + selectedIds({ + integrations: ['aps', 'gpt', 'prebid', 'osano', 'permutive', 'sourcepoint'], + creative: { enabled: true, clickGuard: false, renderGuard: true }, + gptDiagnosticsActive: true, + }) + ).toEqual([ + 'render_runtime', + 'aps', + 'creative', + 'gpt', + 'gpt_diagnostics', + 'osano_consent', + 'permutive_context', + 'sourcepoint_consent', + 'prebid', + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', + ]); + expect( + selectedIds({ + integrations: ['prebid'], + creative: { enabled: true, clickGuard: false, renderGuard: false }, + renderTraceOverlay: true, + }) + ).toEqual(['render_runtime', 'prebid', 'diagnostics_presentation']); + expect(() => selectReleaseCatalog({ integrations: ['unknown'] })).toThrow( + 'Unknown integration: unknown' + ); + }); + + it('grants presentation authority to the one deferred presentation slice only', () => { + const presentationConsumers = RELEASE_CATALOG.filter(({ consumes }) => + consumes.some((edge) => edge.startsWith('trace.presentation.v1')) + ); + expect(presentationConsumers.map(({ id }) => id)).toEqual(['diagnostics_presentation']); + for (const id of ['aps', 'gpt', 'gpt_later']) { + expect(RELEASE_CATALOG.find((entry) => entry.id === id)?.consumes).not.toContain( + 'trace.presentation.v1' + ); + } + }); + + it.each(DEFERRED_FACTORIES)( + '%s exports its real release-bound deferred registration', + async (id, request, exportName) => { + const module = (await import(request)) as Record; + const factory = module[exportName]; + expect(factory).toEqual(expect.any(Function)); + const registration = Reflect.apply( + factory as (releaseId: string) => IntegrationRegistration, + undefined, + [RELEASE_ID] + ); + expect(registration).toMatchObject({ abi: 1, id, phase: 'deferred', releaseId: RELEASE_ID }); + expect(Reflect.ownKeys(registration).sort()).toEqual([ + 'abi', + 'id', + 'phase', + 'prepare', + 'releaseId', + ]); + expect(Object.isFrozen(registration)).toBe(true); + } + ); + + it('keeps production core and deferred entry graphs free of test seams and owner duplication', () => { + const coreSources = transitiveSources('src/composition/index.ts'); + expect( + [...coreSources].some((source) => /(?:browser_test|\/test\/|ForTest)/.test(source)) + ).toBe(false); + expect([...coreSources].some((source) => source.startsWith('src/integrations/'))).toBe(false); + + for (const [, request] of DEFERRED_FACTORIES) { + const entry = `${request.replace('../../', 'src/').replace(/^src\/src\//, 'src/')}.ts`; + const sources = transitiveSources(entry); + expect([...sources].some((source) => source.startsWith('src/adapters/'))).toBe(false); + expect( + [...sources].some((source) => /composition\/browser(?:_test)?\.ts$/.test(source)) + ).toBe(false); + expect([...sources].some((source) => source.endsWith('kernel/runtime.ts'))).toBe(false); + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts deleted file mode 100644 index 9f9a3f977..000000000 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ /dev/null @@ -1,4475 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -function apsRenderer() { - const bid = envelope.seatbid[0].bid[0]; - return { - type: 'aps' as const, - version: 1 as const, - accountId: 'example-account-id', - bidId: bid.id, - creativeId: 'fictional-creative-id', - tagType: 'iframe' as const, - creativeUrl: bid.ext.creativeurl, - aaxResponse: btoa(JSON.stringify(envelope)), - width: bid.w, - height: bid.h, - }; -} - -/** - * Default external-bundle manifest for tests. Mirrors what the real external - * Prebid.js bundle stamps on `window.__tsjs_prebid_bundle` (see - * build-prebid-external.mjs). Individual tests override and restore it. - */ -const DEFAULT_BUNDLE_MANIFEST = { - adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - bidderCodes: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - userIdModules: ['sharedIdSystem'], -}; - -/** Loose bid shape used by the requestBids shim tests. */ -interface TestBid { - bidder: string; - params?: Record; -} - -/** Loose ad unit shape used by the requestBids shim tests. */ -interface TestAdUnit { - code?: string; - bids?: TestBid[]; -} - -/** Window properties the prebid shim reads and writes in these tests. */ -interface InjectedPrebidTestConfig { - accountId?: string; - timeout?: number; - debug?: boolean; - bidders?: string[]; - clientSideBidders?: string[]; - excludedGamAdUnitPathSuffixes?: unknown; -} - -interface TestGoogletag { - cmd: { push: (fn: () => void) => void }; - pubads: () => unknown; -} - -interface ApsPrebidTestEntry { - adUnitCode: string; - markUsed(): void; -} - -interface PrebidTestWindow { - pbjs?: unknown; - tsjs?: { - apsPrebidRenderers?: Record; - [key: string]: unknown; - }; - googletag?: TestGoogletag; - __tsjs_prebid?: InjectedPrebidTestConfig; - __tsjsPrebidShimInstalled?: boolean; - __tsjs_prebid_bundle?: unknown; - __tsjs_prebid_diagnostics?: { - userIdModules?: { - includedModules: string[]; - configuredUserIdNames: string[]; - missingConfiguredUserIdNames: string[]; - }; - }; -} - -const testWindow = window as unknown as PrebidTestWindow; - -/** Argument type accepted by the shimmed `pbjs.requestBids`. */ -type RequestBidsArg = Parameters['requestBids']>[0]; - -/** The bid adapter spec object registered via `pbjs.registerBidAdapter`. */ -interface TestAdapterSpec { - code: string; - supportedMediaTypes: string[]; - isBidRequestValid: (bid: Record) => boolean; - buildRequests: ( - bidRequests: Array>, - bidderRequest?: Record - ) => { - method: string; - url: string; - data: Record; - options: Record; - }; - interpretResponse: ( - response: Record, - request?: Record - ) => Array>; -} - -// Define mocks using vi.hoisted so they exist before the module under test is -// imported. The shim reads Prebid.js from the `window.pbjs` global (owned by -// the external bundle in production), so tests install the mock there instead -// of mocking module imports. -const { - mockSetConfig, - mockProcessQueue, - mockRequestBids, - mockRegisterBidAdapter, - mockGetUserIdsAsEids, - mockGetConfig, - mockRemoveAdUnit, - mockMarkWinningBidAsUsed, - mockOnEvent, - mockPbjs, -} = vi.hoisted(() => { - const mockSetConfig = vi.fn(); - const mockProcessQueue = vi.fn(); - const mockRequestBids = vi.fn(); - const mockRegisterBidAdapter = vi.fn(); - const mockMarkWinningBidAsUsed = vi.fn(); - const mockOnEvent = vi.fn(); - const mockGetUserIdsAsEids = vi.fn( - () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> - ); - const mockGetConfig = vi.fn(); - - const mockRemoveAdUnit = vi.fn((adUnitCode?: string | string[]) => { - if (!adUnitCode) { - mockPbjs.adUnits = []; - return; - } - const codes = new Set(Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]); - mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !codes.has(unit.code)); - }); - const mockPbjs: { - setConfig: typeof mockSetConfig; - processQueue: typeof mockProcessQueue; - requestBids: typeof mockRequestBids; - registerBidAdapter: typeof mockRegisterBidAdapter; - getUserIdsAsEids: typeof mockGetUserIdsAsEids; - getConfig: typeof mockGetConfig; - removeAdUnit: ReturnType; - markWinningBidAsUsed: typeof mockMarkWinningBidAsUsed; - adUnits: TestAdUnit[]; - setTargetingForGPTAsync?: (adUnitCodes?: string[]) => void; - [key: string]: unknown; - } = { - setConfig: mockSetConfig, - processQueue: mockProcessQueue, - requestBids: mockRequestBids, - registerBidAdapter: mockRegisterBidAdapter, - getUserIdsAsEids: mockGetUserIdsAsEids, - getConfig: mockGetConfig, - removeAdUnit: mockRemoveAdUnit, - markWinningBidAsUsed: mockMarkWinningBidAsUsed, - onEvent: mockOnEvent, - adUnits: [] as TestAdUnit[], - setTargetingForGPTAsync: undefined as ((adUnitCodes?: string[]) => void) | undefined, - que: [] as Array<() => void>, - cmd: [] as Array<() => void>, - }; - - // Install the mock global BEFORE the shim module evaluates — the shim - // captures `window.pbjs` at module scope. - const w = globalThis.window as unknown as { - pbjs?: unknown; - __tsjs_prebid_bundle?: unknown; - }; - w.pbjs = mockPbjs; - w.__tsjs_prebid_bundle = { - adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - bidderCodes: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - userIdModules: ['sharedIdSystem'], - }; - - return { - mockSetConfig, - mockProcessQueue, - mockRequestBids, - mockRegisterBidAdapter, - mockGetUserIdsAsEids, - mockGetConfig, - mockRemoveAdUnit, - mockMarkWinningBidAsUsed, - mockOnEvent, - mockPbjs, - }; -}); - -import { - collectBidders, - getInjectedConfig, - auctionBidsToPrebidBids, - installPrebidNpm, - installRefreshHandler, -} from '../../../src/integrations/prebid/index'; -import type { AuctionBid } from '../../../src/core/auction'; -import { log } from '../../../src/core/log'; -import { GptDiagnosticsObserver } from '../../../src/integrations/gpt_diagnostics/observer'; -import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; -import envelope from '../../fixtures/aps-renderer-v1.json'; - -// installPrebidNpm is a per-page no-op once the sentinel is set (the module -// self-init above already set it), so every test starts from a clean page. -beforeEach(() => { - delete testWindow.__tsjsPrebidShimInstalled; -}); - -describe('prebid/collectBidders', () => { - it('returns empty array for empty ad units', () => { - expect(collectBidders([])).toEqual([]); - }); - - it('returns empty array for ad units without bids', () => { - expect(collectBidders([{}, { bids: [] }])).toEqual([]); - }); - - it('collects unique bidders from ad units', () => { - const adUnits = [ - { bids: [{ bidder: 'appnexus' }, { bidder: 'rubicon' }] }, - { bids: [{ bidder: 'appnexus' }, { bidder: 'openx' }] }, - ]; - const result = collectBidders(adUnits); - expect(result).toHaveLength(3); - expect(result).toContain('appnexus'); - expect(result).toContain('rubicon'); - expect(result).toContain('openx'); - }); - - it('skips bids without a bidder field', () => { - const adUnits = [{ bids: [{ bidder: 'kargo' }, {}] }]; - expect(collectBidders(adUnits)).toEqual(['kargo']); - }); -}); - -describe('prebid/getInjectedConfig', () => { - afterEach(() => { - delete testWindow.__tsjs_prebid; - }); - - it('returns undefined when window.__tsjs_prebid is not set', () => { - expect(getInjectedConfig()).toBeUndefined(); - }); - - it('returns the injected config when present', () => { - testWindow.__tsjs_prebid = { accountId: 'server-42', timeout: 2000 }; - expect(getInjectedConfig()).toEqual({ accountId: 'server-42', timeout: 2000 }); - }); -}); - -describe('prebid/auctionBidsToPrebidBids', () => { - it('maps AuctionBid[] to Prebid bid response objects', () => { - const auctionBids: AuctionBid[] = [ - { - impid: 'div-gpt-1', - adm: '
Ad
', - price: 3.5, - width: 300, - height: 250, - seat: 'appnexus', - creativeId: 'cr-123', - adomain: ['example.com'], - }, - ]; - const bidRequests = [{ adUnitCode: 'div-gpt-1', bidId: 'bid-abc' }]; - - const result = auctionBidsToPrebidBids(auctionBids, bidRequests, true); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - requestId: 'bid-abc', - cpm: 3.5, - width: 300, - height: 250, - ad: '
Ad
', - ttl: 300, - creativeId: 'cr-123', - netRevenue: true, - currency: 'USD', - bidderCode: 'appnexus', - meta: { advertiserDomains: ['example.com'] }, - }); - }); - - it('preserves an APS renderer without converting it to executable markup', () => { - const renderer = apsRenderer(); - const auctionBids: AuctionBid[] = [ - { - impid: 'div-aps', - adm: '', - renderer, - price: 1.23, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'fictional-creative-id', - adomain: ['advertiser.example'], - }, - ]; - - const result = auctionBidsToPrebidBids( - auctionBids, - [{ adUnitCode: 'div-aps', bidId: 'prebid-request-id' }], - true - ); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual( - expect.objectContaining({ - requestId: 'prebid-request-id', - bidderCode: 'aps', - ad: '', - trustedServerRenderer: renderer, - }) - ); - }); - - it('drops an APS bid whose renderer fails admission validation', () => { - const result = auctionBidsToPrebidBids( - [ - { - impid: 'div-aps', - adm: '', - renderer: { ...apsRenderer(), aaxResponse: 'invalid' }, - price: 1.23, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'fictional-creative-id', - adomain: [], - }, - ], - [{ adUnitCode: 'div-aps', bidId: 'prebid-request-id' }], - true - ); - - expect(result).toEqual([]); - }); - - it('falls back to impid when no matching bidRequest found', () => { - const auctionBids: AuctionBid[] = [ - { - impid: 'div-gpt-2', - adm: '
Ad2
', - price: 2.0, - width: 728, - height: 90, - seat: 'rubicon', - creativeId: 'cr-456', - adomain: [], - }, - ]; - - const result = auctionBidsToPrebidBids(auctionBids, [], true); - - expect(result).toHaveLength(1); - expect(result[0].requestId).toBe('div-gpt-2'); - expect(result[0].cpm).toBe(2.0); - }); - - it('handles multiple bids across different impids', () => { - const auctionBids: AuctionBid[] = [ - { - impid: 'slot-a', - adm: '
A
', - price: 1.0, - width: 300, - height: 250, - seat: 'bidderA', - creativeId: 'cr-a', - adomain: [], - }, - { - impid: 'slot-b', - adm: '
B
', - price: 2.0, - width: 728, - height: 90, - seat: 'bidderB', - creativeId: 'cr-b', - adomain: ['b.com'], - }, - ]; - const bidRequests = [ - { adUnitCode: 'slot-a', bidId: 'req-a' }, - { adUnitCode: 'slot-b', bidId: 'req-b' }, - ]; - - const result = auctionBidsToPrebidBids(auctionBids, bidRequests, true); - - expect(result).toHaveLength(2); - expect(result[0].requestId).toBe('req-a'); - expect(result[1].requestId).toBe('req-b'); - }); -}); - -describe('prebid/installPrebidNpm', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset requestBids to the mock so each test starts fresh - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - mockGetConfig.mockReset(); - document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - delete testWindow.__tsjs_prebid; - delete testWindow.__tsjs_prebid_diagnostics; - delete testWindow.tsjs; - delete mockPbjs['__tsApsBidResponseListenerInstalled']; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('registers the trustedServer bid adapter', () => { - installPrebidNpm(); - - expect(mockRegisterBidAdapter).toHaveBeenCalledTimes(1); - expect(mockRegisterBidAdapter).toHaveBeenCalledWith( - undefined, - 'trustedServer', - expect.objectContaining({ - code: 'trustedServer', - supportedMediaTypes: ['banner'], - isBidRequestValid: expect.any(Function), - buildRequests: expect.any(Function), - interpretResponse: expect.any(Function), - }) - ); - }); - - it('registers accepted APS descriptors under Prebid generated ad IDs', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'prebid-generated-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - trustedServerRenderer: renderer, - }); - - const entry = testWindow.tsjs?.apsPrebidRenderers?.['prebid-generated-ad-id']; - expect(entry).toEqual( - expect.objectContaining({ - adUnitCode: 'div-aps', - renderer, - expiresAt: expect.any(Number), - markUsed: expect.any(Function), - }) - ); - - entry?.markUsed(); - expect(mockMarkWinningBidAsUsed).toHaveBeenCalledWith({ - adId: 'prebid-generated-ad-id', - events: true, - }); - }); - - it('makes failed APS renderer registrations ineligible when zero-CPM bids are allowed', () => { - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - const malformedBid: Record = { - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'malformed-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - cpm: 1.23, - trustedServerRenderer: { ...apsRenderer(), aaxResponse: 'invalid' }, - }; - bidResponseListener!(malformedBid); - bidResponseListener!({ - adapterCode: 'publisherAdapter', - bidderCode: 'aps', - adId: 'foreign-ad-id', - adUnitCode: 'div-aps', - trustedServerRenderer: apsRenderer(), - }); - - expect(testWindow.tsjs?.apsPrebidRenderers?.['malformed-ad-id']).toBeUndefined(); - expect(testWindow.tsjs?.apsPrebidRenderers?.['foreign-ad-id']).toBeUndefined(); - expect(malformedBid).not.toHaveProperty('trustedServerRenderer'); - // Prebid's allowZeroCpmBids path still requires cpm >= 0. - expect(malformedBid['cpm']).toBe(-1); - expect(warnSpy).toHaveBeenCalledWith( - '[tsjs-prebid] rejected APS renderer capability that failed registration' - ); - }); - - it('registers APS renderer via meta when Prebid strips the custom top-level field', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - const [built] = auctionBidsToPrebidBids( - [ - { - impid: 'div-aps', - renderer, - price: 1.0, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'cr-aps', - adomain: [], - }, - ], - [{ adUnitCode: 'div-aps', bidId: 'req-strip' }], - true - ); - - // Prebid delivered the bid with the custom top-level field REMOVED — only - // first-class fields (requestId, meta) survive normalization. - const delivered: Record = { - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'stripped-field-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - requestId: built.requestId, - meta: built.meta, - }; - bidResponseListener!(delivered); - - const entry = testWindow.tsjs?.apsPrebidRenderers?.['stripped-field-ad-id']; - expect(entry).toEqual( - expect.objectContaining({ adUnitCode: 'div-aps', renderer, markUsed: expect.any(Function) }) - ); - // The capability is scrubbed from the delivered bid after registration. - expect(delivered.meta).not.toHaveProperty('trustedServerRenderer'); - }); - - it('registers a distinct renderer for each of multiple APS bids on one imp', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - // Two APS bids for the same imp share a requestId; each built bid must carry - // its own descriptor so neither registration is lost. - const firstRenderer = { ...apsRenderer(), creativeId: 'cr-aps-first' }; - const secondRenderer = { ...apsRenderer(), creativeId: 'cr-aps-second' }; - const sharedBid = { - impid: 'div-aps', - price: 1.0, - width: 300, - height: 250, - seat: 'aps', - adomain: [], - }; - const built = auctionBidsToPrebidBids( - [ - { ...sharedBid, renderer: firstRenderer, creativeId: 'cr-aps-first' }, - { ...sharedBid, renderer: secondRenderer, creativeId: 'cr-aps-second' }, - ], - [{ adUnitCode: 'div-aps', bidId: 'req-shared' }], - true - ); - expect(built).toHaveLength(2); - - for (const [index, bid] of built.entries()) { - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: `shared-imp-ad-id-${index}`, - adUnitCode: 'div-aps', - ttl: 300, - requestId: bid.requestId, - meta: bid.meta, - }); - } - - const registry = testWindow.tsjs?.apsPrebidRenderers; - expect(registry?.['shared-imp-ad-id-0']).toEqual( - expect.objectContaining({ renderer: firstRenderer }) - ); - expect(registry?.['shared-imp-ad-id-1']).toEqual( - expect.objectContaining({ renderer: secondRenderer }) - ); - }); - - it('does not register anything for a stripped bid that carries no meta descriptor', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - // First bid registers through the surviving custom-field path. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'surviving-field-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - requestId: 'req-reused', - trustedServerRenderer: apsRenderer(), - }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['surviving-field-ad-id']).toBeDefined(); - - // A later field-stripped bid reusing the same requestId has no descriptor of its - // own, so no stale renderer may be registered for it. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'reused-request-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - requestId: 'req-reused', - meta: { advertiserDomains: [] }, - }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['reused-request-ad-id']).toBeUndefined(); - }); - - it('registers and scrubs on bidAccepted before later events can observe the descriptor', () => { - installPrebidNpm(); - - const bidAcceptedListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidAccepted' - )?.[1] as ((bid: Record) => void) | undefined; - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidAcceptedListener).toBeTypeOf('function'); - expect(bidResponseListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - const [built] = auctionBidsToPrebidBids( - [ - { - impid: 'div-aps', - renderer, - price: 1.0, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'cr-aps', - adomain: [], - }, - ], - [{ adUnitCode: 'div-aps', bidId: 'req-accepted' }], - true - ); - - // Prebid emits bidAccepted and bidResponse with the same in-place-mutated - // bid object; the bidAccepted pass must register and scrub both carriers. - const accepted: Record = { - ...built, - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'accepted-ad-id', - adUnitCode: 'div-aps', - }; - bidAcceptedListener!(accepted); - - expect(testWindow.tsjs?.apsPrebidRenderers?.['accepted-ad-id']).toEqual( - expect.objectContaining({ adUnitCode: 'div-aps', renderer }) - ); - expect(accepted).not.toHaveProperty('trustedServerRenderer'); - expect(accepted.meta).not.toHaveProperty('trustedServerRenderer'); - - // The later bidResponse pass sees the already-scrubbed object and no-ops. - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - bidResponseListener!(accepted); - expect(testWindow.tsjs?.apsPrebidRenderers?.['accepted-ad-id']).toEqual( - expect.objectContaining({ renderer }) - ); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - it('tolerates a non-object meta value on the bid', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - // A module overwrote meta with a string and there is no top-level field: - // nothing registers and nothing throws. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'corrupt-meta-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - meta: 'corrupted', - }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['corrupt-meta-ad-id']).toBeUndefined(); - - // With a surviving top-level field the corrupt meta must not block registration. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'corrupt-meta-with-field-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - meta: 'corrupted', - trustedServerRenderer: apsRenderer(), - }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['corrupt-meta-with-field-ad-id']).toBeDefined(); - }); - - it('does not register malformed or non-trusted APS renderer capabilities', () => { - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - const malformedBid: Record = { - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'malformed-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - trustedServerRenderer: { ...apsRenderer(), aaxResponse: 'invalid' }, - }; - bidResponseListener!(malformedBid); - bidResponseListener!({ - adapterCode: 'publisherAdapter', - bidderCode: 'aps', - adId: 'foreign-ad-id', - adUnitCode: 'div-aps', - trustedServerRenderer: apsRenderer(), - }); - - expect(testWindow.tsjs?.apsPrebidRenderers?.['malformed-ad-id']).toBeUndefined(); - expect(testWindow.tsjs?.apsPrebidRenderers?.['foreign-ad-id']).toBeUndefined(); - expect(malformedBid).not.toHaveProperty('trustedServerRenderer'); - expect(warnSpy).toHaveBeenCalledWith( - '[tsjs-prebid] rejected APS renderer capability that failed registration' - ); - }); - - it('calls setConfig with debug=false by default', () => { - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith(expect.objectContaining({ debug: false })); - }); - - it('respects custom config values', () => { - installPrebidNpm({ - endpoint: '/custom/auction', - timeout: 2000, - debug: true, - }); - - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ debug: true, bidderTimeout: 2000 }) - ); - }); - - it('calls processQueue after configuration', () => { - installPrebidNpm(); - expect(mockProcessQueue).toHaveBeenCalledTimes(1); - }); - - it('reports the User ID modules selected by the generated bundle', () => { - installPrebidNpm(); - - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ - includedModules: ['sharedIdSystem'], - configuredUserIdNames: [], - missingConfiguredUserIdNames: [], - }); - }); - - it('refreshes late User ID config without repeating missing-module warnings', () => { - installPrebidNpm(); - mockGetConfig.mockImplementation((key?: string) => - key === 'userSync.userIds' ? [{ name: 'sharedId' }, { name: 'pairId' }] : {} - ); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - - mockPbjs.requestBids({ adUnits: [] }); - mockPbjs.requestBids({ adUnits: [] }); - - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ - includedModules: ['sharedIdSystem'], - configuredUserIdNames: ['pairId', 'sharedId'], - missingConfiguredUserIdNames: ['pairId'], - }); - expect( - warnSpy.mock.calls.filter(([message]) => String(message).includes('"pairId"')) - ).toHaveLength(1); - }); - - it('returns the pbjs instance', () => { - const result = installPrebidNpm(); - expect(result).toBe(mockPbjs); - }); - - it('installs only once per page via the __tsjsPrebidShimInstalled sentinel', () => { - const first = installPrebidNpm(); - const wrappedRequestBids = mockPbjs.requestBids; - const second = installPrebidNpm(); - - expect(second).toBe(first); - expect(mockRegisterBidAdapter).toHaveBeenCalledTimes(1); - expect(mockPbjs.requestBids).toBe(wrappedRequestBids); - expect(testWindow.__tsjsPrebidShimInstalled).toBe(true); - }); - - it('warns once about an unstamped User ID manifest instead of once per module', () => { - delete testWindow.__tsjs_prebid_bundle; - mockGetConfig.mockImplementation((key?: string) => - key === 'userSync.userIds' ? [{ name: 'sharedId' }, { name: 'pairId' }] : {} - ); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - - installPrebidNpm(); - mockPbjs.requestBids({ adUnits: [] }); - - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ - includedModules: [], - configuredUserIdNames: ['pairId', 'sharedId'], - missingConfiguredUserIdNames: [], - }); - const manifestWarnings = warnSpy.mock.calls.filter(([message]) => - String(message).includes('did not stamp a User ID module manifest') - ); - expect(manifestWarnings).toHaveLength(1); - const moduleWarnings = warnSpy.mock.calls.filter(([message]) => - String(message).includes('is not included in the external bundle') - ); - expect(moduleWarnings).toHaveLength(0); - - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - describe('adapter spec', () => { - function getAdapterSpec(): TestAdapterSpec { - installPrebidNpm(); - return mockRegisterBidAdapter.mock.calls[0][2] as TestAdapterSpec; - } - - it('isBidRequestValid always returns true', () => { - const spec = getAdapterSpec(); - expect(spec.isBidRequestValid({})).toBe(true); - }); - - it('buildRequests creates a POST request to /auction', () => { - const spec = getAdapterSpec(); - const bidRequests = [ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]; - - const result = spec.buildRequests(bidRequests); - - expect(result.method).toBe('POST'); - expect(result.url).toBe('/auction'); - expect(result.options).toEqual({ contentType: 'application/json' }); - - const payload = JSON.parse(result.data); - expect(payload.adUnits).toHaveLength(1); - expect(payload.adUnits[0].code).toBe('div-gpt-1'); - expect(payload.eids).toBeUndefined(); - }); - - it('buildRequests includes current Prebid EIDs in the /auction payload', () => { - const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'id5-sync.com', - uids: [{ id: 'ID5_abc', atype: 1 }], - }, - { - source: 'sharedid.org', - uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], - }, - { - source: 'google.com', - uids: [{ id: 'pair_123', atype: 571187 }], - }, - ]); - - const result = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); - - const payload = JSON.parse(result.data); - expect(payload.eids).toEqual([ - { - source: 'id5-sync.com', - uids: [{ id: 'ID5_abc', atype: 1 }], - }, - { - source: 'sharedid.org', - uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], - }, - { - source: 'google.com', - uids: [{ id: 'pair_123', atype: 571187 }], - }, - ]); - }); - - it('buildRequests clears stale ts-eids cookie when current Prebid EIDs are absent', () => { - const spec = getAdapterSpec(); - document.cookie = 'ts-eids=stale-value'; - mockGetUserIdsAsEids.mockReturnValue([]); - - spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); - - expect(document.cookie).toBe(''); - }); - - it('buildRequests preserves uid ext and sanitizes invalid atype values', () => { - const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'adserver.org', - uids: [ - { - id: 'uid-with-ext', - atype: 1, - ext: { provider: 'liveintent.com', rtiPartner: 'TDID' }, - }, - { - id: 'uid-bad-atype', - atype: 2_147_483_648, - ext: { keep: true }, - }, - { - id: 'uid-float-atype', - atype: 1.5, - }, - ], - }, - ]); - - const result = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); - - const payload = JSON.parse(result.data); - expect(payload.eids).toEqual([ - { - source: 'adserver.org', - uids: [ - { - id: 'uid-with-ext', - atype: 1, - ext: { provider: 'liveintent.com', rtiPartner: 'TDID' }, - }, - { - id: 'uid-bad-atype', - ext: { keep: true }, - }, - { - id: 'uid-float-atype', - }, - ], - }, - ]); - }); - - it('buildRequests uses custom endpoint when configured', () => { - mockRegisterBidAdapter.mockClear(); - installPrebidNpm({ endpoint: '/custom/auction' }); - const spec = mockRegisterBidAdapter.mock.calls[0][2]; - - const result = spec.buildRequests([ - { - adUnitCode: 'slot1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - - expect(result.url).toBe('/custom/auction'); - }); - - it('interpretResponse parses seatbid and returns Prebid bids', () => { - const spec = getAdapterSpec(); - - const built = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidId: 'bid-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - - const serverResponse = { - body: { - seatbid: [ - { - seat: 'appnexus', - bid: [ - { - impid: 'div-gpt-1', - price: 4.5, - adm: '
Creative
', - w: 300, - h: 250, - crid: 'cr-789', - adomain: ['advertiser.com'], - }, - ], - }, - ], - }, - }; - - const bids = spec.interpretResponse(serverResponse, built); - - expect(bids).toHaveLength(1); - expect(bids[0]).toEqual( - expect.objectContaining({ - requestId: 'bid-1', - cpm: 4.5, - width: 300, - height: 250, - ad: '
Creative
', - currency: 'USD', - netRevenue: true, - bidderCode: 'appnexus', - }) - ); - }); - - it('interpretResponse handles empty/missing seatbid', () => { - const spec = getAdapterSpec(); - const built = spec.buildRequests([]); - - expect(spec.interpretResponse({ body: {} }, built)).toEqual([]); - expect(spec.interpretResponse({ body: null }, built)).toEqual([]); - expect(spec.interpretResponse({}, built)).toEqual([]); - }); - - it('keeps request mapping isolated across overlapping auctions', () => { - const spec = getAdapterSpec(); - - const requestA = spec.buildRequests([ - { - adUnitCode: 'slot-a', - bidId: 'bid-a', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - const requestB = spec.buildRequests([ - { - adUnitCode: 'slot-b', - bidId: 'bid-b', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - - const responseA = { - body: { - seatbid: [ - { - seat: 'appnexus', - bid: [{ impid: 'slot-a', price: 1.1, adm: '
A
', w: 300, h: 250 }], - }, - ], - }, - }; - const responseB = { - body: { - seatbid: [ - { - seat: 'rubicon', - bid: [{ impid: 'slot-b', price: 2.2, adm: '
B
', w: 300, h: 250 }], - }, - ], - }, - }; - - const bidsA = spec.interpretResponse(responseA, requestA); - const bidsB = spec.interpretResponse(responseB, requestB); - - expect(bidsA[0].requestId).toBe('bid-a'); - expect(bidsB[0].requestId).toBe('bid-b'); - }); - }); - - describe('requestBids shim', () => { - it('injects trustedServer bidder into every ad unit', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { bids: [{ bidder: 'appnexus', params: {} }] }, - { bids: [{ bidder: 'rubicon', params: {} }] }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // Each ad unit should have trustedServer added - for (const unit of adUnits) { - const hasTsBidder = unit.bids.some((b: TestBid) => b.bidder === 'trustedServer'); - expect(hasTsBidder).toBe(true); - } - - const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); - expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: {} }); - expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); - expect(adUnits[1].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); - - // Should call through to original requestBids - expect(mockRequestBids).toHaveBeenCalled(); - }); - - it('does not duplicate trustedServer if already present', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [{ bids: [{ bidder: 'trustedServer', params: {} }] }]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsCount = adUnits[0].bids.filter((b: TestBid) => b.bidder === 'trustedServer').length; - expect(tsCount).toBe(1); - }); - - it('captures per-bidder params on trustedServer bid', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); - expect(trustedServerBid).toBeDefined(); - expect(trustedServerBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); - }); - - it('preserves captured bidder params when requestBids runs twice on the same ad unit', () => { - const pbjs = installPrebidNpm(); - - // First auction: inline server-side params supplied by the publisher. - const adUnits = [ - { - code: 'div-1', - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // Second auction (refresh/re-auction) with the SAME ad unit object: the - // server-side bidder entries were already pruned, so the shim must not - // overwrite the captured params with an empty object. - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const trustedServerBid = adUnits[0].bids.find( - (b: TestBid) => b.bidder === 'trustedServer' - ) as TestBid; - expect(trustedServerBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - }); - - it('adds bids array to ad units that have none', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [{ code: 'div-1' }] as TestAdUnit[]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - expect(adUnits[0].bids).toHaveLength(1); - expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); - }); - - it('normalizes a truthy non-array bids value without throwing', () => { - const pbjs = installPrebidNpm(); - const adUnits = [ - { code: 'example-malformed-slot', bids: { malformed: true } }, - ] as TestAdUnit[]; - - expect(() => pbjs.requestBids({ adUnits } as unknown as RequestBidsArg)).not.toThrow(); - - expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); - }); - - it('includes zone from mediaTypes.banner.name in trustedServer params', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - code: 'ad-header-0', - mediaTypes: { banner: { name: 'header', sizes: [[728, 90]] } }, - bids: [{ bidder: 'kargo', params: { placementId: '_abc' } }], - }, - { - code: 'ad-fixed_bottom-0', - mediaTypes: { banner: { name: 'fixed_bottom', sizes: [[728, 90]] } }, - bids: [{ bidder: 'kargo', params: { placementId: '_def' } }], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid0 = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid0.params.zone).toBe('header'); - - const tsBid1 = adUnits[1].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid1.params.zone).toBe('fixed_bottom'); - }); - - it('omits zone when mediaTypes.banner.name is not set', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - code: 'ad-header-0', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [{ bidder: 'appnexus', params: {} }], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.zone).toBeUndefined(); - }); - - it('omits zone when ad unit has no mediaTypes', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [{ bids: [{ bidder: 'rubicon', params: {} }] }]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.zone).toBeUndefined(); - }); - - it('clears stale zone when existing trustedServer bid is reused', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - code: 'ad-header-0', - mediaTypes: { banner: { name: 'header', sizes: [[300, 250]] } }, - bids: [ - { bidder: 'trustedServer', params: { custom: 'keep' } }, - { bidder: 'kargo', params: { placementId: '_abc' } }, - ], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - let tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.zone).toBe('header'); - expect(tsBid.params.custom).toBe('keep'); - - delete adUnits[0].mediaTypes.banner.name; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.zone).toBeUndefined(); - expect(tsBid.params.custom).toBe('keep'); - }); - - it('falls back to pbjs.adUnits when requestObj has no adUnits', () => { - const pbjs = installPrebidNpm(); - - mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }] as TestAdUnit[]; - pbjs.requestBids({} as RequestBidsArg); - - const hasTsBidder = (mockPbjs.adUnits[0].bids ?? []).some( - (b: TestBid) => b.bidder === 'trustedServer' - ); - expect(hasTsBidder).toBe(true); - }); - - it('syncs a structured ts-eids cookie after bidsBackHandler', () => { - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'sharedid.org', - uids: [ - { id: 'shared_123', atype: 3 }, - { id: 'shared_456', ext: { provider: 'example' } }, - ], - }, - ]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - expect(JSON.parse(atob(cookieValue!))).toEqual([ - { - source: 'sharedid.org', - uids: [ - { id: 'shared_123', atype: 3 }, - { id: 'shared_456', ext: { provider: 'example' } }, - ], - }, - ]); - }); - - it('clears ts-eids cookie after bidsBackHandler when no current EIDs remain', () => { - document.cookie = `ts-eids=${btoa(JSON.stringify([{ source: 'sharedid.org', uids: [{ id: 'stale' }] }]))}`; - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - mockGetUserIdsAsEids.mockReturnValue([]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - expect(document.cookie).toBe(''); - }); - }); -}); - -describe('prebid/installPrebidNpm with server-injected config', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - delete testWindow.__tsjs_prebid; - }); - - afterEach(() => { - delete testWindow.__tsjs_prebid; - }); - - it('reads timeout and debug from window.__tsjs_prebid', () => { - testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; - - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ debug: true, bidderTimeout: 1500 }) - ); - }); - - it('explicit config overrides server-injected values', () => { - testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; - - installPrebidNpm({ timeout: 3000, debug: false }); - - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ debug: false, bidderTimeout: 3000 }) - ); - }); - - it('works with no config argument and no injected config', () => { - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith(expect.objectContaining({ debug: false })); - expect(mockProcessQueue).toHaveBeenCalled(); - }); -}); - -describe('prebid/installRefreshHandler', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRequestBids.mockReset(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockPbjs.setTargetingForGPTAsync = undefined; - testWindow.tsjs = undefined; - delete testWindow.googletag; - delete testWindow.__tsjs_prebid; - }); - - afterEach(() => { - testWindow.tsjs = undefined; - delete testWindow.googletag; - delete testWindow.__tsjs_prebid; - }); - - it('builds refresh ad units from injected slot metadata', () => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [ - [970, 250], - [728, 90], - ], - targeting: { zone: 'homepage', pos: 'atf' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 750, - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - mediaTypes: { - banner: { - name: 'homepage', - sizes: [ - [970, 250], - [728, 90], - ], - }, - }, - bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }], - }), - ], - }) - ); - }); - - it('resolves the exact slot when div_ids share a prefix', () => { - // Regression: a single find() with a startsWith() clause returned the - // first slot whose div_id is a prefix of the element id. With div_ids - // "div-ad" and "div-ad-header", refreshing the "div-ad-header" element - // must resolve to the header slot, not the shorter prefix slot. - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'prefix_ad', - gam_unit_path: '/123/prefix', - div_id: 'div-ad', - formats: [[300, 250]], - targeting: { zone: 'prefix' }, - }, - { - id: 'header_ad', - gam_unit_path: '/123/header', - div_id: 'div-ad-header', - formats: [[970, 250]], - targeting: { zone: 'header' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-header', - mediaTypes: { - banner: { - name: 'header', - sizes: [[970, 250]], - }, - }, - }), - ], - }) - ); - }); - - it('scopes the GPT targeting call to the refreshed slot code', () => { - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - // Run the bidsBackHandler synchronously so the targeting call fires. - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - const originalRefresh = vi.fn(); - // Only the header slot is refreshed; the footer slot must be untouched. - const headerSlot = { - getSlotElementId: vi.fn(() => 'div-ad-header'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn().mockReturnThis(), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [headerSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'header_ad', - gam_unit_path: '/123/header', - div_id: 'div-ad-header', - formats: [[728, 90]], - targeting: { zone: 'header' }, - }, - { - id: 'footer_ad', - gam_unit_path: '/123/footer', - div_id: 'div-ad-footer', - formats: [[728, 90]], - targeting: { zone: 'footer' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh([headerSlot]); - - expect(setTargetingForGPTAsync).toHaveBeenCalledTimes(1); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['div-ad-header']); - expect(originalRefresh).toHaveBeenCalledWith([headerSlot], undefined); - - mockPbjs.setTargetingForGPTAsync = undefined; - }); - - it('includes configured client-side bidders in refresh ad units', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - // Original publisher ad unit carries a client-side rubicon bid. - mockPbjs.adUnits = [ - { - code: 'div-ad-homepage-header', - bids: [ - { bidder: 'trustedServer', params: {} }, - { bidder: 'rubicon', params: { accountId: 1, siteId: 2, zoneId: 3 } }, - ], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - bids: [ - { bidder: 'trustedServer', params: { zone: 'homepage' } }, - { bidder: 'rubicon', params: { accountId: 1, siteId: 2, zoneId: 3 } }, - ], - }), - ], - }) - ); - - delete testWindow.__tsjs_prebid; - mockPbjs.adUnits = []; - }); - - it('preserves raw server-side bidder params in refresh ad units', () => { - // Original publisher ad unit carries an inline server-side appnexus bid that - // the initial auction has not yet folded into the trustedServer bid. - mockPbjs.adUnits = [ - { - code: 'div-ad-homepage-header', - bids: [{ bidder: 'appnexus', params: { placementId: 12345 } }], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - bids: [ - { - bidder: 'trustedServer', - params: { - zone: 'homepage', - bidderParams: { appnexus: { placementId: 12345 } }, - }, - }, - ], - }), - ], - }) - ); - - mockPbjs.adUnits = []; - }); - - it('recovers params and client-side bids for container-backed slots by injected div_id', () => { - // A TS-owned GPT slot may be defined on `${div_id}-container`, but the - // publisher's Prebid ad unit is keyed by the inner div_id. The synthetic - // refresh code stays the GPT element id (so GPT can match it), while params - // and client-side bids are recovered from the injected div_id candidate. - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - mockPbjs.adUnits = [ - { - code: 'div-ad-x', - bids: [ - { bidder: 'appnexus', params: { placementId: 12345 } }, - { bidder: 'rubicon', params: { accountId: 1 } }, - ], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-x-container'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'x_ad', - gam_unit_path: '/123/x', - div_id: 'div-ad-x', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - // Synthetic refresh code stays the GPT element id, not the div_id. - code: 'div-ad-x-container', - bids: [ - { - bidder: 'trustedServer', - params: { - zone: 'homepage', - bidderParams: { appnexus: { placementId: 12345 } }, - }, - }, - { bidder: 'rubicon', params: { accountId: 1 } }, - ], - }), - ], - }) - ); - - delete testWindow.__tsjs_prebid; - mockPbjs.adUnits = []; - }); - - it('recovers server-side bidder params already folded onto the original trustedServer bid', () => { - // After the initial auction, the requestBids shim has folded the publisher's - // server-side params into the original ad unit's trustedServer bid. A later - // refresh must still recover them by code. - mockPbjs.adUnits = [ - { - code: 'div-ad-homepage-header', - bids: [ - { - bidder: 'trustedServer', - params: { bidderParams: { appnexus: { placementId: 12345 } } }, - }, - ], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - bids: [ - { - bidder: 'trustedServer', - params: { - zone: 'homepage', - bidderParams: { appnexus: { placementId: 12345 } }, - }, - }, - ], - }), - ], - }) - ); - - mockPbjs.adUnits = []; - }); - - it('auctions refreshed TS initial slots and clears stale TS targeting before refresh', () => { - const originalRefresh = vi.fn(); - const clearTargeting = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn((key: string) => { - if (key === 'ts_initial') return ['1']; - if (key === 'zone') return ['homepage']; - return []; - }), - getSizes: vi.fn(() => [ - { getWidth: () => 970, getHeight: () => 250 }, - { getWidth: () => 728, getHeight: () => 90 }, - ]), - clearTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [ - [970, 250], - [728, 90], - ], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 750, - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - mediaTypes: { - banner: { - name: 'homepage', - sizes: [ - [970, 250], - [728, 90], - ], - }, - }, - bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }], - }), - ], - }) - ); - expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).not.toHaveBeenCalled(); - - const bidsBackHandler = mockRequestBids.mock.calls[0][0].bidsBackHandler; - bidsBackHandler(); - - expect(setTargetingForGPTAsync).toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([gptSlot], undefined); - }); - - it('passes an explicitly excluded path directly to GPT after clearing stale targeting', () => { - const originalRefresh = vi.fn(); - const clearTargeting = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-tracking'), - getAdUnitPath: vi.fn(() => '/123/trackingonly'), - getTargeting: vi.fn(() => []), - clearTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - const options = { changeCorrelator: false }; - - installRefreshHandler(750); - pubads.refresh([gptSlot], options); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledWith([gptSlot], options); - }); - - it('passes an all-excluded global refresh directly to GPT', () => { - const originalRefresh = vi.fn(); - const trackingSlot = { - getSlotElementId: vi.fn(() => 'div-ad-tracking'), - getAdUnitPath: vi.fn(() => '/123/trackingonly'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const measurementSlot = { - getSlotElementId: vi.fn(() => 'div-ad-measurement'), - getAdUnitPath: vi.fn(() => '/123/measurement-only'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const targetSlots = [trackingSlot, measurementSlot]; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => targetSlots), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly', '/measurement-only'], - }; - const options = { changeCorrelator: false }; - - installRefreshHandler(750); - pubads.refresh(undefined, options); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(trackingSlot.clearTargeting).toHaveBeenCalled(); - expect(measurementSlot.clearTargeting).toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith(undefined, options); - }); - - it('auctions eligible slots and refreshes every slot in a mixed global refresh', () => { - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - const originalRefresh = vi.fn(); - const displaySlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getAdUnitPath: vi.fn(() => '/123/content'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const trackingSlot = { - getSlotElementId: vi.fn(() => 'div-ad-tracking'), - getAdUnitPath: vi.fn(() => '/123/trackingonly'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const targetSlots = [displaySlot, trackingSlot]; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => targetSlots), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(displaySlot.clearTargeting).toHaveBeenCalled(); - expect(trackingSlot.clearTargeting).toHaveBeenCalled(); - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [expect.objectContaining({ code: 'div-ad-display' })], - }) - ); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['div-ad-display']); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); - - mockPbjs.setTargetingForGPTAsync = undefined; - }); - - it.each([ - ['a missing path getter', {}], - ['a non-string path', { getAdUnitPath: vi.fn(() => 123) }], - [ - 'a throwing path getter', - { - getAdUnitPath: vi.fn(() => { - throw new Error('path unavailable'); - }), - }, - ], - ])('fails open to an auction for %s', (_description, pathBehavior) => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getTargeting: vi.fn(() => []), - ...pathBehavior, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it.each([ - ['an empty suffix', ['']], - ['a non-array suffix list', {}], - ])('ignores %s from injected config and runs the refresh auction', (_description, suffixes) => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getAdUnitPath: vi.fn(() => '/123/content'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: suffixes }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it.each(['/123/TrackingOnly', '/123/trackingonly/'])( - 'uses literal case-sensitive suffix matching for %s', - (adUnitPath) => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getAdUnitPath: vi.fn(() => adUnitPath), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - } - ); - - it('passes the adInit internal refresh straight to GPT without a client-side auction', () => { - const originalRefresh = vi.fn(); - const clearTargeting = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - clearTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { adInitRefreshInProgress: true }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([gptSlot], undefined); - }); - - it('runs a client-side auction for publisher refreshes after adInit completes', () => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { adInitRefreshInProgress: false }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it('keeps nested Prebid refreshes Prebid-only and restores the diagnostics context', () => { - const listeners = new Map void>(); - const store = new GptDiagnosticsStore({ defer: () => undefined }); - const explicitSlot = { - getSlotElementId: () => 'nested-explicit', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const bareSlot = { - getSlotElementId: () => 'nested-bare', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - let throwRefresh = false; - let getSlots: () => object[] = () => []; - const originalRefresh = vi.fn((slots?: unknown[]) => { - for (const slot of slots ?? getSlots()) listeners.get('slotRequested')?.({ slot }); - if (throwRefresh) throw new Error('delegated refresh failed'); - return 'delegated refresh result'; - }); - const pubads = { - addEventListener: vi.fn((name: string, listener: (event: { slot: object }) => void) => { - listeners.set(name, listener); - }), - refresh: originalRefresh, - getSlots: vi.fn(() => [bareSlot]), - }; - getSlots = pubads.getSlots; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - - new GptDiagnosticsObserver(store).install(); - installRefreshHandler(750); - const pbjs = installPrebidNpm(); - - const prepareDelivery = (code: string) => { - mockRequestBids.mockImplementationOnce((options) => { - options.bidsBackHandler?.(); - }); - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: vi.fn(), - } as unknown as RequestBidsArg); - }; - - prepareDelivery('nested-explicit'); - expect(pubads.refresh([explicitSlot])).toBe('delegated refresh result'); - expect(store.snapshot().slots[0].requests[0].requestPath).toBe('prebid_refresh'); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - - prepareDelivery('nested-bare'); - expect(pubads.refresh()).toBe('delegated refresh result'); - expect(store.snapshot().slots[1].requests[0].requestPath).toBe('prebid_refresh'); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - - prepareDelivery('nested-explicit'); - throwRefresh = true; - expect(() => pubads.refresh([explicitSlot])).toThrow('delegated refresh failed'); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - - testWindow.tsjs = { - adInitRefreshInProgress: true, - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - throwRefresh = false; - expect(pubads.refresh([explicitSlot])).toBe('delegated refresh result'); - expect(store.snapshot().slots[0].requests[2].requestPath).toBe('unattributed'); - }); - - it.each([ - { order: 'diagnostics observer first', diagnosticsFirst: true, expectedPath: 'prebid_refresh' }, - { order: 'Prebid wrapper first', diagnosticsFirst: false, expectedPath: 'competing' }, - ])( - 'attributes a Prebid-consumed refresh as $expectedPath when installed with the $order', - ({ diagnosticsFirst, expectedPath }) => { - const listeners = new Map void>(); - const store = new GptDiagnosticsStore({ defer: () => undefined }); - const slot = { - getSlotElementId: () => 'install-order', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const originalRefresh = vi.fn((slots?: unknown[]) => { - for (const refreshed of slots ?? []) listeners.get('slotRequested')?.({ slot: refreshed }); - return 'delegated refresh result'; - }); - const pubads = { - addEventListener: vi.fn((name: string, listener: (event: { slot: object }) => void) => { - listeners.set(name, listener); - }), - refresh: originalRefresh as (slots?: unknown[], opts?: unknown) => unknown, - getSlots: vi.fn(() => [slot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - - // Only the bundle evaluation order enforces this today, so pin both - // outcomes: the diagnostics wrapper must sit inside the Prebid one to see - // the dispatch context that marks a refresh as Prebid's. - if (diagnosticsFirst) { - new GptDiagnosticsObserver(store).install(); - installRefreshHandler(750); - } else { - installRefreshHandler(750); - new GptDiagnosticsObserver(store).install(); - } - const pbjs = installPrebidNpm(); - mockRequestBids.mockImplementationOnce((options) => options.bidsBackHandler?.()); - pbjs.requestBids({ - adUnits: [{ code: 'install-order', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: vi.fn(), - } as unknown as RequestBidsArg); - - pubads.refresh([slot]); - - expect(store.snapshot().slots[0].requests[0].requestPath).toBe(expectedPath); - } - ); - - it('keeps the outer dispatch context set across a nested Prebid refresh', () => { - const store = new GptDiagnosticsStore({ defer: () => undefined }); - const slot = { - getSlotElementId: () => 'nested-reentrant', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const contextAfterInner: Array = []; - let reentered = false; - const originalRefresh = vi.fn(() => { - if (!reentered) { - reentered = true; - pubads.refresh([slot]); - contextAfterInner.push( - (testWindow.tsjs as { prebidRefreshDispatchInProgress?: boolean }) - .prebidRefreshDispatchInProgress - ); - } - return 'delegated refresh result'; - }); - const pubads = { - refresh: originalRefresh as (slots?: unknown[], opts?: unknown) => unknown, - getSlots: vi.fn(() => [slot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - - const pbjs = installPrebidNpm(); - installRefreshHandler(750); - mockRequestBids.mockImplementation((options) => options.bidsBackHandler?.()); - pbjs.requestBids({ - adUnits: [{ code: 'nested-reentrant', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: vi.fn(), - } as unknown as RequestBidsArg); - - expect(pubads.refresh([slot])).toBe('delegated refresh result'); - // The inner dispatch owns the flag while it runs and must hand it back, or - // the observer would stop attributing every later publisher refresh. - expect(contextAfterInner).toEqual([true]); - expect( - originalRefresh, - 'the nested refresh must reach the delegated call' - ).toHaveBeenCalledTimes(2); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - }); - - it('restores diagnostics context when its setter mutates and then throws', () => { - const slot = { - getSlotElementId: () => 'mutating-context-setter', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const originalRefresh = vi.fn(); - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [slot]), - }; - const contextTarget: Record = {}; - let throwAfterMutation = true; - testWindow.tsjs = new Proxy(contextTarget, { - set(target, property, value) { - Reflect.set(target, property, value); - if (property === 'prebidRefreshDispatchInProgress' && throwAfterMutation) { - throwAfterMutation = false; - throw new Error('example mutating context setter failure'); - } - return true; - }, - }); - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - mockRequestBids.mockImplementation((options) => options.bidsBackHandler?.()); - - installPrebidNpm(); - installRefreshHandler(750); - pubads.refresh([slot]); - pubads.refresh([slot]); - - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect( - Object.prototype.hasOwnProperty.call(contextTarget, 'prebidRefreshDispatchInProgress') - ).toBe(false); - }); -}); - -describe('prebid publisher snapshots and delivery refreshes', () => { - let deliveryAdIds = new WeakMap(); - let installedGptSlots: Array> = []; - let auctionSequence = 0; - - beforeEach(() => { - vi.clearAllMocks(); - deliveryAdIds = new WeakMap(); - installedGptSlots = []; - auctionSequence = 0; - mockRequestBids.mockReset(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.removeAdUnit = mockRemoveAdUnit; - delete (mockPbjs as unknown as Record).__tsRemoveAdUnitWrapped; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - // By default the manifest declares all adapters compiled in. - (window as unknown as { __tsjs_prebid_bundle?: unknown }).__tsjs_prebid_bundle = - DEFAULT_BUNDLE_MANIFEST; - mockPbjs.setTargetingForGPTAsync = undefined; - delete testWindow.__tsjs_prebid; - testWindow.tsjs = undefined; - delete testWindow.googletag; - }); - - afterEach(() => { - delete testWindow.__tsjs_prebid; - testWindow.tsjs = undefined; - delete testWindow.googletag; - }); - - function installGpt(slots: Array>) { - installedGptSlots = slots; - for (const slot of slots) { - if (!slot || typeof slot !== 'object') continue; - const originalGetTargeting = slot.getTargeting?.bind(slot); - slot.getTargeting = (key: string) => { - const deliveryAdId = deliveryAdIds.get(slot); - if (key === 'hb_adid' && deliveryAdId) return [deliveryAdId]; - return originalGetTargeting?.(key) ?? []; - }; - } - - const originalRefresh = vi.fn(); - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => slots), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - installRefreshHandler(640); - return { originalRefresh, pubads }; - } - - function refreshAdUnitFromLastRequest(): - | (Record & { code?: string; bids?: TestBid[] }) - | undefined { - const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; - return lastCall?.[0]?.adUnits?.[0]; - } - - function completePublisherAuction( - opts?: { adUnits?: Array<{ code?: string }>; bidsBackHandler?: (...args: unknown[]) => void }, - options: { auctionId?: string; applyTargeting?: boolean } = {} - ): void { - const auctionId = options.auctionId ?? `example-auction-${auctionSequence++}`; - const bidResponses: Record> }> = {}; - - for (const unit of opts?.adUnits ?? []) { - if (!unit.code) continue; - const adId = `${auctionId}-${unit.code}`; - bidResponses[unit.code] = { - bids: [{ adId, adUnitCode: unit.code, auctionId }], - }; - if (options.applyTargeting !== false) { - const slot = installedGptSlots.find((candidate) => { - const elementId = candidate?.getSlotElementId?.(); - return elementId === unit.code || elementId === `${unit.code}-container`; - }); - if (slot) deliveryAdIds.set(slot, adId); - } - } - - opts?.bidsBackHandler?.(bidResponses, false, auctionId); - } - - function installPrebidRefreshDiagnostics( - implementation?: (slots: Array>) => void - ) { - const recordPrebidRefresh = vi.fn(implementation); - testWindow.tsjs = { gptDiagnosticsRecorder: { recordPrebidRefresh } }; - return recordPrebidRefresh; - } - - it('records a publisher delivery refresh immediately before its GPT request', () => { - const slot = { - getSlotElementId: () => 'example-delivery-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-delivery-marker', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('records a completed synthetic refresh immediately before its GPT request', () => { - const slot = { - getSlotElementId: () => 'example-synthetic-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('records every slot in a mixed SRA refresh before its GPT request', () => { - const deliverySlot = { - getSlotElementId: () => 'example-mixed-delivery-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const independentSlot = { - getSlotElementId: () => 'example-mixed-independent-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const targetSlots = [deliverySlot, independentSlot]; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt(targetSlots); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { - code: 'example-mixed-delivery-marker', - bids: [{ bidder: 'exampleServer', params: {} }], - }, - ], - bidsBackHandler: () => pubads.refresh(targetSlots), - } as unknown as RequestBidsArg); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith(targetSlots); - expect(recordPrebidRefresh.mock.calls[0][0][0]).toBe(deliverySlot); - expect(recordPrebidRefresh.mock.calls[0][0][1]).toBe(independentSlot); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(targetSlots, undefined); - }); - - it('records one synthetic timeout fallback before one GPT request', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-timeout-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation(() => undefined); - installPrebidNpm(); - - pubads.refresh([slot]); - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(640); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('records a caught synthetic auction failure before one GPT fallback request', () => { - const slot = { - getSlotElementId: () => 'example-failure-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation(() => { - throw new Error('example auction failure'); - }); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('does not record or refresh again for a late callback after timeout', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-late-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - let bidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - bidsBackHandler = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - vi.advanceTimersByTime(640); - bidsBackHandler?.(); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('does not record an adInit refresh bypass', () => { - const slot = { - getSlotElementId: () => 'example-adinit-marker-bypass', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = vi.fn(); - testWindow.tsjs = { - adInitRefreshInProgress: true, - gptDiagnosticsRecorder: { recordPrebidRefresh }, - }; - const { originalRefresh, pubads } = installGpt([slot]); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('does not record empty or invalid refresh passthroughs', () => { - const slot = { - getSlotElementId: () => 'example-invalid-marker-bypass', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - const invalidSlots = [slot, null]; - - pubads.refresh([]); - pubads.refresh(invalidSlots); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, invalidSlots, undefined); - }); - - it('does not record a bare refresh when GPT cannot resolve its slot list', () => { - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const originalRefresh = vi.fn(); - const pubads = { refresh: originalRefresh }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - installRefreshHandler(640); - - pubads.refresh(); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); - }); - - it('does not record while a synthetic refresh is still waiting for its auction', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-waiting-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - let bidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - bidsBackHandler = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - - bidsBackHandler?.(); - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('still refreshes with unchanged arguments when diagnostics throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshOptions = { changeCorrelator: false }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(() => { - throw new Error('example diagnostics failure'); - }); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot], refreshOptions); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], refreshOptions); - }); - - it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; - const runtimeInstance = 'example-runtime-instance'; - const code = `example-slot-${runtimeInstance}`; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [{ getWidth: () => 320, getHeight: () => 100 }], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - const firstParams = { placement: 'first' }; - const effectiveParams = { placement: 'effective' }; - - pbjs.requestBids({ - adUnits: [ - { - code, - mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, - bids: [ - { bidder: 'exampleServer', params: firstParams }, - { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, - { bidder: 'exampleServer', params: effectiveParams }, - { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, - ], - }, - ], - } as unknown as RequestBidsArg); - effectiveParams.placement = 'changed-after-auction'; - - pubads.refresh([slot]); - - expect(mockPbjs.adUnits).toEqual([]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(refreshAdUnitFromLastRequest()).toEqual({ - code, - mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, - bids: [ - { - bidder: 'trustedServer', - params: { - bidderParams: { exampleServer: { placement: 'effective' } }, - zone: 'example-zone', - }, - }, - { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, - { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, - ], - }); - }); - - it('isolates nested bidder-param objects and arrays from later publisher mutation', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; - const code = 'example-nested-params-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - const serverParams = { - placement: { - rules: [{ label: 'original-rule' }], - sizes: [300, 250], - }, - }; - const browserParams = { - groups: [{ values: ['original-value'] }], - }; - - pbjs.requestBids({ - adUnits: [ - { - code, - bids: [ - { bidder: 'exampleServer', params: serverParams }, - { bidder: 'exampleBrowser', params: browserParams }, - ], - }, - ], - } as unknown as RequestBidsArg); - serverParams.placement.rules[0].label = 'changed-rule'; - serverParams.placement.sizes.push(999); - browserParams.groups[0].values[0] = 'changed-value'; - - pubads.refresh([slot]); - - const expectedBids = [ - { - bidder: 'trustedServer', - params: { - bidderParams: { - exampleServer: { - placement: { - rules: [{ label: 'original-rule' }], - sizes: [300, 250], - }, - }, - }, - }, - }, - { - bidder: 'exampleBrowser', - params: { groups: [{ values: ['original-value'] }] }, - }, - ]; - const firstRefreshBids = refreshAdUnitFromLastRequest().bids; - expect(firstRefreshBids).toEqual(expectedBids); - - firstRefreshBids[0].params.bidderParams.exampleServer.placement.rules[0].label = - 'changed-refresh-rule'; - firstRefreshBids[0].params.bidderParams.exampleServer.placement.sizes.push(777); - firstRefreshBids[1].params.groups[0].values[0] = 'changed-refresh-value'; - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); - }); - - it('keeps snapshots across repeated synthetic refreshes and overwrites newer publisher config', () => { - const code = 'example-dynamic-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { - code, - mediaTypes: { banner: { name: 'example-zone-one', sizes: [[300, 250]] } }, - bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], - }, - ], - } as unknown as RequestBidsArg); - pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ - bidderParams: { exampleServer: { placement: 'one' } }, - zone: 'example-zone-one', - }); - - pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ - bidderParams: { exampleServer: { placement: 'one' } }, - zone: 'example-zone-one', - }); - - pbjs.requestBids({ - adUnits: [ - { - code, - mediaTypes: { banner: { name: 'example-zone-two', sizes: [[300, 250]] } }, - bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], - }, - ], - } as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ - bidderParams: { exampleServer: { placement: 'two' } }, - zone: 'example-zone-two', - }); - }); - - it('does not cross-contaminate dynamic-code snapshots and retains the global fallback', () => { - const slotOne = { - getSlotElementId: () => 'example-code-one', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const slotTwo = { - getSlotElementId: () => 'example-code-two', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const globalSlot = { - getSlotElementId: () => 'example-global-code', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slotOne, slotTwo, globalSlot]); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { - code: 'example-code-one', - bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], - }, - { - code: 'example-code-two', - bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], - }, - ], - } as unknown as RequestBidsArg); - mockPbjs.adUnits = [ - { - code: 'example-global-code', - bids: [{ bidder: 'exampleFallback', params: { placement: 'global' } }], - }, - ]; - - pubads.refresh([slotOne]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ - exampleServer: { placement: 'one' }, - }); - pubads.refresh([slotTwo]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ - exampleServer: { placement: 'two' }, - }); - pubads.refresh([globalSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ - exampleFallback: { placement: 'global' }, - }); - }); - - it('prefers a rich live unit when a fresh same-code request overwrites the snapshot with empty bids', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; - const code = 'example-live-rich-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const liveUnit = { - code, - bids: [ - { bidder: 'exampleServer', params: { placement: 'live-server' } }, - { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, - ], - }; - mockPbjs.adUnits = [liveUnit]; - const pbjs = installPrebidNpm(); - - pbjs.requestBids(); - pbjs.requestBids({ adUnits: [{ code, bids: [] }] } as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids).toEqual([ - { - bidder: 'trustedServer', - params: { bidderParams: { exampleServer: { placement: 'live-server' } } }, - }, - { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, - ]); - }); - - it('does not resurrect an older snapshot when the live unit is intentionally empty', () => { - const code = 'example-live-empty-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: { placement: 'snapshot' } }] }], - } as unknown as RequestBidsArg); - mockPbjs.adUnits = [{ code, bids: [] }]; - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids).toEqual([ - { bidder: 'trustedServer', params: { bidderParams: {} } }, - ]); - }); - - it('evicts snapshots with the matching removeAdUnit lifecycle', () => { - const codes = ['example-remove-one', 'example-remove-two', 'example-remove-all']; - const slots = codes.map((code) => ({ - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - })); - const { pubads } = installGpt(slots); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: codes.map((code) => ({ - code, - bids: [{ bidder: 'exampleServer', params: { placement: code } }], - })), - } as unknown as RequestBidsArg); - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit( - codes[0] - ); - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit([ - codes[1], - ]); - - pubads.refresh([slots[0]]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); - pubads.refresh([slots[1]]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); - pubads.refresh([slots[2]]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ - exampleServer: { placement: codes[2] }, - }); - - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit(); - pubads.refresh([slots[2]]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); - }); - - it('bounds snapshots with LRU eviction while retaining a recently refreshed entry', () => { - const capacity = 256; - const oldestCode = 'example-lru-0'; - const activeCode = `example-lru-${capacity - 1}`; - const oldestSlot = { - getSlotElementId: () => oldestCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const activeSlot = { - getSlotElementId: () => activeCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([oldestSlot, activeSlot]); - const pbjs = installPrebidNpm(); - - for (let index = 0; index < capacity; index += 1) { - pbjs.requestBids({ - adUnits: [ - { - code: `example-lru-${index}`, - bids: [{ bidder: 'exampleServer', params: { placement: index } }], - }, - ], - } as unknown as RequestBidsArg); - } - - pubads.refresh([activeSlot]); - pbjs.requestBids({ - adUnits: [ - { - code: `example-lru-${capacity}`, - bids: [{ bidder: 'exampleServer', params: { placement: capacity } }], - }, - ], - } as unknown as RequestBidsArg); - - pubads.refresh([oldestSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); - pubads.refresh([activeSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ - exampleServer: { placement: capacity - 1 }, - }); - }); - - it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { - const slotOne = { - getSlotElementId: () => 'example-covered-one', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const slotTwo = { - getSlotElementId: () => 'example-covered-two-container', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - testWindow.tsjs = { - adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], - }; - const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-covered-one', bids: [{ bidder: 'exampleServer', params: {} }] }, - { code: 'example-covered-two', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - pubads.refresh([slotOne]); - pubads.refresh([slotTwo]); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slotOne.clearTargeting).not.toHaveBeenCalled(); - expect(slotTwo.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slotOne], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); - }); - - it('registers delivery state for a publisher auction without a bidsBackHandler', () => { - const code = 'example-handlerless-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('preserves one mixed refresh request and its original options', () => { - const deliverySlot = { - getSlotElementId: () => 'example-sra-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const independentSlot = { - getSlotElementId: () => 'example-sra-independent', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshOptions = { changeCorrelator: true }; - const { originalRefresh, pubads } = installGpt([deliverySlot, independentSlot]); - let syntheticBidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - if (mockRequestBids.mock.calls.length === 1) { - completePublisherAuction(opts); - } else { - syntheticBidsBackHandler = opts.bidsBackHandler; - } - }); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: 'example-sra-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([deliverySlot, independentSlot], refreshOptions), - } as unknown as RequestBidsArg); - - expect(originalRefresh).not.toHaveBeenCalled(); - expect(independentSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - - syntheticBidsBackHandler?.(); - - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([deliverySlot, independentSlot], refreshOptions); - }); - - it('partitions a bare delivery refresh from an unmatched GPT slot', () => { - const coveredSlot = { - getSlotElementId: () => 'example-covered', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const gamOnlySlot = { - getSlotElementId: () => 'example-gam-only-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh(), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); - expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); - }); - - it('keeps explicit unrelated lists synthetic and partitions mixed delivery lists', () => { - const coveredSlot = { - getSlotElementId: () => 'example-covered', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const unrelatedSlot = { - getSlotElementId: () => 'example-unrelated', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - pubads.refresh([unrelatedSlot]); - pubads.refresh([coveredSlot, unrelatedSlot]); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect( - mockRequestBids.mock.calls[1][0].adUnits.map((unit: { code?: string }) => unit.code) - ).toEqual(['example-unrelated']); - expect( - mockRequestBids.mock.calls[2][0].adUnits.map((unit: { code?: string }) => unit.code) - ).toEqual(['example-unrelated']); - expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); - expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); - }); - - it('partitions four delivered slots from an unmatched explicit slot', () => { - const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ - getSlotElementId: () => `example-covered-${index}`, - getTargeting: () => [], - clearTargeting: vi.fn(), - })); - const gamOnlySlot = { - getSlotElementId: () => 'example-gam-only-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshSlots = [...coveredSlots, gamOnlySlot]; - const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: coveredSlots.map((_, index) => ({ - code: `example-covered-${index}`, - bids: [{ bidder: 'exampleServer', params: { placement: index } }], - })), - bidsBackHandler: () => pubads.refresh(refreshSlots), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - coveredSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); - expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); - }); - - it('expires an unconsumed publisher delivery before a later refresh', () => { - vi.useFakeTimers(); - try { - const code = 'example-expired-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - vi.advanceTimersByTime(5001); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('expires an unconsumed targeted delivery before a later refresh', () => { - vi.useFakeTimers(); - try { - const code = 'example-expired-targeted-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - vi.advanceTimersByTime(5001); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('correlates a targeted delivery refresh after more than one second without a timer race', () => { - vi.useFakeTimers(); - try { - const code = 'example-delayed-delivery'; - const auctionId = 'example-delayed-auction'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { auctionId, applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - setTimeout(() => { - deliveryAdIds.set(slot, `${auctionId}-${code}`); - pubads.refresh([slot]); - }, 1500); - }, - } as unknown as RequestBidsArg); - - vi.advanceTimersByTime(1500); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('correlates null and no-argument targeting with a custom GPT slot match', () => { - const code = 'example-custom-matched-code'; - const slot = { - getSlotElementId: () => 'example-different-gpt-slot', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - let auctionId = 'example-null-auction'; - const setTargetingForGPTAsync = vi.fn(() => { - deliveryAdIds.set(slot, `${auctionId}-${code}`); - }); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { auctionId, applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - ( - pbjs as unknown as { setTargetingForGPTAsync: (codes?: string[]) => void } - ).setTargetingForGPTAsync(null, () => () => true); - pubads.refresh([slot]); - }, - } as unknown as RequestBidsArg); - - auctionId = 'example-no-argument-auction'; - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - ( - pbjs as unknown as { setTargetingForGPTAsync: (codes?: string[]) => void } - ).setTargetingForGPTAsync(); - pubads.refresh([slot]); - }, - } as unknown as RequestBidsArg); - - expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(1, null, expect.any(Function)); - expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(2); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); - mockPbjs.setTargetingForGPTAsync = undefined; - }); - - it('correlates requested no-bid slots without manufacturing unrelated bid state', () => { - const slot = { - getSlotElementId: () => 'example-no-bid-delivery', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation( - (opts?: { bidsBackHandler?: (...args: unknown[]) => void }) => { - opts?.bidsBackHandler?.({ 'example-no-bid-delivery': { bids: [null, {}] } }, false, 'bad'); - } - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-no-bid-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('bounds code-only delivery correlation to one suppressed independent refresh', () => { - const code = 'example-code-only-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - // Model an initial impression rendered with display() after an auction - // that did not apply hb_adid targeting. Its code-only state is unconsumed. - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - }); - - it('does not use code fallback when a slot has an unmatched hb_adid', () => { - const code = 'example-stale-targeting'; - const slot = { - getSlotElementId: () => code, - getTargeting: (key: string) => (key === 'hb_adid' ? ['example-stale-ad-id'] : []), - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('uses an independent auction when a pending hb_adid exceeds the capacity bound', () => { - const capacity = 2048; - const code = 'example-capacity-delivery'; - const oldestAdId = 'example-capacity-ad-0'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => { - if (mockRequestBids.mock.calls.length === 1) { - opts.bidsBackHandler?.({ - [code]: { - bids: Array.from({ length: capacity + 1 }, (_, index) => ({ - adId: `example-capacity-ad-${index}`, - adUnitCode: code, - })), - }, - }); - return; - } - completePublisherAuction(opts); - }); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - deliveryAdIds.set(slot, oldestAdId); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('bypasses a mixed explicit delivery list spanning nested contexts', () => { - const outerSlot = { - getSlotElementId: () => 'example-outer-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const innerSlot = { - getSlotElementId: () => 'example-inner-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const gamOnlySlot = { - getSlotElementId: () => 'example-gam-only-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; - const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - pbjs.requestBids({ - adUnits: [ - { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh(refreshSlots), - } as unknown as RequestBidsArg); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); - }); - - it('correlates a microtask refresh by its requested code without targeting', async () => { - const slot = { - getSlotElementId: () => 'example-deferred-refresh', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - let deferredRefresh: Promise | undefined; - - pbjs.requestBids({ - adUnits: [ - { code: 'example-deferred-refresh', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - deferredRefresh = Promise.resolve().then(() => pubads.refresh([slot])); - }, - } as unknown as RequestBidsArg); - await deferredRefresh; - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('correlates targeting and refresh deferred together to a microtask', async () => { - const code = 'example-targeted-microtask'; - const auctionId = 'example-targeted-microtask-auction'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { auctionId, applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - let deferredRefresh: Promise | undefined; - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - deferredRefresh = Promise.resolve().then(() => { - deliveryAdIds.set(slot, `${auctionId}-${code}`); - pubads.refresh([slot]); - }); - }, - } as unknown as RequestBidsArg); - await deferredRefresh; - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('consumes all overlapping pending bids for the same ad-unit code', () => { - const code = 'example-overlapping-code'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - - deliveryAdIds.set(slot, `example-auction-0-${code}`); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); - }); - - it('filters invalid explicit entries without duplicating or leaking a valid delivery', () => { - const code = 'example-valid-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => - pubads.refresh([slot, undefined, null] as unknown as Array>), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot, undefined, null], undefined); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - }); - - it('does not mutate reused publisher request options', () => { - const code = 'example-reused-request'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - const request = { - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - }; - - pbjs.requestBids(request as unknown as RequestBidsArg); - pbjs.requestBids(request as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(request).not.toHaveProperty('bidsBackHandler'); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('falls back to one GPT refresh when a synthetic auction throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-refresh', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation(() => { - throw new Error('example synthetic failure'); - }); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(setTargetingForGPTAsync).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('applies targeting before falling back when a synthetic auction never calls back', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-missing-refresh-callback', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation(() => undefined); - installPrebidNpm(); - - pubads.refresh([slot]); - expect(originalRefresh).not.toHaveBeenCalled(); - vi.advanceTimersByTime(640); - - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-missing-refresh-callback']); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('applies fallback targeting once and ignores a late synthetic callback', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-late-refresh-callback', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - let syntheticBidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - syntheticBidsBackHandler = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - vi.advanceTimersByTime(640); - syntheticBidsBackHandler?.(); - - expect(setTargetingForGPTAsync).toHaveBeenCalledTimes(1); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-late-refresh-callback']); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('completes a synthetic refresh when targeting throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-targeting', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockPbjs.setTargetingForGPTAsync = vi.fn(() => { - throw new Error('example targeting failure'); - }); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('does not stack the removeAdUnit lifecycle wrapper across installation', () => { - const pbjs = installPrebidNpm(); - installPrebidNpm(); - - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit( - 'example-reinstalled-slot' - ); - - expect(mockRemoveAdUnit).toHaveBeenCalledTimes(1); - }); - - it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { - const outerSlot = { - getSlotElementId: () => 'example-outer-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const innerSlot = { - getSlotElementId: () => 'example-inner-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - pbjs.requestBids({ - adUnits: [ - { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh([innerSlot]), - } as unknown as RequestBidsArg); - pubads.refresh([outerSlot]); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [outerSlot], undefined); - }); - - it('cleans delivery context after a publisher callback throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-callback', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - expect(() => - pbjs.requestBids({ - adUnits: [ - { - code: 'example-throwing-callback', - bids: [{ bidder: 'exampleServer', params: {} }], - }, - ], - bidsBackHandler: () => { - throw new Error('example callback failure'); - }, - } as unknown as RequestBidsArg) - ).toThrow('example callback failure'); - - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - }); - - it('completes an internal synthetic refresh once without recursion', () => { - const slot = { - getSlotElementId: () => 'example-independent-refresh', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); -}); - -describe('prebid/client-side bidders', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - // By default the manifest declares all adapters compiled in. - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - delete testWindow.__tsjs_prebid; - }); - - afterEach(() => { - delete testWindow.__tsjs_prebid; - }); - - it('excludes client-side bidders from trustedServer bidderParams', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - { bidder: 'kargo', params: { placementId: 'k1' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid).toBeDefined(); - // rubicon should NOT be in bidderParams — it runs client-side - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - kargo: { placementId: 'k1' }, - }); - }); - - it('preserves client-side bidder bids as standalone entries', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // rubicon bid should remain untouched as a standalone entry - const rubiconBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'rubicon') as TestBid; - expect(rubiconBid).toBeDefined(); - expect(rubiconBid.params).toEqual({ accountId: 'abc' }); - expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); - }); - - it('handles multiple client-side bidders', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - { bidder: 'openx', params: { unit: '456' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - // Only appnexus should be in bidderParams - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - }); - - // Both client-side bidders should remain - expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'rubicon')).toBeDefined(); - expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'openx')).toBeDefined(); - expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); - }); - - it('behaves normally when no client-side bidders are configured', () => { - // No __tsjs_prebid at all — all bidders go server-side - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - }); - - it('behaves normally when client-side bidders list is empty', () => { - testWindow.__tsjs_prebid = { clientSideBidders: [] }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - }); - - it('still injects trustedServer when all bidders are client-side', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'appnexus'] }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'rubicon', params: { accountId: 'abc' } }, - { bidder: 'appnexus', params: { placementId: 123 } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // trustedServer should still be present (even with empty bidderParams) - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid).toBeDefined(); - expect(tsBid.params.bidderParams).toEqual({}); - }); - - it('logs error when a client-side bidder has no adapter in the external bundle', () => { - // rubicon is compiled into the external bundle, but openx is not - testWindow.__tsjs_prebid_bundle = { - ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['rubicon'], - bidderCodes: ['rubicon'], - }; - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - // Should log an error for the missing adapter. - // log.error() uses styled console output: console.error('%c[tsjs]%c ...:', style, reset, ...args) - // so the actual message is the 4th argument. - const errorCalls = errorSpy.mock.calls; - const hasOpenxError = errorCalls.some((args) => - args.some( - (a) => - typeof a === 'string' && - a.includes('client-side bidder "openx" has no adapter in the external Prebid bundle') - ) - ); - expect(hasOpenxError).toBe(true); - - // The error should point at the operator surface: the CLI config key, - // not the internal build script. - const pointsAtBundleConfig = errorCalls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('[integrations.prebid.bundle].adapters')) - ); - expect(pointsAtBundleConfig).toBe(true); - - // Should NOT log an error for the compiled-in adapter - const hasRubiconError = errorCalls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('client-side bidder "rubicon"')) - ); - expect(hasRubiconError).toBe(false); - - errorSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('accepts alias bidder codes stamped in bidderCodes', () => { - // The adf module registers adf plus the adform/adformOpenRTB aliases; - // the module-name list alone would flag them as missing. - testWindow.__tsjs_prebid_bundle = { - ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['adf'], - bidderCodes: ['adf', 'adform', 'adformOpenRTB'], - }; - testWindow.__tsjs_prebid = { clientSideBidders: ['adform'] }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some( - (a) => typeof a === 'string' && a.includes('has no adapter in the external Prebid bundle') - ) - ); - expect(hasAdapterError).toBe(false); - - errorSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('rejects a module file stem that is not a registered bidder code', () => { - // a1MediaBidAdapter.js registers a1media — configuring the file stem - // must be flagged even though the module itself is compiled in. - testWindow.__tsjs_prebid_bundle = { - ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['a1Media'], - bidderCodes: ['a1media'], - }; - testWindow.__tsjs_prebid = { clientSideBidders: ['a1Media'] }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some( - (a) => - typeof a === 'string' && - a.includes('client-side bidder "a1Media" has no adapter in the external Prebid bundle') - ) - ); - expect(hasAdapterError).toBe(true); - - errorSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('treats a malformed manifest as unstamped instead of throwing', () => { - // The manifest is a plain window global any page script can overwrite. - testWindow.__tsjs_prebid_bundle = { adapters: 'rubicon', userIdModules: 42 }; - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - expect(() => installPrebidNpm()).not.toThrow(); - - const hasManifestWarn = warnSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) - ); - expect(hasManifestWarn).toBe(true); - - warnSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('warns when the external bundle stamped no adapter manifest', () => { - delete testWindow.__tsjs_prebid_bundle; - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasManifestWarn = warnSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) - ); - expect(hasManifestWarn).toBe(true); - - warnSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('does not log errors when all client-side bidders have adapters', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some( - (a) => typeof a === 'string' && a.includes('has no adapter in the external Prebid bundle') - ) - ); - expect(hasAdapterError).toBe(false); - - errorSpy.mockRestore(); - }); -}); - -describe('prebid/self-init without the external bundle', () => { - afterEach(() => { - // Restore the module registry and the full mock global for later suites. - testWindow.pbjs = mockPbjs; - delete testWindow.googletag; - vi.resetModules(); - }); - - it('keeps basic Prebid enabled when only the APS lifecycle API is unavailable', async () => { - vi.resetModules(); - const registerBidAdapter = vi.fn(); - const onEvent = vi.fn(); - const originalRequestBids = vi.fn(); - const compatiblePbjs = { - ...mockPbjs, - registerBidAdapter, - onEvent, - requestBids: originalRequestBids, - markWinningBidAsUsed: undefined, - que: [] as Array<() => void>, - cmd: [] as Array<() => void>, - }; - testWindow.pbjs = compatiblePbjs; - const pubads = { refresh: vi.fn() }; - const cmdPush = vi.fn((callback: () => void) => callback()); - testWindow.googletag = { cmd: { push: cmdPush }, pubads: () => pubads }; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await import('../../../src/integrations/prebid/index'); - - expect(registerBidAdapter).toHaveBeenCalledTimes(1); - expect(compatiblePbjs.requestBids).not.toBe(originalRequestBids); - - const adapter = registerBidAdapter.mock.calls[0][2] as TestAdapterSpec; - const convertedBids = adapter.interpretResponse( - { - body: { - seatbid: [ - { - seat: 'appnexus', - bid: [ - { - impid: 'ordinary-slot', - price: 2.5, - adm: '
ordinary creative
', - w: 300, - h: 250, - }, - ], - }, - { - seat: 'aps', - bid: [ - { - impid: 'aps-slot', - price: 3.5, - ext: { trusted_server: { renderer: apsRenderer() } }, - }, - ], - }, - ], - }, - }, - { - tsjsBidRequests: [ - { adUnitCode: 'ordinary-slot', bidId: 'ordinary-request' }, - { adUnitCode: 'aps-slot', bidId: 'aps-request' }, - ], - } - ); - - expect(convertedBids).toHaveLength(1); - expect(convertedBids[0]).toEqual( - expect.objectContaining({ - requestId: 'ordinary-request', - bidderCode: 'appnexus', - ad: '
ordinary creative
', - }) - ); - expect(convertedBids).not.toEqual( - expect.arrayContaining([expect.objectContaining({ bidderCode: 'aps' })]) - ); - expect(onEvent).not.toHaveBeenCalledWith('bidResponse', expect.any(Function)); - expect( - warnSpy.mock.calls.some((args) => - args.some( - (value) => typeof value === 'string' && value.includes('APS renderer bids disabled') - ) - ) - ).toBe(true); - expect(testWindow.__tsjsPrebidShimInstalled).toBe(true); - - warnSpy.mockRestore(); - }); -}); - -describe('prebid self-init user ID module timing', () => { - const userSyncCallCount = () => - mockSetConfig.mock.calls.filter(([arg]) => arg && typeof arg === 'object' && 'userSync' in arg) - .length; - - const setReadyState = (value: DocumentReadyState) => { - Object.defineProperty(document, 'readyState', { value, configurable: true }); - }; - - beforeEach(() => { - vi.resetModules(); - mockSetConfig.mockClear(); - }); - - afterEach(() => { - setReadyState('complete'); - }); - - it('installs user ID modules immediately when the bundle loads after window load', async () => { - // The GPT slim loader appends this bundle from a window.load handler, so - // the document is already complete — a load listener would never fire. - setReadyState('complete'); - - await import('../../../src/integrations/prebid/index'); - - expect(userSyncCallCount()).toBeGreaterThan(0); - }); - - it('defers user ID modules to window load when the document is still loading', async () => { - setReadyState('loading'); - - await import('../../../src/integrations/prebid/index'); - - expect(userSyncCallCount()).toBe(0); - - window.dispatchEvent(new Event('load')); - expect(userSyncCallCount()).toBe(1); - - // { once: true } — a second load event must not reinstall. - window.dispatchEvent(new Event('load')); - expect(userSyncCallCount()).toBe(1); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts new file mode 100644 index 000000000..f97ae9d88 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -0,0 +1,1900 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { GoogletagAdapter } from '../../../src/adapters/googletag'; +import { + PrebidAdmissionContractError, + type PrebidAdapter, + type PrebidFacade, +} from '../../../src/adapters/prebid'; +import { + createPrebidIntegrationRegistration, + createPrebidSelectionCoordinator, + publishPrebidBid, + type PrebidBidPublicationInput, + type PreparedTrustedBidV1, +} from '../../../src/integrations/prebid/module'; +import { + createPrebidRefreshPolicy, + createPrebidSyntheticRefreshRunner, + preparePrebidRegisteredRefreshAuction, +} from '../../../src/integrations/prebid/refresh'; +import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; +import { + createIntegrationRegistry, + type IntegrationActivationContext, + type IntegrationInstallCallbacks, + type IntegrationRegistration, +} from '../../../src/kernel/integration_registry'; +import { createRuntimeSession, type RenderAttemptScope } from '../../../src/kernel/sessions'; +import { + createCommittedArtifactStore, + createRenderAttempt, + type RenderAttempt, +} from '../../../src/services/render'; +import { createReservationService } from '../../../src/services/reservations'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest(ids: readonly string[]) { + return { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'critical' as const })), + }; +} + +function registration( + id: string, + prepare: IntegrationRegistration['prepare'] +): IntegrationRegistration { + return Object.freeze({ abi: 1, id, phase: 'critical', releaseId: RELEASE_ID, prepare }); +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +function recursivelyFrozen(candidate: unknown, seen = new Set()): boolean { + if (candidate === null || (typeof candidate !== 'object' && typeof candidate !== 'function')) { + return typeof candidate !== 'number' || Number.isFinite(candidate); + } + if (typeof candidate === 'function' || seen.has(candidate) || !Object.isFrozen(candidate)) { + return false; + } + const prototype = Object.getPrototypeOf(candidate); + if ( + prototype !== Object.prototype && + prototype !== null && + !(Array.isArray(candidate) && prototype === Array.prototype) + ) { + return false; + } + seen.add(candidate); + return Reflect.ownKeys(candidate).every((key) => { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + return ( + descriptor !== undefined && 'value' in descriptor && recursivelyFrozen(descriptor.value, seen) + ); + }); +} + +function createLegacyPrebidRegistrationForTest(_releaseId: string): IntegrationRegistration { + return registration('prebid', ({ config, interfaces }) => { + if (!recursivelyFrozen(config)) throw new TypeError('Prebid test config is invalid'); + const runtime = interfaces['prebid'] as + Readonly<{ activate?: () => unknown; start?: (config: unknown) => void }> | undefined; + if ( + !runtime || + !Object.isFrozen(runtime) || + typeof runtime.activate !== 'function' || + typeof runtime.start !== 'function' + ) { + throw new TypeError('Prebid test runtime is unavailable'); + } + const activate = runtime.activate; + const start = runtime.start; + return Object.freeze({ + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + const release = activate(); + if (typeof release !== 'function') { + throw new TypeError('Prebid test runtime disposer is unavailable'); + } + onDispose(release as () => void); + afterCommit(() => start(config)); + }, + }); + }); +} + +type TrustedServerBidder = Readonly<{ + callBids: ( + request: Readonly, + addBidResponse: (adUnitCode: string, bid: Readonly>) => void, + done: () => void + ) => void; +}>; + +function recursivelyFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) recursivelyFreeze(child); + Object.freeze(value); + } + return value; +} + +function productionPrebidBinding(userIdModules: readonly object[]) { + const listeners = new Map void>>(); + const responses = new Map>[]>(); + let bidder: TrustedServerBidder | undefined; + let highest: readonly object[] = Object.freeze([]); + const responseFor = (adUnitCode: string) => { + const response = [...(responses.get(adUnitCode) ?? [])] as object[] & { bids: object[] }; + response.bids = response; + return response; + }; + const pbjs = { + addAdUnits: vi.fn(), + getBidResponsesForAdUnitCode: vi.fn((adUnitCode: string) => responseFor(adUnitCode)), + getHighestCpmBids: vi.fn(() => [...highest]), + offEvent: vi.fn((type: string, listener: (event: unknown) => void) => { + listeners.get(type)?.delete(listener); + }), + onEvent: vi.fn((type: string, listener: (event: unknown) => void) => { + const current = listeners.get(type) ?? new Set(); + current.add(listener); + listeners.set(type, current); + }), + processQueue: vi.fn(), + registerBidAdapter: vi.fn((factory: () => TrustedServerBidder) => { + bidder = factory(); + }), + renderAd: vi.fn(), + requestBids: vi.fn(), + setTargetingForGPTAsync: vi.fn(), + que: Object.freeze({ + push: (command: () => void) => { + command(); + return 1; + }, + }), + }; + const stamp = recursivelyFreeze({ + abi: 1, + artifactReleaseId: 'b'.repeat(64), + prebidVersion: '10.26.0', + moduleStems: ['alphaBidAdapter', 'sharedIdSystem'], + bidderCodes: ['alpha'], + bidderAliases: [], + userIdModules: [...userIdModules], + }); + Object.defineProperty(pbjs, '__trustedServerArtifactV1', { + configurable: false, + enumerable: false, + value: stamp, + writable: false, + }); + return Object.freeze({ + addResponse: (adUnitCode: string, bid: Readonly>): void => { + responses.set(adUnitCode, Object.freeze([bid])); + for (const listener of listeners.get('bidResponse') ?? []) listener(bid); + }, + bidder: () => bidder, + emit: (type: string, event: unknown): void => { + for (const listener of listeners.get(type) ?? []) listener(event); + }, + pbjs, + select: (bids: readonly object[]): void => { + highest = Object.freeze([...bids]); + }, + }); +} + +function requiredUserIdConfig() { + return Object.freeze({ + clientSideBidders: Object.freeze(['alpha']), + requiredUserIdModules: Object.freeze([ + Object.freeze({ + moduleName: 'sharedIdSystem', + configNames: Object.freeze(['sharedId']), + eidSources: Object.freeze(['sharedid.org']), + }), + ]), + }); +} + +function initialProductionPrebidHarness(userIdModules: readonly object[]) { + const binding = productionPrebidBinding(userIdModules); + (window as unknown as { pbjs?: unknown }).pbjs = binding.pbjs; + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(9); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected initial navigation'); + const navigation = navigationResult.value; + const renderSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
production-prebid
', + width: 300, + height: 250, + }); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-one', + provider: 'aps', + upstreamBidId: 'upstream-one', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trustedServer' }), + rendererReservationId: `r1_${'p'.repeat(22)}`, + renderSource, + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'auction-one', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + bids: Object.freeze([bid]), + }); + if (!navigation.installAuctionProjection(projection)) throw new Error('Expected projection'); + const reservations = createReservationService({ + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as typeof renderSource) + : undefined, + }); + const artifacts = createCommittedArtifactStore(); + const registerPucGamAttempt = vi.fn(() => true); + const createAttempt = (owner: RenderAttemptScope) => + createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as typeof renderSource) + : undefined, + reservations, + }); + const preparationDisposers: Array<() => void> = []; + const activationDisposers: Array<() => void> = []; + const afterCommit: Array<() => void> = []; + const controller = new AbortController(); + return Object.freeze({ + activationContext: Object.freeze({ + afterCommit: (callback: () => void) => afterCommit.push(callback), + onDispose: (callback: () => void) => activationDisposers.push(callback), + signal: controller.signal, + }), + afterCommit, + bid, + binding, + config: requiredUserIdConfig(), + dispose: () => { + for (let index = activationDisposers.length - 1; index >= 0; index -= 1) { + activationDisposers[index]?.(); + } + for (let index = preparationDisposers.length - 1; index >= 0; index -= 1) { + preparationDisposers[index]?.(); + } + reservations.dispose(); + artifacts.dispose(); + runtime.dispose(); + delete (window as unknown as { pbjs?: unknown }).pbjs; + }, + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({ document }), + 'slots.v1': Object.freeze({}), + 'render.v1': Object.freeze({ + createAttempt, + navigation, + projection, + registerPucGamAttempt, + reservations, + }), + 'messages.v1': Object.freeze({}), + 'aps.v1': Object.freeze({}), + }), + navigation, + prepareContext: Object.freeze({ + config: requiredUserIdConfig(), + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({ document }), + 'slots.v1': Object.freeze({}), + 'render.v1': Object.freeze({ + createAttempt, + navigation, + projection, + registerPucGamAttempt, + reservations, + }), + 'messages.v1': Object.freeze({}), + 'aps.v1': Object.freeze({}), + }), + onDispose: (callback: () => void) => preparationDisposers.push(callback), + signal: controller.signal, + }), + registerPucGamAttempt, + reservations, + }); +} + +describe('production Prebid critical registration', () => { + afterEach(() => { + delete (window as unknown as { pbjs?: unknown }).pbjs; + }); + + it('passes exact configured user-ID/EID requirements into artifact admission', async () => { + const harness = initialProductionPrebidHarness([]); + try { + const prepared = await createPrebidIntegrationRegistration(RELEASE_ID).prepare( + harness.prepareContext + ); + const capability = prepared.interfaces?.['prebid.v1'] as + Readonly<{ adapter?: PrebidAdapter }> | undefined; + expect(capability?.adapter?.bindingStatus()).toBe('incompatible'); + } finally { + harness.dispose(); + } + }); + + it('publishes the initial TS winner and promotes its exact selection through render.v1', async () => { + const harness = initialProductionPrebidHarness([ + Object.freeze({ + moduleName: 'sharedIdSystem', + configNames: Object.freeze(['sharedId']), + eidSources: Object.freeze(['sharedid.org']), + }), + ]); + try { + const prepared = await createPrebidIntegrationRegistration(RELEASE_ID).prepare( + harness.prepareContext + ); + prepared.activate(harness.activationContext); + for (const callback of harness.afterCommit) callback(); + const bidder = harness.binding.bidder(); + if (!bidder) throw new Error('Expected trustedServer bidder registration'); + const done = vi.fn(); + let admitted: Readonly> | undefined; + bidder.callBids( + Object.freeze({ + auctionId: 'auction-one', + bids: Object.freeze([ + Object.freeze({ + adUnitCode: 'slot-one', + adUnitId: 'unit-one', + auctionId: 'auction-one', + bidId: 'request-one', + src: 'client', + transactionId: 'transaction-one', + }), + ]), + }), + (adUnitCode, response) => { + const enriched = Object.freeze({ ...response, adUnitCode }); + admitted = enriched; + harness.binding.addResponse(adUnitCode, enriched); + }, + done + ); + expect(done).toHaveBeenCalledOnce(); + expect(admitted).toMatchObject({ + adId: harness.bid.rendererReservationId, + bidderCode: 'trustedServer', + requestId: 'request-one', + }); + const selected = admitted; + if (!selected) throw new Error('Expected admitted TS bid'); + harness.binding.select([ + Object.freeze({ + ...selected, + adUnitCode: 'slot-one', + auctionId: 'auction-one', + }), + ]); + expect(harness.reservations.recognize(harness.bid.rendererReservationId)).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + harness.binding.emit('auctionEnd', Object.freeze({ auctionId: 'auction-one' })); + expect(harness.binding.pbjs.getHighestCpmBids).toHaveBeenCalledOnce(); + expect(harness.registerPucGamAttempt).toHaveBeenCalledOnce(); + expect(harness.reservations.recognize(harness.bid.rendererReservationId)).toMatchObject({ + state: 'renderable', + }); + } finally { + harness.dispose(); + } + }); +}); + +describe('transactional test-composition Prebid boundary', () => { + it('prepares inertly, activates reversible listeners, and starts only after commit', async () => { + const config = Object.freeze({ clientSideBidders: Object.freeze(['rubicon']) }); + const order: string[] = []; + const start = vi.fn((received: unknown) => { + order.push('start'); + expect(received).toBe(config); + }); + const release = vi.fn(() => order.push('release')); + const activate = vi.fn(() => { + order.push('prebid:activate'); + return release; + }); + let finishPreparation: (() => void) | undefined; + const preparationGate = new Promise((resolve) => { + finishPreparation = resolve; + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid', 'gate']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid', 'gate']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ prebid: Object.freeze({ activate, start }) }), + }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + registry.register( + registration('gate', async () => { + order.push('gate:prepare'); + await preparationGate; + return Object.freeze({ activate: () => order.push('gate:activate') }); + }) + ); + + const installing = registry.install(callbacks(order)); + await vi.waitFor(() => expect(order).toEqual(['gate:prepare'])); + expect(start).not.toHaveBeenCalled(); + + finishPreparation?.(); + const result = await installing; + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'gate:prepare', + 'core', + 'prebid:activate', + 'gate:activate', + 'publish', + 'start', + 'drain', + ]); + expect(activate).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(release).toHaveBeenCalledTimes(1); + }); + + it('unwinds Prebid activation before fallback when a later module fails', async () => { + const release = vi.fn(); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid', 'broken']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid', 'broken']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => release, start }), + }), + }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + registry.register( + registration('broken', () => ({ + activate: () => { + throw new Error('fictional activation failure'); + }, + })) + ); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(release).toHaveBeenCalledTimes(1); + expect(start).not.toHaveBeenCalled(); + }); + + it('does not start when reversible Prebid activation fails', async () => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + prebid: Object.freeze({ + activate: () => { + throw new Error('fictional listener activation failure'); + }, + start, + }), + }), + }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(start).not.toHaveBeenCalled(); + }); + + it('fails preparation without effects when the composition omits the Prebid boundary', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); + + it.each([ + [ + 'accessor', + Object.freeze( + Object.defineProperty({}, 'externalBundleUrl', { + enumerable: true, + get: () => '/publisher-controlled', + }) + ), + ], + ['mutable nested data', Object.freeze({ nested: {} })], + ['non-plain data', Object.freeze({ value: Object.freeze(new Date(0)) })], + ])('rejects %s configuration during inert preparation', async (_caseName, config) => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(start).not.toHaveBeenCalled(); + }); + + it('isolates post-commit startup failure to the Prebid module', async () => { + const start = vi.fn(() => { + throw new Error('fictional Prebid startup failure'); + }); + const runtimeFailures: unknown[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + onRuntimeFailure: (failure) => runtimeFailures.push(failure), + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createLegacyPrebidRegistrationForTest(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'prebid', phase: 'after_commit' }], + }); + expect(start).toHaveBeenCalledTimes(1); + expect(runtimeFailures).toEqual([{ id: 'prebid', phase: 'after_commit' }]); + }); +}); + +describe('RCJ-PREBID-04 prospective refresh policy', () => { + function refreshHarness( + excludedGamAdUnitPathSuffixes: readonly string[] | (() => readonly string[]) + ) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(3); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const clearCalls: Array = []; + const operationDisposals: Array> = []; + const googletag = { + run: vi.fn((command: (gpt: object) => unknown) => { + const dispose = vi.fn(); + operationDisposals.push(dispose); + const facade = Object.freeze({ + adUnitPath: (slot: object) => { + const getter = Reflect.get(slot, 'getAdUnitPath'); + if (typeof getter !== 'function') return undefined; + return Reflect.apply(getter, slot, []); + }, + clearTargeting: (slot: object, key: string) => { + clearCalls.push([slot, key]); + const clear = Reflect.get(slot, 'clearTargeting'); + if (typeof clear === 'function') return Reflect.apply(clear, slot, [key]); + return undefined; + }, + }); + return Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose, + }); + }), + }; + const auctionDisposals: Array> = []; + const runSyntheticAuction = vi.fn((_slots: readonly object[]) => { + const dispose = vi.fn(); + auctionDisposals.push(dispose); + return Object.freeze({ completion: Promise.resolve(), dispose }); + }); + const policy = createPrebidRefreshPolicy({ + currentNavigation: () => navigation, + excludedGamAdUnitPathSuffixes, + googletag: googletag as unknown as Pick, + runSyntheticAuction, + }); + return { + auctionDisposals, + clearCalls, + navigation, + operationDisposals, + policy, + runSyntheticAuction, + runtime, + }; + } + + it('clears every target then filters only literal case-sensitive suffix matches', async () => { + const harness = refreshHarness(['/tracking']); + const excluded = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => '/network/tracking'), + }; + const caseMismatch = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => '/network/Tracking'), + }; + const trailingSlash = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => '/network/tracking/'), + }; + const missing = { clearTargeting: vi.fn() }; + const nonString = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => 42), + }; + const throwing = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => { + throw new Error('path unavailable'); + }), + }; + const clearFailure = { + clearTargeting: vi.fn((key: string) => { + if (key === 'hb_adid') throw new Error('clear unavailable'); + }), + getAdUnitPath: vi.fn(() => '/network/tracking'), + }; + const slots = Object.freeze([ + excluded, + caseMismatch, + trailingSlash, + missing, + nonString, + throwing, + clearFailure, + ]); + + await harness.policy.prepare( + Object.freeze({ requestedSlots: slots, slots, options: Object.freeze({ exact: true }) }) + ); + + const expectedKeys = [ + 'ts_initial', + 'hb_pb', + 'hb_bidder', + 'hb_adid', + 'hb_cache_host', + 'hb_cache_path', + ]; + for (const slot of slots) { + expect( + harness.clearCalls.filter(([target]) => target === slot).map(([, key]) => key) + ).toEqual(expectedKeys); + } + expect(harness.runSyntheticAuction).toHaveBeenCalledExactlyOnceWith( + [caseMismatch, trailingSlash, missing, nonString, throwing, clearFailure], + harness.navigation + ); + harness.policy.dispose(); + harness.runtime.dispose(); + }); + + it('skips the synthetic auction when all targets are excluded', async () => { + const harness = refreshHarness(['/skip']); + const slots = Object.freeze([ + { getAdUnitPath: () => '/one/skip' }, + { getAdUnitPath: () => '/two/skip' }, + ]); + + await harness.policy.prepare( + Object.freeze({ requestedSlots: undefined, slots, options: undefined }) + ); + + expect(harness.runSyntheticAuction).not.toHaveBeenCalled(); + expect(harness.clearCalls).toHaveLength(slots.length * 6); + harness.policy.dispose(); + harness.runtime.dispose(); + }); + + it('reads the configured exclusion snapshot only when the activated policy prepares', async () => { + let configuredSuffixes: readonly string[] = Object.freeze([]); + const harness = refreshHarness(() => configuredSuffixes); + configuredSuffixes = Object.freeze(['/configured-after-activation']); + const slot = Object.freeze({ getAdUnitPath: () => '/network/configured-after-activation' }); + + await harness.policy.prepare( + Object.freeze({ requestedSlots: Object.freeze([slot]), slots: Object.freeze([slot]) }) + ); + + expect(harness.runSyntheticAuction).not.toHaveBeenCalled(); + expect(harness.clearCalls).toHaveLength(6); + harness.policy.dispose(); + harness.runtime.dispose(); + }); + + it('settles pending work on navigation abort and ignores a late auction completion', async () => { + const harness = refreshHarness([]); + let finishAuction!: () => void; + const auction = new Promise((resolve) => { + finishAuction = resolve; + }); + const auctionDispose = vi.fn(); + harness.runSyntheticAuction.mockReturnValue( + Object.freeze({ completion: auction, dispose: auctionDispose }) + ); + const slot = Object.freeze({ getAdUnitPath: () => '/eligible' }); + const completion = harness.policy.prepare( + Object.freeze({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + options: undefined, + }) + ); + await vi.waitFor(() => expect(harness.runSyntheticAuction).toHaveBeenCalledOnce()); + + harness.runtime.replaceNavigation(); + await expect(completion).resolves.toBeUndefined(); + expect(harness.operationDisposals[0]).toHaveBeenCalledOnce(); + expect(auctionDispose).toHaveBeenCalledOnce(); + finishAuction(); + await auction; + await Promise.resolve(); + expect(harness.runSyntheticAuction).toHaveBeenCalledOnce(); + harness.policy.dispose(); + }); + + it('settles pending work when the refresh policy is disposed', async () => { + const harness = refreshHarness([]); + let finishAuction!: () => void; + const auction = new Promise((resolve) => { + finishAuction = resolve; + }); + const auctionDispose = vi.fn(); + harness.runSyntheticAuction.mockReturnValue( + Object.freeze({ completion: auction, dispose: auctionDispose }) + ); + const slot = Object.freeze({ getAdUnitPath: () => '/eligible' }); + const completion = harness.policy.prepare( + Object.freeze({ requestedSlots: Object.freeze([slot]), slots: Object.freeze([slot]) }) + ); + await vi.waitFor(() => expect(harness.runSyntheticAuction).toHaveBeenCalledOnce()); + + harness.policy.dispose(); + harness.policy.dispose(); + await expect(completion).resolves.toBeUndefined(); + expect(harness.operationDisposals[0]).toHaveBeenCalledOnce(); + expect(auctionDispose).toHaveBeenCalledOnce(); + finishAuction(); + await auction; + harness.runtime.dispose(); + }); +}); + +describe('RCJ-PREBID-04 adapter-backed synthetic refresh runner', () => { + it('routes detached server and client bids without consulting publisher Prebid state', () => { + const slot = Object.freeze({ id: 'slot-a' }); + const serverParams = Object.freeze({ placement: 'current' }); + const unit = Object.freeze({ + code: 'slot-a', + mediaTypes: Object.freeze({ + banner: Object.freeze({ sizes: Object.freeze([Object.freeze([300, 250])]) }), + }), + bids: Object.freeze([ + Object.freeze({ + bidder: 'trustedServer', + params: Object.freeze({ + bidderParams: Object.freeze({ + client: Object.freeze({ stale: true }), + preserved: Object.freeze({ placement: 'folded' }), + server: Object.freeze({ placement: 'stale' }), + }), + zone: 'news', + }), + }), + Object.freeze({ bidder: 'server', params: serverParams }), + Object.freeze({ bidder: 'client', params: Object.freeze({ placement: 'browser' }) }), + ]), + }); + + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze(['client']), + resolveAdUnit: (candidate) => (candidate === slot ? unit : undefined), + slots: Object.freeze([slot]), + }); + + expect(prepared).toEqual({ + adUnitCodes: ['slot-a'], + adUnits: [ + { + code: 'slot-a', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + preserved: { placement: 'folded' }, + server: { placement: 'current' }, + }, + zone: 'news', + }, + }, + { bidder: 'client', params: { placement: 'browser' } }, + ], + }, + ], + }); + expect(Object.isFrozen(prepared?.adUnits)).toBe(true); + expect(Object.isFrozen(prepared?.adUnits[0])).toBe(true); + }); + + it('preserves legacy last-write precedence when folded params follow direct bids', () => { + const slot = Object.freeze({ id: 'slot-order' }); + const unit = Object.freeze({ + code: 'slot-order', + mediaTypes: Object.freeze({ banner: Object.freeze({ sizes: Object.freeze([]) }) }), + bids: Object.freeze([ + Object.freeze({ + bidder: 'server', + params: Object.freeze({ placement: 'direct-first' }), + }), + Object.freeze({ + bidder: 'trustedServer', + params: Object.freeze({ + bidderParams: Object.freeze({ + preserved: Object.freeze({ placement: 'folded-only' }), + server: Object.freeze({ placement: 'folded-last' }), + }), + }), + }), + ]), + }); + + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }); + + expect(prepared?.adUnits).toEqual([ + { + code: 'slot-order', + mediaTypes: { banner: { sizes: [] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + server: { placement: 'folded-last' }, + preserved: { placement: 'folded-only' }, + }, + }, + }, + ], + }, + ]); + const bidderParams = ( + prepared?.adUnits[0] as { + bids: readonly [{ params: { bidderParams: Readonly> } }]; + } + ).bids[0].params.bidderParams; + expect(Object.keys(bidderParams)).toEqual(['server', 'preserved']); + }); + + it('fails closed when detached registrations contain duplicate trustedServer bids', () => { + const slot = Object.freeze({ id: 'slot-duplicate-trusted' }); + const trustedBid = Object.freeze({ + bidder: 'trustedServer', + params: Object.freeze({ bidderParams: Object.freeze({}) }), + }); + const unit = Object.freeze({ + code: 'slot-duplicate-trusted', + mediaTypes: Object.freeze({ banner: Object.freeze({ sizes: Object.freeze([]) }) }), + bids: Object.freeze([trustedBid, trustedBid]), + }); + + expect( + preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }) + ).toBeUndefined(); + }); + + it('keeps deterministic order while resolving duplicate direct and client bids', () => { + const slot = Object.freeze({ id: 'slot-duplicates' }); + const unit = Object.freeze({ + code: 'slot-duplicates', + mediaTypes: Object.freeze({ banner: Object.freeze({ sizes: Object.freeze([]) }) }), + bids: Object.freeze([ + Object.freeze({ bidder: 'alpha', params: Object.freeze({ sequence: 1 }) }), + Object.freeze({ bidder: 'client', params: Object.freeze({ sequence: 1 }) }), + Object.freeze({ bidder: 'beta', params: Object.freeze({ sequence: 1 }) }), + Object.freeze({ bidder: 'alpha', params: Object.freeze({ sequence: 2 }) }), + Object.freeze({ bidder: 'client', params: Object.freeze({ sequence: 2 }) }), + ]), + }); + + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze(['client']), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }); + + expect(prepared?.adUnits).toEqual([ + { + code: 'slot-duplicates', + mediaTypes: { banner: { sizes: [] } }, + bids: [ + { + bidder: 'trustedServer', + params: { bidderParams: { alpha: { sequence: 2 }, beta: { sequence: 1 } } }, + }, + { bidder: 'client', params: { sequence: 1 } }, + { bidder: 'client', params: { sequence: 2 } }, + ], + }, + ]); + const bidderParams = ( + prepared?.adUnits[0] as { + bids: readonly [{ params: { bidderParams: Readonly> } }]; + } + ).bids[0].params.bidderParams; + expect(Object.keys(bidderParams)).toEqual(['alpha', 'beta']); + }); + + it('returns a recursively frozen synthetic refresh preparation', () => { + const slot = Object.freeze({ id: 'slot-frozen' }); + const unit = Object.freeze({ + code: 'slot-frozen', + mediaTypes: Object.freeze({ + banner: Object.freeze({ sizes: Object.freeze([Object.freeze([300, 250])]) }), + }), + bids: Object.freeze([ + Object.freeze({ + bidder: 'server', + params: Object.freeze({ + placement: Object.freeze({ + rules: Object.freeze([Object.freeze({ label: 'frozen' })]), + }), + }), + }), + ]), + }); + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }); + const seen = new Set(); + const expectRecursivelyFrozen = (value: unknown): void => { + if (value === null || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + expect(Object.isFrozen(value)).toBe(true); + for (const child of Object.values(value)) expectRecursivelyFrozen(child); + }; + + expect(prepared).toBeDefined(); + expectRecursivelyFrozen(prepared); + }); + + it('fails closed when a physical slot has no detached registered ad unit', () => { + const slot = Object.freeze({ id: 'unregistered' }); + + expect( + preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => undefined, + slots: Object.freeze([slot]), + }) + ).toBeUndefined(); + }); + + function runnerHarness(options: Readonly<{ requestThrows?: boolean }> = {}) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(4); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const order: string[] = []; + let requestOptions: + | Readonly<{ + adUnits: readonly object[]; + bidsBackHandler: () => void; + timeout: number; + }> + | undefined; + const facade = Object.freeze({ + requestBids: vi.fn((received: unknown) => { + order.push('request'); + if (options.requestThrows) throw new Error('request unavailable'); + requestOptions = received as typeof requestOptions; + }), + setTargetingForGpt: vi.fn((codes: readonly string[]) => { + order.push(`target:${codes.join(',')}`); + }), + }) as unknown as Readonly; + const adapterDispose = vi.fn(); + const prebid = Object.freeze({ + run: vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: adapterDispose, + }) + ), + }) as unknown as Pick; + let deadline: (() => void) | undefined; + const timerHandle = Object.freeze({}); + const clear = vi.fn(); + const slot = Object.freeze({ id: 'slot-a' }); + const adUnit = Object.freeze({ code: 'slot-a', bids: Object.freeze([]) }); + const prepareAuction = vi.fn(() => + Object.freeze({ + adUnitCodes: Object.freeze(['slot-a']), + adUnits: Object.freeze([adUnit]), + }) + ); + const runner = createPrebidSyntheticRefreshRunner({ + prebid, + prepareAuction, + scheduler: Object.freeze({ + clear, + set: (callback: () => void, milliseconds: number) => { + expect(milliseconds).toBe(1_500); + deadline = callback; + return timerHandle; + }, + }), + }); + return { + adapterDispose, + clear, + deadline: () => deadline, + facade, + navigation, + order, + prepareAuction, + requestOptions: () => requestOptions, + runner, + runtime, + slot, + timerHandle, + }; + } + + it('requests eligible ad units then applies only their scoped targeting before completion', async () => { + const harness = runnerHarness(); + const operation = harness.runner(Object.freeze([harness.slot]), harness.navigation); + + expect(harness.order).toEqual(['request']); + expect(harness.prepareAuction).toHaveBeenCalledExactlyOnceWith( + [harness.slot], + harness.navigation + ); + expect(harness.requestOptions()).toMatchObject({ + adUnits: [{ code: 'slot-a', bids: [] }], + timeout: 1_500, + }); + harness.requestOptions()?.bidsBackHandler(); + await expect(operation.completion).resolves.toBeUndefined(); + + expect(harness.order).toEqual(['request', 'target:slot-a']); + expect(harness.clear).toHaveBeenCalledExactlyOnceWith(harness.timerHandle); + expect(harness.adapterDispose).toHaveBeenCalledOnce(); + harness.runtime.dispose(); + }); + + it('uses one targeting/settlement latch for timeout, disposal, and late callbacks', async () => { + const timedOut = runnerHarness(); + const timedOutOperation = timedOut.runner(Object.freeze([timedOut.slot]), timedOut.navigation); + const lateTimeoutCallback = timedOut.requestOptions()?.bidsBackHandler; + timedOut.deadline()?.(); + await expect(timedOutOperation.completion).resolves.toBeUndefined(); + lateTimeoutCallback?.(); + expect(timedOut.order).toEqual(['request', 'target:slot-a']); + expect(timedOut.adapterDispose).toHaveBeenCalledOnce(); + timedOut.runtime.dispose(); + + const disposed = runnerHarness(); + const disposedOperation = disposed.runner(Object.freeze([disposed.slot]), disposed.navigation); + const lateDisposedCallback = disposed.requestOptions()?.bidsBackHandler; + disposedOperation.dispose(); + disposedOperation.dispose(); + await expect(disposedOperation.completion).resolves.toBeUndefined(); + lateDisposedCallback?.(); + disposed.deadline()?.(); + expect(disposed.order).toEqual(['request']); + expect(disposed.adapterDispose).toHaveBeenCalledOnce(); + disposed.runtime.dispose(); + }); + + it('forwards completion without targeting when requestBids throws', async () => { + const harness = runnerHarness({ requestThrows: true }); + const operation = harness.runner(Object.freeze([harness.slot]), harness.navigation); + + await expect(operation.completion).resolves.toBeUndefined(); + expect(harness.order).toEqual(['request']); + expect(harness.facade.setTargetingForGpt).not.toHaveBeenCalled(); + expect(harness.adapterDispose).toHaveBeenCalledOnce(); + expect(harness.deadline()).toBeUndefined(); + harness.runtime.dispose(); + }); +}); + +describe('ordered Prebid bid publication', () => { + function preparePublication() { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(1); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const reservationId = `r1_${'a'.repeat(22)}`; + const renderSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
private creative
', + width: 300, + height: 250, + }); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-one', + provider: 'aps', + upstreamBidId: 'upstream-one', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trustedServer' }), + rendererReservationId: reservationId, + renderSource, + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'auction-one', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + bids: Object.freeze([bid]), + }); + expect(navigation.installAuctionProjection(projection)).toBe(true); + const reservations = createReservationService({ + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as typeof renderSource) + : undefined, + }); + const generatedBid = Object.freeze({ + requestId: 'prebid-request-one', + adId: 'prebid-generated-id', + cpm: bid.cpm, + width: 300, + height: 250, + }); + const order: string[] = []; + const admitTrustedBid = vi.fn((_preparedBid: Readonly) => { + order.push('admit'); + expect(reservations.recognize(reservationId)).toMatchObject({ + recognized: true, + state: 'awaiting_prebid_selection', + }); + return 'admitted' as const; + }); + const trackAdmittedBid = vi.fn(() => { + order.push('track'); + return true; + }); + const input: PrebidBidPublicationInput = { + admitTrustedBid, + auctionId: 'auction-one', + adUnitCode: bid.slot, + bid, + generatedBid, + navigation, + reservations: { + registerPrebidLease: (registrationInput) => { + order.push('reservation'); + return reservations.registerPrebidLease(registrationInput); + }, + tombstonePrebidLease: reservations.tombstonePrebidLease, + }, + trackAdmittedBid, + }; + return { + admitTrustedBid, + bid, + generatedBid, + input, + navigation, + order, + reservationId, + reservations, + runtime, + trackAdmittedBid, + }; + } + + it('registers the lease before exposing one capability-free frozen bid', () => { + const publication = preparePublication(); + + const result = publishPrebidBid(publication.input); + + expect(result.ok).toBe(true); + expect(publication.order).toEqual(['reservation', 'admit', 'track']); + expect(publication.admitTrustedBid).toHaveBeenCalledTimes(1); + const prepared = publication.admitTrustedBid.mock.calls[0]?.[0]; + if (!prepared) throw new Error('Expected prepared bid'); + expect(prepared).toMatchObject({ + auctionId: 'auction-one', + adUnitCode: 'slot-one', + bid: { + requestId: 'prebid-request-one', + adId: publication.reservationId, + cpm: 1.25, + width: 300, + height: 250, + ad: '', + ttl: 300, + creativeId: 'upstream-one', + netRevenue: true, + currency: 'USD', + bidderCode: 'trustedServer', + meta: { + advertiserDomains: [], + tsAuctionId: 'auction-one', + tsBidId: 'upstream-one', + }, + }, + }); + expect(Object.isFrozen(prepared)).toBe(true); + expect(Object.isFrozen(prepared.bid)).toBe(true); + expect(Object.isFrozen(prepared.bid.meta)).toBe(true); + expect(JSON.stringify(prepared)).not.toContain('private creative'); + expect(publication.generatedBid.adId).toBe('prebid-generated-id'); + publication.runtime.dispose(); + }); + + it('suppresses a partially published bid or failed selection tracking as a contract violation', () => { + const partial = preparePublication(); + expect( + publishPrebidBid({ + ...partial.input, + admitTrustedBid: () => { + throw new PrebidAdmissionContractError(); + }, + }) + ).toEqual({ ok: false, reason: 'prebid_contract_violation' }); + expect(partial.reservations.recognize(partial.reservationId)).toMatchObject({ + state: 'prebid_contract_violation', + }); + partial.runtime.dispose(); + + const untracked = preparePublication(); + expect(publishPrebidBid({ ...untracked.input, trackAdmittedBid: () => false })).toEqual({ + ok: false, + reason: 'prebid_contract_violation', + }); + expect(untracked.reservations.recognize(untracked.reservationId)).toMatchObject({ + state: 'prebid_contract_violation', + }); + untracked.runtime.dispose(); + }); + + it.each([ + ['not admitted', () => 'not_admitted' as const, 'prebid_admission_failed'], + [ + 'throw', + () => { + throw new Error('fictional Prebid failure'); + }, + 'prebid_admission_failed', + ], + ['partial publication', () => 'partially_admitted', 'prebid_contract_violation'], + ])('tombstones an admission that reports %s', (_caseName, admission, reason) => { + const publication = preparePublication(); + + expect(publishPrebidBid({ ...publication.input, admitTrustedBid: admission })).toEqual({ + ok: false, + reason, + }); + expect(publication.reservations.recognize(publication.reservationId)).toMatchObject({ + recognized: true, + state: reason, + }); + publication.runtime.dispose(); + }); + + it('fails before exposure on collision and leaves the generated identity untouched', () => { + const publication = preparePublication(); + expect( + publication.reservations.registerPrebidLease({ + reservationId: publication.reservationId, + slot: publication.bid.slot, + navigation: publication.navigation, + auctionId: 'auction-one', + adUnitCode: publication.bid.slot, + renderSource: publication.bid.renderSource, + winnerContext: Object.freeze({ selectedCpm: publication.bid.cpm }), + prebidBid: Object.freeze({ cpm: publication.bid.cpm }), + }) + ).toMatchObject({ ok: true }); + + expect(publishPrebidBid(publication.input)).toEqual({ + ok: false, + reason: 'reservation_collision', + }); + expect(publication.admitTrustedBid).not.toHaveBeenCalled(); + expect(publication.generatedBid.adId).toBe('prebid-generated-id'); + publication.runtime.dispose(); + }); + + it('rejects a stale projected bid and malformed generated response before registration', () => { + const stale = preparePublication(); + expect(publishPrebidBid({ ...stale.input, auctionId: 'other-auction' })).toEqual({ + ok: false, + reason: 'winner_not_renderable', + }); + expect(stale.order).toEqual([]); + stale.runtime.dispose(); + + const malformed = preparePublication(); + expect(publishPrebidBid({ ...malformed.input, generatedBid: { cpm: 1.25 } })).toEqual({ + ok: false, + reason: 'descriptor_invalid', + }); + expect(malformed.order).toEqual([]); + malformed.runtime.dispose(); + }); +}); + +describe('Prebid selection coordination', () => { + function prepareSelection( + options: Readonly<{ + activateResult?: boolean; + synchronousTimer?: boolean; + throwCreateAttempt?: boolean; + throwFail?: boolean; + throwPromotion?: boolean; + }> = {} + ) { + let now = 0; + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const reservations = createReservationService({ + now: () => now, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as Readonly<{ type: 'aps' | 'adm'; version: 1 }>) + : undefined, + }); + const artifacts = createCommittedArtifactStore(); + const attempts: RenderAttempt[] = []; + const promotions: Array> = []; + const attemptOwners: RenderAttemptScope[] = []; + const timers = new Map void>(); + const cleared: object[] = []; + const activateAttempt = vi.fn(() => options.activateResult ?? true); + const coordinator = createPrebidSelectionCoordinator({ + activateAttempt, + createAttempt: (owner) => { + if (options.throwCreateAttempt) throw new Error('attempt factory failed'); + attemptOwners.push(owner); + const result = createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as Readonly<{ type: 'aps' | 'adm'; version: 1 }>) + : undefined, + reservations, + }); + if (result.ok) { + attempts.push(result.value); + if (options.throwFail) { + return Object.freeze({ + ok: true as const, + value: Object.freeze({ + ...result.value, + fail: () => { + throw new Error('attempt failure settlement failed'); + }, + }), + }); + } + } + return result; + }, + reservations: { + promotePrebidSelection: (input) => { + if (options.throwPromotion) throw new Error('promotion failed'); + const result = reservations.promotePrebidSelection(input); + promotions.push(result); + return result; + }, + tombstone: reservations.tombstone, + tombstonePrebidGroup: reservations.tombstonePrebidGroup, + }, + scheduler: { + clear: (handle) => { + cleared.push(handle as object); + timers.delete(handle as object); + }, + set: (callback, milliseconds) => { + expect(milliseconds).toBe(10_000); + const handle = Object.freeze({}); + timers.set(handle, callback); + if (options.synchronousTimer) callback(); + return handle; + }, + }, + }); + const admitted = (idCharacter: string, adUnitCode = 'slot-one') => { + const reservationId = `r1_${idCharacter.repeat(22)}`; + const bid = Object.freeze({ + requestId: `request-${idCharacter}`, + adId: reservationId, + cpm: 1.25, + width: 300, + height: 250, + ad: '' as const, + ttl: 300 as const, + creativeId: `creative-${idCharacter}`, + netRevenue: true as const, + currency: 'USD' as const, + bidderCode: 'trustedServer' as const, + meta: Object.freeze({ + advertiserDomains: Object.freeze([] as string[]), + tsAuctionId: 'auction-one', + tsBidId: `bid-${idCharacter}`, + }), + }); + const prepared = Object.freeze({ auctionId: 'auction-one', adUnitCode, bid }); + const renderSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: `
${idCharacter}
`, + width: 300, + height: 250, + }); + expect( + reservations.registerPrebidLease({ + reservationId, + slot: adUnitCode, + navigation, + auctionId: prepared.auctionId, + adUnitCode, + renderSource, + winnerContext: Object.freeze({ selectedCpm: bid.cpm }), + prebidBid: bid, + }) + ).toMatchObject({ ok: true }); + expect(coordinator.track(prepared, navigation)).toBe(!options.synchronousTimer); + return prepared; + }; + return { + admitted, + activateAttempt, + attempts, + attemptOwners, + cleared, + coordinator, + navigation, + promotions, + reservations, + runtime, + setNow: (value: number) => { + now = value; + }, + timers, + }; + } + + it('contains a hostile publication failure settlement and releases its ephemeral owner', () => { + const harness = prepareSelection({ throwFail: true }); + + expect( + harness.coordinator.settlePublicationFailure( + harness.navigation, + 'auction-one', + 'slot-one', + 'prebid_admission_failed' + ) + ).toBe(false); + expect(harness.attempts[0]?.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'navigation_disposed', + }); + expect(harness.navigation.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + batches: 0, + }); + harness.runtime.dispose(); + }); + + it('promotes only the exact selected TS id and suppresses its group losers', () => { + const harness = prepareSelection(); + const selected = harness.admitted('a'); + const losing = harness.admitted('b'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]), + }) + ); + + expect(harness.attempts).toHaveLength(1); + expect(harness.promotions).toEqual([expect.objectContaining({ ok: true })]); + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ + state: 'renderable', + }); + expect(harness.reservations.recognize(losing.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attemptOwners[0]?.winnerContext).toEqual({ selectedCpm: 1.25 }); + expect(harness.attempts[0]?.winnerContext).toBeUndefined(); + expect(harness.activateAttempt).toHaveBeenCalledTimes(1); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + + it('selects an APS reservation after Prebid strips unknown top-level fields', () => { + // The legacy adapter carried the executable APS descriptor in a custom + // top-level field, which Prebid normalization dropped. The hard-cutover + // contract is stronger: only first-class `adId` plus per-bid `meta` + // identity cross Prebid; the executable source remains in the reservation. + const harness = prepareSelection(); + const selected = harness.admitted('m'); + const normalized = Object.freeze({ + adId: selected.bid.adId, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + bidderCode: selected.bid.bidderCode, + cpm: selected.bid.cpm, + meta: Object.freeze({ ...selected.bid.meta }), + requestId: selected.bid.requestId, + }); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: selected.auctionId }), + Object.freeze({ highestBids: () => Object.freeze([normalized]) }) + ); + + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ + state: 'renderable', + }); + expect(harness.activateAttempt).toHaveBeenCalledOnce(); + harness.runtime.dispose(); + }); + + it('tombstones a selected reservation when its PUC attempt cannot activate', () => { + const harness = prepareSelection({ activateResult: false }); + const selected = harness.admitted('f'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]), + }) + ); + + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ state: 'stale' }); + expect(harness.attempts[0]?.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'prebid_contract_violation', + }); + harness.runtime.dispose(); + }); + + it('marks the whole TS group unselected when native Prebid wins', () => { + const harness = prepareSelection(); + const losing = harness.admitted('c'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + adId: 'native-prebid-id', + adUnitCode: 'slot-one', + auctionId: 'auction-one', + cpm: 9, + }), + ]), + }) + ); + + expect(harness.reservations.recognize(losing.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts).toEqual([]); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + + it('fails closed when the pinned single-unit winner query is ambiguous', () => { + const harness = prepareSelection(); + const selected = harness.admitted('i'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + Object.freeze({ + adId: 'native-prebid-id', + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + cpm: selected.bid.cpm, + }), + ]), + }) + ); + + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts).toEqual([]); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + + it('times out a missing auctionEnd and cancels the watchdog on navigation disposal', () => { + const timedOut = prepareSelection(); + const bid = timedOut.admitted('d'); + timedOut.setNow(9_999); + expect(timedOut.timers.size).toBe(1); + [...timedOut.timers.values()][0]?.(); + expect(timedOut.reservations.recognize(bid.bid.adId)).toMatchObject({ + state: 'prebid_selection_timeout', + }); + timedOut.runtime.dispose(); + + const disposed = prepareSelection(); + const disposedBid = disposed.admitted('e'); + disposed.runtime.replaceNavigation(); + expect(disposed.reservations.recognize(disposedBid.bid.adId)).toMatchObject({ + state: 'aborted', + }); + expect(disposed.timers).toHaveLength(0); + }); + + it('aborts every ad unit in one exact auction and releases each short lease at expiry', () => { + const harness = prepareSelection(); + const first = harness.admitted('j', 'slot-one'); + const second = harness.admitted('k', 'slot-two'); + + harness.coordinator.abort(harness.navigation, 'auction-one'); + + expect(harness.reservations.recognize(first.bid.adId)).toMatchObject({ state: 'aborted' }); + expect(harness.reservations.recognize(second.bid.adId)).toMatchObject({ state: 'aborted' }); + expect(harness.timers).toHaveLength(0); + expect(harness.navigation.snapshotInventoryForTest().batches).toBe(0); + + harness.setNow(10_000); + expect(harness.reservations.recognize(first.bid.adId)).toEqual({ recognized: false }); + expect(harness.reservations.recognize(second.bid.adId)).toEqual({ recognized: false }); + expect(harness.reservations.snapshotInventoryForTest().size).toBe(0); + harness.runtime.dispose(); + }); + + it('selects independently across multiple ad units without promoting either group loser', () => { + const harness = prepareSelection(); + const first = harness.admitted('l', 'slot-one'); + const firstLoser = harness.admitted('m', 'slot-one'); + const second = harness.admitted('n', 'slot-two'); + const secondLoser = harness.admitted('o', 'slot-two'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: (adUnitCode?: string) => { + const selected = adUnitCode === 'slot-one' ? first : second; + return Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]); + }, + }) + ); + + expect(harness.reservations.recognize(first.bid.adId)).toMatchObject({ state: 'renderable' }); + expect(harness.reservations.recognize(second.bid.adId)).toMatchObject({ state: 'renderable' }); + expect(harness.reservations.recognize(firstLoser.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.reservations.recognize(secondLoser.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts).toHaveLength(2); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + + it('rolls back a scheduler that invokes the deadline before timer publication returns', () => { + const harness = prepareSelection({ synchronousTimer: true }); + const bid = harness.admitted('g'); + + expect(harness.reservations.recognize(bid.bid.adId)).toMatchObject({ + state: 'prebid_selection_timeout', + }); + expect(harness.timers).toHaveLength(0); + expect(harness.navigation.snapshotInventoryForTest().batches).toBe(0); + harness.runtime.dispose(); + }); + + it.each([ + { failure: 'attempt creation', options: { throwCreateAttempt: true } }, + { failure: 'reservation promotion', options: { throwPromotion: true } }, + ])('fails closed when $failure throws during selection', ({ options }) => { + const harness = prepareSelection(options); + const selected = harness.admitted('h'); + + expect(() => + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]), + }) + ) + ).not.toThrow(); + + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts[0]?.snapshot().outcome).toEqual( + options.throwPromotion + ? { outcome: 'failed', reason: 'prebid_contract_violation' } + : undefined + ); + expect(harness.timers).toHaveLength(0); + expect(harness.navigation.snapshotInventoryForTest().batches).toBe(0); + harness.runtime.dispose(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts new file mode 100644 index 000000000..aaae60d1f --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + PrebidAdapter, + PrebidEventFacade, + PrebidFacade, + PrebidTrustedServerAuctionV1, +} from '../../../src/adapters/prebid'; +import { createPrebidStartup } from '../../../src/integrations/prebid/startup'; +import type { GptRefreshPolicy } from '../../../src/integrations/gpt/startup'; + +describe('Prebid startup bridge', () => { + it('installs one reversible bidder/event operation before starting the external boundary', async () => { + let bidderListener: ((auction: Readonly) => void) | undefined; + let auctionEndListener: + ((event: unknown, prebid: Readonly) => void) | undefined; + const operationDispose = vi.fn(); + const releaseBidder = vi.fn(); + const releaseAuctionEnd = vi.fn(); + const order: string[] = []; + const eventFacade = Object.freeze({ highestBids: vi.fn(() => Object.freeze([])) }); + const facade = Object.freeze({ + registerTrustedServerBidder: vi.fn( + (listener: (auction: Readonly) => void) => { + order.push('register-bidder'); + bidderListener = listener; + return () => { + order.push('release-bidder'); + releaseBidder(); + }; + } + ), + subscribe: vi.fn( + ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ) => { + expect(eventType).toBe('auctionEnd'); + order.push('subscribe-auction-end'); + auctionEndListener = listener; + return () => { + order.push('release-auction-end'); + releaseAuctionEnd(); + }; + } + ), + }) as unknown as Readonly; + const run = vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }) + ); + const notifyReady = vi.fn(); + const adapter = Object.freeze({ run, notifyReady }) as unknown as PrebidAdapter; + const onAuction = vi.fn(); + const onAuctionEnd = vi.fn(); + const dispose = vi.fn(); + const start = vi.fn(); + const startup = createPrebidStartup({ + dispose, + onAuction, + onAuctionEnd, + prebid: adapter, + start, + }); + + const release = startup.activate(); + await Promise.resolve(); + + expect(run).toHaveBeenCalledTimes(1); + expect(order).toEqual(['subscribe-auction-end']); + expect(facade.registerTrustedServerBidder).not.toHaveBeenCalled(); + expect(facade.subscribe).toHaveBeenCalledTimes(1); + const event = Object.freeze({ auctionId: 'auction-one' }); + auctionEndListener?.(event, eventFacade); + expect(onAuctionEnd).toHaveBeenCalledExactlyOnceWith(event, eventFacade); + + const config = Object.freeze({ externalBundleUrl: '/prebid.js' }); + startup.start(config); + await Promise.resolve(); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + expect(notifyReady).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); + expect(facade.registerTrustedServerBidder).toHaveBeenCalledTimes(1); + expect(order).toEqual(['subscribe-auction-end', 'register-bidder']); + const auction = Object.freeze({ + auctionId: 'auction-one', + bids: Object.freeze([]), + complete: vi.fn(), + }); + bidderListener?.(auction); + expect(onAuction).toHaveBeenCalledExactlyOnceWith(auction); + + release(); + release(); + expect(operationDispose).toHaveBeenCalledTimes(2); + expect(releaseAuctionEnd).toHaveBeenCalledTimes(1); + expect(releaseBidder).toHaveBeenCalledTimes(1); + expect(order).toEqual([ + 'subscribe-auction-end', + 'register-bidder', + 'release-bidder', + 'release-auction-end', + ]); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('releases effects that settle after the runtime owner is already disposed', async () => { + let resolveOperation!: (release: () => void) => void; + const result = new Promise<() => void>((resolve) => { + resolveOperation = resolve; + }); + const operationDispose = vi.fn(); + const run = vi.fn(() => + Object.freeze({ status: 'present' as const, result, dispose: operationDispose }) + ); + const dispose = vi.fn(); + const startup = createPrebidStartup({ + dispose, + onAuction: vi.fn(), + onAuctionEnd: vi.fn(), + prebid: Object.freeze({ run, notifyReady: vi.fn() }) as unknown as PrebidAdapter, + }); + const releaseEffects = vi.fn(); + + const release = startup.activate(); + release(); + resolveOperation(releaseEffects); + await Promise.resolve(); + + expect(operationDispose).toHaveBeenCalledTimes(1); + expect(releaseEffects).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('installs the TS auctionEnd listener before startup can add a publisher callback', async () => { + const listeners: Array<(event: unknown, prebid: Readonly) => void> = []; + const order: string[] = []; + const eventFacade = Object.freeze({ highestBids: vi.fn(() => Object.freeze([])) }); + const facade = Object.freeze({ + registerTrustedServerBidder: vi.fn(() => vi.fn()), + subscribe: vi.fn( + ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ) => { + expect(eventType).toBe('auctionEnd'); + listeners.push(listener); + return vi.fn(); + } + ), + }) as unknown as Readonly; + const run = vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: vi.fn(), + }) + ); + const startup = createPrebidStartup({ + dispose: vi.fn(), + onAuction: vi.fn(), + onAuctionEnd: () => order.push('trusted-server'), + prebid: Object.freeze({ run, notifyReady: vi.fn() }) as unknown as PrebidAdapter, + start: () => { + listeners.push(() => order.push('publisher')); + }, + }); + + startup.activate(); + await Promise.resolve(); + startup.start(Object.freeze({})); + await Promise.resolve(); + const event = Object.freeze({ auctionId: 'auction-one' }); + for (const listener of listeners) listener(event, eventFacade); + + expect(order).toEqual(['trusted-server', 'publisher']); + }); + + it('installs, configures, and releases one runtime-owned GPT refresh policy', async () => { + const order: string[] = []; + const operationDispose = vi.fn(); + const facade = Object.freeze({ + registerTrustedServerBidder: vi.fn(() => vi.fn()), + subscribe: vi.fn(() => vi.fn()), + }) as unknown as Readonly; + const prebid = Object.freeze({ + notifyReady: vi.fn(), + run: vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }) + ), + }) as unknown as Pick; + const policy = Object.freeze({ prepare: vi.fn(), dispose: vi.fn() }); + const releasePolicy = vi.fn(() => order.push('release-policy')); + const install = vi.fn((_policy: GptRefreshPolicy) => { + order.push('install-policy'); + return releasePolicy; + }); + const configure = vi.fn((_config: unknown) => order.push('configure-policy')); + const start = vi.fn(() => order.push('start-prebid')); + const startup = createPrebidStartup({ + dispose: vi.fn(), + onAuction: vi.fn(), + onAuctionEnd: vi.fn(), + prebid, + refresh: Object.freeze({ configure, install, policy }), + start, + }); + + const release = startup.activate(); + expect(install).toHaveBeenCalledExactlyOnceWith(policy); + const config = Object.freeze({ excludedGamAdUnitPathSuffixes: Object.freeze(['/skip']) }); + startup.start(config); + expect(configure).toHaveBeenCalledExactlyOnceWith(config); + expect(order).toEqual(['install-policy', 'configure-policy', 'start-prebid']); + + release(); + release(); + expect(policy.dispose).toHaveBeenCalledOnce(); + expect(releasePolicy).toHaveBeenCalledOnce(); + }); + + it('unwinds the adapter and policy when GPT refuses a second refresh owner', () => { + const operationDispose = vi.fn(); + const prebid = Object.freeze({ + notifyReady: vi.fn(), + run: vi.fn(() => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(vi.fn()), + dispose: operationDispose, + }) + ), + }) as unknown as Pick; + const policy = Object.freeze({ prepare: vi.fn(), dispose: vi.fn() }); + const dispose = vi.fn(); + const startup = createPrebidStartup({ + dispose, + onAuction: vi.fn(), + onAuctionEnd: vi.fn(), + prebid, + refresh: Object.freeze({ + install: vi.fn(() => undefined), + policy, + }), + }); + + expect(() => startup.activate()).toThrow('Prebid refresh policy is unavailable'); + expect(operationDispose).toHaveBeenCalledOnce(); + expect(policy.dispose).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts new file mode 100644 index 000000000..bb0992f90 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts @@ -0,0 +1,652 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; +import type { + RuntimeAuctionContextService, + RuntimeCapabilityV1, +} from '../../../src/kernel/runtime'; +import { + adoptInitialRenderArtifactsFromHandoff, + createRenderRuntimeIntegrationRegistration, +} from '../../../src/integrations/render_runtime/module'; +import { log } from '../../../src/core/log'; +import { createCommittedArtifactStore, type RenderAttempt } from '../../../src/services/render'; +import type { NavigationSession } from '../../../src/kernel/sessions'; + +const RELEASE_ID = 'a'.repeat(64); + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('render_runtime provider', () => { + it('adopts transferred DOM artifacts without removing them on rollback, then arms commit ownership', () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1' })]), + cycles: Object.freeze([]), + artifacts: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + }), + identities: Object.freeze([frame]), + }); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ + id: `a1_${'A'.repeat(22)}`, + navigationGeneration, + }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + + const rollbackStore = createCommittedArtifactStore(); + expect( + adoptInitialRenderArtifactsFromHandoff(adoption, navigation, rollbackStore, document) + ).toBeDefined(); + rollbackStore.dispose(); + expect(frame.isConnected).toBe(true); + + const committedStore = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + committedStore, + document + ); + expect(committed?.adoption).toBe(adoption); + committed?.arm(); + committedStore.dispose(); + expect(frame.isConnected).toBe(false); + expect(batch.dispose).toHaveBeenCalledTimes(2); + }); + + it('rolls back prepared resources without unbound disposer failures', () => { + const release: Array<() => void> = []; + const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined); + const runtime = Object.freeze({ + attachAuctionContextService: () => () => undefined, + boot: () => + Object.freeze({ + auctionProjection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([]), + }), + slots: Object.freeze([]), + bids: Object.freeze([]), + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: false }), + }), + manifest: Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' as const }), + ]), + }), + }), + document, + enqueue: () => true, + generation: Object.freeze({}), + protectFirstDisplayAttemptBatch: vi.fn(() => true), + registerAuctionContext: () => () => undefined, + } satisfies RuntimeCapabilityV1); + + createRenderRuntimeIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ 'runtime.v1': runtime }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => release.push(callback), + } satisfies IntegrationPrepareContext) + ); + release.reverse().forEach((callback) => callback()); + + expect(warn).not.toHaveBeenCalledWith('render_runtime disposal failed', expect.anything()); + warn.mockRestore(); + }); + + it('stages the seven real capabilities inertly and activates direct registration once', async () => { + const release: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + const protect = vi.fn(() => true); + let contextService: RuntimeAuctionContextService | undefined; + const runtime = Object.freeze({ + attachAuctionContextService: (service: RuntimeAuctionContextService) => { + if (contextService) return undefined; + contextService = service; + return () => { + if (contextService === service) contextService = undefined; + }; + }, + boot: () => + Object.freeze({ + auctionProjection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([]), + }), + slots: Object.freeze([]), + bids: Object.freeze([]), + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: false }), + }), + manifest: Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' as const }), + Object.freeze({ id: 'permutive_context', phase: 'critical' as const }), + ]), + }), + }), + document, + enqueue: () => true, + generation: Object.freeze({}), + protectFirstDisplayAttemptBatch: protect, + registerAuctionContext: ( + integrationId: string, + contributor: () => Readonly> | undefined + ) => contextService?.register(integrationId, contributor), + } satisfies RuntimeCapabilityV1); + const registration = createRenderRuntimeIntegrationRegistration(RELEASE_ID); + const prepared = registration.prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ 'runtime.v1': runtime }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => release.push(callback), + } satisfies IntegrationPrepareContext) + ); + if ('then' in Object(prepared)) throw new Error('render_runtime preparation must be sync'); + const exactPrepared = prepared as PreparedIntegration; + const interfaces = exactPrepared.interfaces; + expect(Reflect.ownKeys(interfaces ?? {})).toEqual([ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + ]); + const direct = interfaces?.['direct.v1'] as { + addAdUnits: (candidate: unknown) => unknown; + requestAds: (candidate?: unknown) => Promise; + }; + const slotCapability = interfaces?.['slots.v1'] as { + attachPhysicalService: (service: object) => () => void; + snapshot: () => readonly Readonly<{ registeredSlotId: string }>[]; + }; + expect(() => + direct.addAdUnits({ + code: 'programmatic', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'fictional', params: {} }], + }) + ).toThrow(); + + exactPrepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + expect( + direct.addAdUnits({ + code: 'programmatic', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'fictional', params: {} }], + }) + ).toEqual({ registered: ['programmatic'] }); + const physicalRecords: Array>> = []; + const physicalService = Object.freeze({ + register: vi.fn( + (_owner: object, registrations: readonly Readonly>[]) => { + physicalRecords.push( + ...registrations.map((registration) => + Object.freeze({ + ...registration, + navigationGeneration: Object.freeze({}), + domAliases: registration['domAliases'] ?? Object.freeze([]), + }) + ) + ); + return Object.freeze({ ok: true as const, records: Object.freeze([...physicalRecords]) }); + } + ), + snapshotRegisteredSlots: vi.fn(() => Object.freeze([...physicalRecords])), + }); + const releasePhysical = slotCapability.attachPhysicalService(physicalService); + expect(physicalService.register).toHaveBeenCalledOnce(); + expect(slotCapability.snapshot().map(({ registeredSlotId }) => registeredSlotId)).toEqual([ + 'programmatic', + ]); + releasePhysical(); + expect(slotCapability.snapshot().map(({ registeredSlotId }) => registeredSlotId)).toEqual([ + 'programmatic', + ]); + const releaseContext = runtime.registerAuctionContext('permutive_context', () => + Object.freeze({ permutive_segments: Object.freeze(['segment-one']) }) + ); + expect(releaseContext).toBeTypeOf('function'); + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => + Object.freeze({ + id: 'auction-one', + cur: 'USD', + seatbid: Object.freeze([]), + ext: Object.freeze({ + trusted_server: Object.freeze({ + slot_results: Object.freeze({ + version: 1, + auctionId: 'auction-one', + results: Object.freeze([ + Object.freeze({ slot: 'programmatic', outcome: 'no_bid' as const }), + ]), + }), + }), + }), + }), + } as Response); + await expect(direct.requestAds({ slots: ['programmatic'] })).resolves.toEqual({ + slots: [{ slot: 'programmatic', path: 'primary', outcome: 'no_bid' }], + }); + expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toMatchObject({ + config: { permutive_segments: ['segment-one'] }, + }); + fetcher.mockRestore(); + releaseContext?.(); + + activationRelease.reverse().forEach((callback) => callback()); + release.reverse().forEach((callback) => callback()); + expect(() => + direct.addAdUnits({ + code: 'late', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }) + ).toThrow(); + }); + + it('rejects renderer and APS-message registration until activation and removes exact registrations', () => { + const release: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + const runtime = Object.freeze({ + attachAuctionContextService: () => () => undefined, + boot: () => + Object.freeze({ + auctionProjection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([]), + }), + slots: Object.freeze([]), + bids: Object.freeze([]), + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: false, + gpt: Object.freeze({ active: false }), + }), + manifest: Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' as const }), + ]), + }), + }), + document, + enqueue: () => true, + generation: Object.freeze({}), + protectFirstDisplayAttemptBatch: vi.fn(() => true), + registerAuctionContext: () => () => undefined, + } satisfies RuntimeCapabilityV1); + const prepared = createRenderRuntimeIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ 'runtime.v1': runtime }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => release.push(callback), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + const render = prepared.interfaces?.['render.v1'] as { + attachPucGamAttemptRegistrar: (registrar: (input: unknown) => boolean) => () => void; + createAttempt: ( + owner: Readonly> + ) => Readonly<{ ok: boolean; value?: RenderAttempt }>; + createSlotOperation: ( + input: Readonly<{ primary: RenderAttempt }> + ) => Readonly<{ ok: true; value: object }> | Readonly<{ ok: false; reason: string }>; + navigation: { + createAuctionBatch: (auctionId: string) => + | { + createRenderAttempt: ( + slot: string + ) => Readonly<{ ok: boolean; value?: Readonly> }>; + } + | undefined; + }; + registerPucGamAttempt: (input: unknown) => boolean; + registerRenderer: ( + type: 'aps', + renderer: (attempt: RenderAttempt, container: HTMLElement) => boolean + ) => () => void; + }; + const messages = prepared.interfaces?.['messages.v1'] as { + messaging: { + parseProtocolMessage: (kind: 'apsEnvelope', candidate: unknown) => object | undefined; + }; + registerApsValidation: (validation: Readonly>) => () => void; + }; + const renderer = vi.fn(() => true); + const origin = window.location.origin; + const rendererUrl = new URL('/integrations/aps/renderer/v1', origin).href; + const validation = Object.freeze({ + expectedPublisherOrigin: origin, + expectedRendererUrl: rendererUrl, + validateApsRenderer: vi.fn(() => true), + }); + const envelope = Object.freeze({ + version: 1, + nonce: `n1_${'a'.repeat(22)}`, + publisherOrigin: origin, + renderer: Object.freeze({ + type: 'aps', + version: 1, + accountId: 'account', + bidId: 'bid', + tagType: 'iframe', + creativeUrl: 'https://example.test/creative', + width: 300, + height: 250, + aaxResponse: 'response', + }), + }); + + expect(() => render.registerRenderer('aps', renderer)).toThrow('inactive'); + expect(() => render.attachPucGamAttemptRegistrar(() => true)).toThrow('unavailable'); + expect(render.registerPucGamAttempt(Object.freeze({}))).toBe(false); + expect(() => messages.registerApsValidation(validation)).toThrow('inactive'); + expect(messages.messaging.parseProtocolMessage('apsEnvelope', envelope)).toBeUndefined(); + + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + const batch = render.navigation.createAuctionBatch('cross-bundle-render-capability'); + const owner = batch?.createRenderAttempt('slot-one'); + expect(owner?.ok).toBe(true); + const attempt = render.createAttempt(owner?.value ?? Object.freeze({})); + expect(attempt.ok).toBe(true); + expect(render.createSlotOperation({ primary: attempt.value as RenderAttempt })).toMatchObject({ + ok: true, + }); + const hostileCause = new Error('publisher-owned validation trap'); + const hostileValidation = new Proxy(Object.freeze({}), { + getPrototypeOf: () => { + throw hostileCause; + }, + }); + let validationError: unknown; + try { + messages.registerApsValidation(hostileValidation); + } catch (error) { + validationError = error; + } + expect(validationError).toBeInstanceOf(TypeError); + expect(validationError).toMatchObject({ + message: 'APS message validation is malformed', + cause: hostileCause, + }); + expect(Object.keys(validationError as object)).not.toContain('cause'); + const pucAttempt = Object.freeze({ marker: 'exact-attempt' }); + const pucRegistrar = vi.fn(() => true); + const releasePucRegistrar = render.attachPucGamAttemptRegistrar(pucRegistrar); + expect(render.registerPucGamAttempt(pucAttempt)).toBe(true); + expect(pucRegistrar).toHaveBeenCalledExactlyOnceWith(pucAttempt); + expect(() => render.attachPucGamAttemptRegistrar(() => true)).toThrow('duplicated'); + releasePucRegistrar(); + expect(render.registerPucGamAttempt(pucAttempt)).toBe(false); + const releaseThrowingPucRegistrar = render.attachPucGamAttemptRegistrar(() => { + throw new Error('contained GPT owner failure'); + }); + expect(render.registerPucGamAttempt(pucAttempt)).toBe(false); + const releaseRenderer = render.registerRenderer('aps', renderer); + const releaseValidation = messages.registerApsValidation(validation); + expect(messages.messaging.parseProtocolMessage('apsEnvelope', envelope)).toEqual(envelope); + expect(() => render.registerRenderer('aps', vi.fn())).toThrow('duplicated'); + expect(() => messages.registerApsValidation(validation)).toThrow('duplicated'); + + releaseRenderer(); + releaseValidation(); + const replacementRenderer = vi.fn(() => false); + const releaseReplacement = render.registerRenderer('aps', replacementRenderer); + const releaseReplacementValidation = messages.registerApsValidation(validation); + releaseRenderer(); + releaseValidation(); + expect(() => render.registerRenderer('aps', vi.fn())).toThrow('duplicated'); + expect(() => messages.registerApsValidation(validation)).toThrow('duplicated'); + + activationRelease.reverse().forEach((callback) => callback()); + releaseThrowingPucRegistrar(); + expect(render.registerPucGamAttempt(pucAttempt)).toBe(false); + expect(() => render.registerRenderer('aps', vi.fn())).toThrow('inactive'); + expect(() => messages.registerApsValidation(validation)).toThrow('inactive'); + expect(messages.messaging.parseProtocolMessage('apsEnvelope', envelope)).toBeUndefined(); + releaseReplacement(); + releaseReplacementValidation(); + release.reverse().forEach((callback) => callback()); + }); + + it('publishes the data-only render trace through the private capability and public diagnostics', () => { + const release: Array<() => void> = []; + const activationRelease: Array<() => void> = []; + const runtime = Object.freeze({ + attachAuctionContextService: () => () => undefined, + boot: () => + Object.freeze({ + auctionProjection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([ + Object.freeze({ slot: 'slot-one', outcome: 'no_bid' as const }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'slot-one', + gamUnitPath: '/123/slot-one', + divId: 'slot-one', + formats: Object.freeze([Object.freeze([300, 250])]), + targeting: Object.freeze({}), + }), + ]), + bids: Object.freeze([]), + }), + diagnostics: Object.freeze({ + version: 1, + renderTraceOverlay: true, + gpt: Object.freeze({ active: false }), + }), + manifest: Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'b'.repeat(64)}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' as const }), + ]), + }), + }), + document, + enqueue: () => true, + generation: Object.freeze({}), + protectFirstDisplayAttemptBatch: vi.fn(() => true), + registerAuctionContext: () => () => undefined, + } satisfies RuntimeCapabilityV1); + const prepared = createRenderRuntimeIntegrationRegistration(RELEASE_ID).prepare( + Object.freeze({ + config: undefined, + interfaces: Object.freeze({ 'runtime.v1': runtime }), + signal: new AbortController().signal, + onDispose: (callback: () => void) => release.push(callback), + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + const trace = prepared.interfaces?.['trace.v1'] as { + diagnostics: { + current: () => Readonly>>>; + }; + observations: { publish: (observation: Readonly>) => boolean }; + record: (record: Readonly>) => Readonly>; + }; + const tracePresentation = prepared.interfaces?.['trace.presentation.v1'] as { + attachPresentation: (factory: (source: object) => object) => () => void; + }; + const direct = prepared.interfaces?.['direct.v1'] as { + diagnostics: { renderTrace: object }; + }; + const slots = prepared.interfaces?.['slots.v1'] as { + attachPhysicalService: (service: object) => () => void; + }; + prepared.activate( + Object.freeze({ + signal: new AbortController().signal, + onDispose: (callback: () => void) => activationRelease.push(callback), + afterCommit: vi.fn(), + } satisfies IntegrationActivationContext) + ); + let physicalRecords: readonly Readonly>[] = Object.freeze([]); + const physicalService = Object.freeze({ + register: ( + owner: { generation: object }, + registrations: readonly Readonly>[] + ) => { + physicalRecords = Object.freeze( + registrations.map((registration) => + Object.freeze({ + ...registration, + domAliases: registration['domAliases'] ?? Object.freeze([]), + navigationGeneration: owner.generation, + traceToken: 'gt1_1', + }) + ) + ); + return Object.freeze({ ok: true as const, records: physicalRecords }); + }, + resolveDomAlias: (alias: string) => + physicalRecords.find((record) => + (record['domAliases'] as readonly string[]).includes(alias) + ), + resolveRegisteredSlot: (slotId: string) => + physicalRecords.find((record) => record['registeredSlotId'] === slotId), + snapshotRegisteredSlots: () => physicalRecords, + }); + const releasePhysicalService = slots.attachPhysicalService(physicalService); + + expect(Reflect.ownKeys(trace)).toEqual([ + 'record', + 'enrich', + 'prune', + 'diagnostics', + 'observations', + ]); + expect(Object.isFrozen(trace)).toBe(true); + expect(Reflect.ownKeys(trace.observations)).toEqual(['publish']); + expect('attachPresentation' in trace).toBe(false); + expect(Reflect.ownKeys(tracePresentation)).toEqual(['attachPresentation']); + expect(Object.isFrozen(tracePresentation)).toBe(true); + expect(tracePresentation.attachPresentation).toBeTypeOf('function'); + expect(direct.diagnostics.renderTrace).toBe(trace.diagnostics); + expect(document.getElementById('ts-render-trace-panel')).toBeNull(); + expect( + trace.observations.publish( + Object.freeze({ + kind: 'render_attempt', + attemptId: 'attempt-one', + slotId: 'slot-one', + path: 'auction', + rendered: true, + injected: true, + servedFrom: 'inline', + state: 'accepted', + outcome: Object.freeze({ outcome: 'accepted' }), + }) + ) + ).toBe(true); + expect(trace.diagnostics.current()['slot-one']).toMatchObject({ + slotId: 'slot-one', + path: 'auction', + rendered: true, + injected: true, + servedFrom: 'inline', + }); + expect( + trace.observations.publish( + Object.freeze({ + kind: 'slotRequested', + slot: Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'slot-one' }), + }) + ) + ).toBe(true); + expect( + trace.observations.publish( + Object.freeze({ + kind: 'slotRenderEnded', + slot: Object.freeze({ token: 'gt1_1', cycleOrdinal: 1, elementId: 'slot-one' }), + isEmpty: false, + }) + ) + ).toBe(true); + expect(trace.diagnostics.current()['slot-one']).toMatchObject({ + count: 2, + path: 'gam-refresh', + rendered: true, + servedFrom: 'gam', + }); + expect(document.getElementById('ts-render-trace-panel')).toBeNull(); + + activationRelease.reverse().forEach((callback) => callback()); + releasePhysicalService(); + release.reverse().forEach((callback) => callback()); + expect(trace.diagnostics.current()).toEqual({}); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts index fc29e14c2..e640024d5 100644 --- a/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts @@ -1,21 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mirrorSourcepointConsent } from '../../../src/integrations/sourcepoint'; - -type SourcepointWindow = Window & { - __tsjs_sourcepoint?: { - rewriteSdk?: boolean; - }; - __tsjs_installSourcepointGuard?: unknown; -}; +import { + disposeSourcepointConsentMirror, + initializeSourcepointConsentMirror, + mirrorSourcepointConsent, +} from '../../../src/integrations/sourcepoint/consent_mirror'; +import { createSourcepointRuntime } from '../../../src/integrations/sourcepoint/module'; describe('Sourcepoint integration initialization', () => { - let win: SourcepointWindow; - beforeEach(async () => { - win = window as SourcepointWindow; - delete win.__tsjs_sourcepoint; - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); guard.resetGuardState(); }); @@ -23,37 +16,22 @@ describe('Sourcepoint integration initialization', () => { afterEach(async () => { const guard = await import('../../../src/integrations/sourcepoint/script_guard'); guard.resetGuardState(); - delete win.__tsjs_sourcepoint; - delete win.__tsjs_installSourcepointGuard; }); it('installs the guard when rewriteSdk is enabled', async () => { - vi.resetModules(); - win.__tsjs_sourcepoint = { rewriteSdk: true }; - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); - await import('../../../src/integrations/sourcepoint/index'); + const release = createSourcepointRuntime().activate(Object.freeze({ rewriteSdk: true })); expect(guard.isGuardInstalled()).toBe(true); + release(); }); it('skips the guard when rewriteSdk is disabled', async () => { - vi.resetModules(); - win.__tsjs_sourcepoint = { rewriteSdk: false }; - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); - await import('../../../src/integrations/sourcepoint/index'); + const release = createSourcepointRuntime().activate(Object.freeze({ rewriteSdk: false })); expect(guard.isGuardInstalled()).toBe(false); - }); - - it('defaults to installing the guard when rewriteSdk is missing for backward compatibility', async () => { - vi.resetModules(); - - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); - await import('../../../src/integrations/sourcepoint/index'); - - expect(guard.isGuardInstalled()).toBe(true); + release(); }); }); @@ -71,7 +49,7 @@ function sourcepointPayload(gppString = 'DBABLA~BVQqAAAAAgA.QA', applicableSecti describe('integrations/sourcepoint', () => { function clearAllCookies(): void { document.cookie.split(';').forEach((c) => { - const name = c.split('=')[0].trim(); + const name = c.split('=')[0]?.trim() ?? ''; if (name) document.cookie = `${name}=; path=/; Max-Age=0`; }); } @@ -83,11 +61,13 @@ describe('integrations/sourcepoint', () => { beforeEach(() => { // Clear cookies and localStorage before each test. + disposeSourcepointConsentMirror(); clearAllCookies(); localStorage.clear(); }); afterEach(() => { + disposeSourcepointConsentMirror(); vi.useRealTimers(); Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); clearAllCookies(); @@ -277,7 +257,7 @@ describe('integrations/sourcepoint', () => { JSON.stringify(sourcepointPayload('initial-gpp', [7])) ); - mirrorSourcepointConsent(); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', JSON.stringify(sourcepointPayload('updated-gpp', [8])) @@ -294,7 +274,7 @@ describe('integrations/sourcepoint', () => { JSON.stringify(sourcepointPayload('initial-gpp', [7])) ); - mirrorSourcepointConsent(); + initializeSourcepointConsentMirror(); localStorage.removeItem('_sp_user_consent_12345'); window.dispatchEvent(new Event('focus')); @@ -309,7 +289,7 @@ describe('integrations/sourcepoint', () => { localStorage.clear(); clearAllCookies(); - await import('../../../src/integrations/sourcepoint'); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', @@ -328,13 +308,13 @@ describe('integrations/sourcepoint', () => { clearAllCookies(); Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); - const sourcepoint = await import('../../../src/integrations/sourcepoint'); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', JSON.stringify(sourcepointPayload('manual-gpp', [7])) ); - expect(sourcepoint.mirrorSourcepointConsent()).toBe(true); + expect(mirrorSourcepointConsent()).toBe(true); localStorage.setItem( '_sp_user_consent_12345', @@ -354,7 +334,7 @@ describe('integrations/sourcepoint', () => { clearAllCookies(); Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); - await import('../../../src/integrations/sourcepoint'); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts new file mode 100644 index 000000000..8dd20232e --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createSourcepointIntegrationRegistration, + createSourcepointRuntime, +} from '../../../src/integrations/sourcepoint/module'; +import { createIntegrationRegistry } from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); +const CRITICAL_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; +const SOURCEPOINT_INTEGRATION_ID = 'sourcepoint_consent'; + +describe('transactional Sourcepoint integration module', () => { + it.each([true, false])( + 'owns the optional SDK guard and consent mirror when rewriteSdk=%s', + (rewriteSdk) => { + const order: string[] = []; + const runtime = createSourcepointRuntime({ + initializeConsentMirror: () => order.push('start:consent'), + installGuard: () => order.push('activate:guard'), + resetConsentMirror: () => order.push('dispose:consent'), + resetGuard: () => order.push('dispose:guard'), + }); + const config = Object.freeze({ rewriteSdk }); + + const release = runtime.activate(config); + runtime.start(config); + release(); + release(); + + expect(order).toEqual( + rewriteSdk + ? ['activate:guard', 'start:consent', 'dispose:consent', 'dispose:guard'] + : ['start:consent', 'dispose:consent'] + ); + } + ); + + it.each([ + ['missing', undefined], + ['mutable', { rewriteSdk: true }], + ['wrong type', Object.freeze({ rewriteSdk: 'yes' })], + ['extra', Object.freeze({ rewriteSdk: true, legacy: true })], + ])('rejects %s boot config before activation', async (_name, config) => { + const activate = vi.fn(() => vi.fn()); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: CRITICAL_SRC, + integrations: [{ id: SOURCEPOINT_INTEGRATION_ID, phase: 'critical' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze([SOURCEPOINT_INTEGRATION_ID]), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + [SOURCEPOINT_INTEGRATION_ID]: Object.freeze({ activate, start: vi.fn() }), + }), + }), + }); + registry.register(createSourcepointIntegrationRegistration(RELEASE_ID)); + + await expect( + registry.install({ activateCore: vi.fn(), publish: vi.fn(), drainPreload: vi.fn() }) + ).resolves.toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(activate).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts b/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts new file mode 100644 index 000000000..136f2b60f --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTestlightRuntime } from '../../../src/integrations/testlight/module'; + +describe('transactional Testlight integration module', () => { + it('bridges preexisting and later callbacks once while isolating invalid and throwing work', () => { + const calls: string[] = []; + const first = () => calls.push('first'); + const throwing = () => { + calls.push('throwing'); + throw new Error('publisher callback failed'); + }; + const second = () => calls.push('second'); + const beforeCommit = () => calls.push('before-commit'); + const afterCommit = () => calls.push('after-commit'); + const original = [first, 'invalid', throwing, second]; + const target = { testlight: { publisher: true, que: original } }; + const enqueue = vi.fn((callback: () => void) => callback()); + const runtime = createTestlightRuntime({ enqueue, started: vi.fn(), target }); + + const release = runtime.activate(undefined); + target.testlight.que.push(beforeCommit); + expect(calls).toEqual([]); + + runtime.start(undefined); + target.testlight.que.push(afterCommit); + + expect(calls).toEqual(['first', 'throwing', 'second', 'before-commit', 'after-commit']); + expect(enqueue).toHaveBeenCalledTimes(5); + release(); + release(); + expect(target.testlight).toEqual({ publisher: true, que: original }); + }); + + it('returns callbacks added during activation to the publisher queue on rollback', () => { + const original = [vi.fn()]; + const later = vi.fn(); + const target = { testlight: { que: original } }; + const runtime = createTestlightRuntime({ + enqueue: vi.fn(), + started: vi.fn(), + target, + }); + + const release = runtime.activate(undefined); + target.testlight.que.push(later); + release(); + + expect(target.testlight.que).toBe(original); + expect(original).toEqual([expect.any(Function), later]); + }); + + it('returns the captured native push result after forwarding a later callback', () => { + const callback = vi.fn(); + const target = { testlight: { que: [] as unknown[] } }; + const runtime = createTestlightRuntime({ + enqueue: (candidate) => candidate(), + started: vi.fn(), + target, + }); + + const release = runtime.activate(undefined); + runtime.start(undefined); + + expect(target.testlight.que.push(callback)).toBe(1); + expect(callback).toHaveBeenCalledOnce(); + expect(target.testlight.que).toHaveLength(0); + + release(); + }); + + it('does not overwrite a publisher queue replacement during disposal', () => { + const target = { testlight: { que: [] as unknown[] } }; + const runtime = createTestlightRuntime({ + enqueue: vi.fn(), + started: vi.fn(), + target, + }); + const release = runtime.activate(undefined); + const replacement: unknown[] = []; + target.testlight.que = replacement; + + release(); + + expect(target.testlight.que).toBe(replacement); + }); + + it('preserves publisher fields added to a runtime-created global', () => { + const target: { testlight?: { publisher?: boolean; que?: unknown[] } } = {}; + const runtime = createTestlightRuntime({ + enqueue: vi.fn(), + started: vi.fn(), + target, + }); + const release = runtime.activate(undefined); + if (!target.testlight) throw new Error('should create the Testlight global'); + target.testlight.publisher = true; + + release(); + + expect(target.testlight).toEqual({ publisher: true }); + }); + + it('snapshots queue data without invoking a publisher iterator', () => { + const callback = vi.fn(); + const original = [callback]; + Object.defineProperty(original, Symbol.iterator, { + configurable: true, + value: () => { + throw new Error('publisher iterator must remain inert'); + }, + }); + const target = { testlight: { que: original } }; + const enqueue = vi.fn((candidate: () => void) => candidate()); + const runtime = createTestlightRuntime({ enqueue, started: vi.fn(), target }); + + const release = runtime.activate(undefined); + expect(() => runtime.start(undefined)).not.toThrow(); + + expect(callback).toHaveBeenCalledOnce(); + release(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts new file mode 100644 index 000000000..6702a1d1d --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createDiagnosticsIngress, + type DiagnosticsObservation, +} from '../../src/kernel/diagnostics'; + +function scalarRecord(valueCount: number): Record { + return Object.fromEntries( + Array.from({ length: valueCount }, (_, index) => [`value${index}`, index]) + ); +} + +function nestedRecord(depth: number): Record { + const root: Record = {}; + let cursor = root; + for (let index = 0; index < depth; index += 1) { + const child: Record = {}; + cursor['child'] = child; + cursor = child; + } + return root; +} + +function primitiveLeafRecord(depth: number, value: unknown): Record { + const root: Record = {}; + let cursor = root; + for (let currentDepth = 1; currentDepth < depth; currentDepth += 1) { + const child: Record = {}; + cursor['child'] = child; + cursor = child; + } + cursor['leaf'] = value; + return root; +} + +describe('kernel diagnostics ingress', () => { + it('exposes only the exact frozen core-owned facade', () => { + const ingress = createDiagnosticsIngress({ reduce: vi.fn() }); + + expect(Object.isFrozen(ingress)).toBe(true); + expect(Reflect.ownKeys(ingress).sort()).toEqual(['dispose', 'publish']); + expect('subscribe' in ingress).toBe(false); + expect('consumerIds' in ingress).toBe(false); + expect('capacity' in ingress).toBe(false); + expect('queue' in ingress).toBe(false); + expect('scheduler' in ingress).toBe(false); + expect('timer' in ingress).toBe(false); + expect('overflow' in ingress).toBe(false); + }); + + it('copies ordinary and null-prototype data trees into fresh deeply frozen values', () => { + const reduced: DiagnosticsObservation[] = []; + const ingress = createDiagnosticsIngress({ + reduce: (observation) => reduced.push(observation), + }); + const nested = Object.assign(Object.create(null) as Record, { + label: '診断✓', + }); + const array = [nested, null, true, 3.25]; + const candidate = { array, name: 'publisher-value' }; + + expect(ingress.publish(candidate)).toBe(true); + expect(reduced).toHaveLength(1); + const accepted = reduced[0]!; + expect(accepted).not.toBe(candidate); + expect(Object.getPrototypeOf(accepted)).toBeNull(); + expect(Object.isFrozen(accepted)).toBe(true); + expect(accepted['array']).not.toBe(array); + expect(Object.isFrozen(accepted['array'])).toBe(true); + const acceptedArray = accepted['array'] as readonly unknown[]; + expect(acceptedArray[0]).not.toBe(nested); + expect(Object.getPrototypeOf(acceptedArray[0])).toBeNull(); + expect(Object.isFrozen(acceptedArray[0])).toBe(true); + expect(acceptedArray).toEqual([{ label: '診断✓' }, null, true, 3.25]); + }); + + it('accepts exactly 512 nodes and rejects 513 before reducer entry', () => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + + expect(ingress.publish(scalarRecord(510))).toBe(true); + expect(ingress.publish(scalarRecord(511))).toBe(true); + expect(ingress.publish(scalarRecord(512))).toBe(false); + expect(reduce).toHaveBeenCalledTimes(2); + }); + + it('accepts depth sixteen and rejects depth seventeen before reducer entry', () => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + + expect(ingress.publish(nestedRecord(15))).toBe(true); + expect(ingress.publish(nestedRecord(16))).toBe(true); + expect(ingress.publish(nestedRecord(17))).toBe(false); + expect(reduce).toHaveBeenCalledTimes(2); + }); + + it.each([ + [15, null, true], + [16, false, true], + [16, 42.25, true], + [16, '診断✓', true], + [17, null, false], + [17, false, false], + [17, 42.25, false], + [17, '診断✓', false], + ])('enforces the depth boundary for primitive leaf depth %i', (depth, value, accepted) => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + + expect(ingress.publish(primitiveLeafRecord(depth, value))).toBe(accepted); + expect(reduce).toHaveBeenCalledTimes(accepted ? 1 : 0); + }); + + it('enforces UTF-8 property-name and string byte limits including multibyte input', () => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + const property127 = 'a'.repeat(127); + const property128 = 'é'.repeat(64); + const property129 = `${'é'.repeat(64)}a`; + const string4095 = 'a'.repeat(4095); + const string4096 = 'é'.repeat(2048); + const string4097 = `${'é'.repeat(2048)}a`; + + expect(ingress.publish({ [property127]: string4095 })).toBe(true); + expect(ingress.publish({ [property128]: string4096 })).toBe(true); + expect(ingress.publish({ [property129]: 'value' })).toBe(false); + expect(ingress.publish({ value: string4097 })).toBe(false); + expect(reduce).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['sparse array', Object.assign(new Array(2), { 0: 'first' })], + ['array extra property', Object.assign(['first'], { extra: true })], + ['undefined', { value: undefined }], + // @ts-expect-error The runtime supports this hostile input even though the build target does not. + ['bigint', { value: 1n }], + ['function', { value: () => undefined }], + ['symbol value', { value: Symbol('fictional') }], + ['nonfinite number', { value: Number.POSITIVE_INFINITY }], + ['custom prototype', Object.freeze(new (class FictionalValue {})())], + ])('rejects %s values before reducer entry', (_label, candidate) => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + + expect(ingress.publish(candidate as Record)).toBe(false); + expect(reduce).not.toHaveBeenCalled(); + }); + + it('rejects aliases, cycles, accessors, symbols, and non-enumerable record fields', () => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + const shared = { value: true }; + const cycle: Record = {}; + cycle['self'] = cycle; + const accessor = Object.defineProperty({}, 'value', { + enumerable: true, + get: vi.fn(() => true), + }); + const symbol = Object.defineProperty({}, Symbol('fictional'), { + enumerable: true, + value: true, + }); + const hidden = Object.defineProperty({}, 'hidden', { + enumerable: false, + value: true, + }); + + expect(ingress.publish({ first: shared, second: shared })).toBe(false); + expect(ingress.publish(cycle)).toBe(false); + expect(ingress.publish(accessor)).toBe(false); + expect(ingress.publish(symbol)).toBe(false); + expect(ingress.publish(hidden)).toBe(false); + expect(reduce).not.toHaveBeenCalled(); + }); + + it('fails closed on hostile reflection and injected copy or freeze failures', () => { + const reduce = vi.fn(); + const reportError = vi.fn(() => { + throw new Error('fictional reporter failure'); + }); + const ingress = createDiagnosticsIngress({ reduce, reportError }); + const hostile = new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('fictional prototype trap'); + }, + } + ); + + expect(() => ingress.publish(hostile)).not.toThrow(); + expect(ingress.publish(hostile)).toBe(false); + const defineProperty = vi.spyOn(Object, 'defineProperty').mockImplementationOnce(() => { + throw new Error('fictional copy failure'); + }); + expect(ingress.publish({ acceptedShape: true })).toBe(false); + defineProperty.mockRestore(); + const freeze = vi.spyOn(Object, 'freeze').mockImplementationOnce(() => { + throw new Error('fictional freeze failure'); + }); + expect(ingress.publish({ acceptedShape: true })).toBe(false); + freeze.mockRestore(); + expect(reduce).not.toHaveBeenCalled(); + }); + + it('returns true after acceptance even when reducer and reporter throw', () => { + const reportError = vi.fn(() => { + throw new Error('fictional reporter failure'); + }); + const ingress = createDiagnosticsIngress({ + reduce: () => { + throw new Error('fictional reducer failure'); + }, + reportError, + }); + + expect(() => ingress.publish({ accepted: true })).not.toThrow(); + expect(ingress.publish({ accepted: true })).toBe(true); + expect(reportError).toHaveBeenCalledTimes(2); + }); + + it('disposes idempotently and makes retained runtime publishers inert', () => { + const reduce = vi.fn(); + const ingress = createDiagnosticsIngress({ reduce }); + const retainedPublish = ingress.publish; + + expect(retainedPublish({ sequence: 1 })).toBe(true); + ingress.dispose(); + ingress.dispose(); + expect(retainedPublish({ sequence: 2 })).toBe(false); + expect(reduce).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/disposable.test.ts b/crates/trusted-server-js/lib/test/kernel/disposable.test.ts new file mode 100644 index 000000000..bfb60dd66 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/disposable.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DisposableStack, TerminalLatch } from '../../src/kernel/disposable'; + +describe('DisposableStack', () => { + it('aborts and disposes in reverse order exactly once while isolating failures', () => { + const calls: string[] = []; + const errors: unknown[] = []; + const stack = new DisposableStack((error) => errors.push(error)); + + stack.onDispose(() => calls.push('first')); + stack.onDispose(() => { + calls.push('second'); + throw new Error('fictional disposer failure'); + }); + stack.onDispose(() => calls.push('third')); + stack.signal.addEventListener('abort', () => calls.push('abort')); + + stack.dispose(); + stack.dispose(); + + expect(stack.disposed).toBe(true); + expect(stack.signal.aborted).toBe(true); + expect(calls).toEqual(['abort', 'third', 'second', 'first']); + expect(errors).toHaveLength(1); + }); + + it('runs a disposer registered after disposal immediately and isolates its failure', () => { + const calls: string[] = []; + const onError = vi.fn(); + const stack = new DisposableStack(onError); + stack.dispose(); + + stack.onDispose(() => calls.push('late')); + stack.onDispose(() => { + throw new Error('late fictional failure'); + }); + + expect(calls).toEqual(['late']); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('observes a rejecting async disposer without delaying terminal disposal', async () => { + const onError = vi.fn(); + const stack = new DisposableStack(onError); + stack.onDispose(async () => { + throw new Error('fictional async disposer failure'); + }); + + stack.dispose(); + + expect(stack.disposed).toBe(true); + expect(stack.signal.aborted).toBe(true); + await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + }); +}); + +describe('TerminalLatch', () => { + it('lets only the first terminal result win and disposes before completion', async () => { + const events: string[] = []; + const latch = new TerminalLatch<{ outcome: string }>(); + latch.onDispose(() => events.push('disposed')); + latch.completion.then(() => events.push('completed')); + + expect(latch.trySettle({ outcome: 'accepted' })).toBe(true); + expect(latch.trySettle({ outcome: 'failed' })).toBe(false); + expect(latch.terminal).toBe(true); + expect(latch.value).toEqual({ outcome: 'accepted' }); + await expect(latch.completion).resolves.toEqual({ outcome: 'accepted' }); + expect(events).toEqual(['disposed', 'completed']); + }); + + it('supports undefined as a terminal value without reopening the latch', async () => { + const latch = new TerminalLatch(); + + expect(latch.trySettle(undefined)).toBe(true); + expect(latch.terminal).toBe(true); + expect(latch.trySettle(undefined)).toBe(false); + await expect(latch.completion).resolves.toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/fallback.test.ts b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts new file mode 100644 index 000000000..bb6f23a79 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildFallbackBoot, + buildKernelBoot, + trustedCriticalOrigin, +} from '../../src/kernel/fallback'; + +const RELEASE_ID = 'a'.repeat(64); +const TRUSTED_CRITICAL_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + +function opaqueDocument(stamp: PropertyDescriptor | undefined): Document { + const view = { location: { origin: 'null' } } as Record; + if (stamp) Object.defineProperty(view, '__tsCreativeOrigin', stamp); + return { defaultView: view } as unknown as Document; +} + +describe('critical artifact origin', () => { + it('accepts only the immutable own-data creative stamp for an opaque document', () => { + expect( + trustedCriticalOrigin( + opaqueDocument({ + configurable: false, + enumerable: false, + value: 'https://publisher.example', + writable: false, + }) + ) + ).toBe('https://publisher.example'); + }); + + it.each([ + undefined, + { configurable: true, enumerable: false, value: 'https://publisher.example', writable: false }, + { configurable: false, enumerable: false, value: 'https://publisher.example', writable: true }, + { configurable: false, enumerable: true, value: 'https://publisher.example', writable: false }, + { configurable: false, enumerable: false, get: () => 'https://publisher.example' }, + { + configurable: false, + enumerable: false, + value: 'https://attacker.example/path', + writable: false, + }, + ])('rejects an absent, mutable, accessor-backed, or non-origin creative stamp', (stamp) => { + expect(trustedCriticalOrigin(opaqueDocument(stamp))).toBeUndefined(); + }); +}); + +function manifest(ids: readonly string[]) { + return { + version: 1 as const, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => + id === 'diagnostics_presentation' + ? { + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=integrations/${id}.min.js?v=${'d'.repeat(64)}`, + } + : { id, phase: 'critical' as const } + ), + }; +} + +function boot( + creative: unknown, + diagnostics: Readonly<{ + renderTraceOverlay: boolean; + gptActive: boolean; + }> = { renderTraceOverlay: false, gptActive: false } +) { + return { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative, + diagnostics: { + version: 1, + renderTraceOverlay: diagnostics.renderTraceOverlay, + gpt: { active: diagnostics.gptActive }, + }, + }; +} + +describe('kernel boot creative ABI', () => { + it.each([ + { version: 1, enabled: false, clickGuard: true, renderGuard: false }, + { version: 1, enabled: false, clickGuard: false, renderGuard: true }, + ])('rejects disabled creative with an enabled guard bit', (creative) => { + expect(buildKernelBoot(RELEASE_ID, manifest([]), boot(creative))).toBeUndefined(); + }); + + it('rejects a null-prototype creative record', () => { + const creative = Object.assign(Object.create(null) as object, { + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false, + }); + + expect(buildKernelBoot(RELEASE_ID, manifest([]), boot(creative))).toBeUndefined(); + }); + + it.each([ + ['enabled creative without a manifest member', true, []], + ['enabled creative with duplicate manifest members', true, ['creative', 'creative']], + ['disabled creative with a manifest member', false, ['creative']], + ] as const)('rejects %s', (_caseName, enabled, ids) => { + expect( + buildKernelBoot( + RELEASE_ID, + manifest(ids), + boot({ version: 1, enabled, clickGuard: false, renderGuard: false }) + ) + ).toBeUndefined(); + }); + + it('accepts enabled creative with both guards false only with one manifest member', () => { + const accepted = buildKernelBoot( + RELEASE_ID, + manifest(['creative']), + boot({ version: 1, enabled: true, clickGuard: false, renderGuard: false }) + ) as { readonly creative?: unknown } | undefined; + + expect(accepted?.creative).toEqual({ + version: 1, + enabled: true, + clickGuard: false, + renderGuard: false, + }); + expect(Object.isFrozen(accepted?.creative)).toBe(true); + }); +}); + +describe('terminal fallback boot manifest', () => { + it('uses the independently trusted critical source when the manifest field is missing', () => { + const fallback = buildFallbackBoot( + RELEASE_ID, + { + ...boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [], + }, + }, + TRUSTED_CRITICAL_SRC + ) as { readonly manifest: unknown }; + + expect(fallback.manifest).toEqual({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: TRUSTED_CRITICAL_SRC, + integrations: [], + }); + }); + + it('uses the independently trusted critical source when the manifest field is malformed', () => { + const fallback = buildFallbackBoot( + RELEASE_ID, + { + ...boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), + manifest: { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'e'.repeat(64)}&publisher=1`, + integrations: [], + }, + }, + TRUSTED_CRITICAL_SRC + ) as { readonly manifest: unknown }; + + expect(fallback.manifest).toEqual({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: TRUSTED_CRITICAL_SRC, + integrations: [], + }); + }); + + it('refuses to construct a fallback boot without an independently trusted critical source', () => { + expect( + buildFallbackBoot( + RELEASE_ID, + boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), + undefined as never + ) + ).toBeUndefined(); + }); + + it('publishes the exact phase-aware fallback manifest with the accepted critical source', () => { + const acceptedManifest = manifest(['render_runtime', 'diagnostics_presentation']); + const fallback = buildFallbackBoot( + RELEASE_ID, + boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), + acceptedManifest.criticalSrc + ) as { readonly manifest: unknown }; + + expect(fallback.manifest).toEqual({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: acceptedManifest.criticalSrc, + integrations: [], + }); + expect(Reflect.ownKeys(fallback.manifest as object).sort()).toEqual([ + 'criticalSrc', + 'integrations', + 'releaseId', + 'version', + ]); + expect(Object.isFrozen(fallback.manifest)).toBe(true); + }); +}); + +describe('kernel boot diagnostics presentation membership', () => { + const disabledCreative = Object.freeze({ + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false, + }); + + it.each([ + { renderTraceOverlay: false, gptActive: false, presentation: false }, + { renderTraceOverlay: true, gptActive: false, presentation: true }, + { renderTraceOverlay: false, gptActive: true, presentation: true }, + { renderTraceOverlay: true, gptActive: true, presentation: true }, + ])( + 'accepts diagnostics_presentation iff overlay=$renderTraceOverlay or GPT=$gptActive', + ({ renderTraceOverlay, gptActive, presentation }) => { + const ids = [ + ...(gptActive ? ['gpt_diagnostics'] : []), + ...(presentation ? ['diagnostics_presentation'] : []), + ]; + + expect( + buildKernelBoot( + RELEASE_ID, + manifest(ids), + boot(disabledCreative, { renderTraceOverlay, gptActive }) + ) + ).toBeDefined(); + } + ); + + it.each([ + { renderTraceOverlay: false, gptActive: false, presentation: true }, + { renderTraceOverlay: true, gptActive: false, presentation: false }, + { renderTraceOverlay: false, gptActive: true, presentation: false }, + { renderTraceOverlay: true, gptActive: true, presentation: false }, + ])( + 'rejects the inverse diagnostics_presentation membership for overlay=$renderTraceOverlay and GPT=$gptActive', + ({ renderTraceOverlay, gptActive, presentation }) => { + const ids = [ + ...(gptActive ? ['gpt_diagnostics'] : []), + ...(presentation ? ['diagnostics_presentation'] : []), + ]; + + expect( + buildKernelBoot( + RELEASE_ID, + manifest(ids), + boot(disabledCreative, { renderTraceOverlay, gptActive }) + ) + ).toBeUndefined(); + } + ); + + it('accepts the complete server-shaped phase-aware boot manifest', () => { + const expectedManifest = manifest(['render_runtime']); + const candidate = { + abi: 1, + releaseId: RELEASE_ID, + manifest: expectedManifest, + ...boot(disabledCreative), + }; + + expect(buildKernelBoot(RELEASE_ID, expectedManifest, candidate)).toBeDefined(); + }); + + it.each([ + [19, true], + [20, true], + [21, false], + ] as const)('accepts at most %i complete server manifest integrations', (count, accepted) => { + const expectedManifest = manifest( + Array.from({ length: count }, (_, index) => `integration_${index + 1}`) + ); + const candidate = { + abi: 1, + releaseId: RELEASE_ID, + manifest: expectedManifest, + ...boot(disabledCreative), + }; + + expect(buildKernelBoot(RELEASE_ID, expectedManifest, candidate) !== undefined).toBe(accepted); + }); + + it('rejects a complete boot whose phase-aware manifest differs from the accepted manifest', () => { + const expectedManifest = manifest(['render_runtime']); + const candidateManifest = { + ...expectedManifest, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'e'.repeat(64)}`, + }; + + expect( + buildKernelBoot(RELEASE_ID, expectedManifest, { + abi: 1, + releaseId: RELEASE_ID, + manifest: candidateManifest, + ...boot(disabledCreative), + }) + ).toBeUndefined(); + }); + + it.each(['diagnostics root', 'diagnostics GPT child'] as const)( + 'rejects a null-prototype %s', + (target) => { + const candidate = boot(disabledCreative); + const diagnostics = + target === 'diagnostics root' + ? Object.assign(Object.create(null) as object, candidate.diagnostics) + : { + ...candidate.diagnostics, + gpt: Object.assign(Object.create(null) as object, candidate.diagnostics.gpt), + }; + + expect( + buildKernelBoot(RELEASE_ID, manifest([]), { + ...candidate, + diagnostics, + }) + ).toBeUndefined(); + } + ); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/identity.test.ts b/crates/trusted-server-js/lib/test/kernel/identity.test.ts new file mode 100644 index 000000000..6c05f3244 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/identity.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createBrowserNavigationIdentityIssuer, + createTestNavigationIdentityIssuer, + mintTestLifecycleTicket, + mintTestRendererNonce, + type RandomValuesSource, +} from '../../src/kernel/identity'; + +function decodeIdentity(value: string): Buffer { + return Buffer.from(value.slice(3), 'base64url'); +} + +function deterministicSource(bytes: readonly number[]): { + readonly source: RandomValuesSource; + readonly calls: ReturnType; +} { + let offset = 0; + const calls = vi.fn((target: Uint8Array): Uint8Array => { + for (let index = 0; index < target.length; index += 1) { + target[index] = bytes[offset % bytes.length] ?? 0; + offset += 1; + } + return target; + }); + return { source: calls, calls }; +} + +describe('navigation identity issuer', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('draws one eight-byte prefix and increments a big-endian u64 ordinal once per attempt', () => { + const { source, calls } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); + const created = createTestNavigationIdentityIssuer({ getRandomValues: source }); + + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + + const first = created.value.mintAttemptId(); + const second = created.value.mintAttemptId(); + + expect(first).toEqual({ ok: true, value: 'a1_AAECAwQFBgcAAAAAAAAAAQ' }); + expect(second).toEqual({ ok: true, value: 'a1_AAECAwQFBgcAAAAAAAAAAg' }); + expect(first.ok && decodeIdentity(first.value)).toEqual( + Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 1]) + ); + expect(second.ok && decodeIdentity(second.value)).toEqual( + Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 2]) + ); + expect(first.ok && first.value).toHaveLength(25); + expect(second.ok && second.value).toHaveLength(25); + expect(calls).toHaveBeenCalledOnce(); + expect(calls.mock.calls[0]?.[0]).toHaveLength(8); + expect(created.value.snapshotOrdinalForTest()).toEqual([0, 2]); + }); + + it('owns an immutable copy of the source-filled navigation prefix', () => { + let sourceBuffer: Uint8Array | undefined; + const created = createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.set([0, 1, 2, 3, 4, 5, 6, 7]); + sourceBuffer = target; + return target; + }, + }); + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + + sourceBuffer?.fill(255); + + expect(created.value.mintAttemptId()).toEqual({ + ok: true, + value: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + }); + }); + + it('survives detachment of the source-filled navigation prefix buffer', () => { + let sourceBuffer: Uint8Array | undefined; + const created = createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.set([0, 1, 2, 3, 4, 5, 6, 7]); + sourceBuffer = target; + return target; + }, + }); + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + if (!sourceBuffer) throw new Error('Expected the source buffer'); + + structuredClone(sourceBuffer.buffer, { transfer: [sourceBuffer.buffer] }); + + expect(sourceBuffer.byteLength).toBe(0); + expect(created.value.mintAttemptId()).toEqual({ + ok: true, + value: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + }); + }); + + it('contains mint buffer and view failures behind the typed identity failure', () => { + const failure = vi.fn(); + const { source } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); + const created = createTestNavigationIdentityIssuer({ + getRandomValues: source, + onFailure: failure, + }); + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + vi.stubGlobal( + 'DataView', + class { + public constructor() { + throw new Error('sensitive detached view failure'); + } + } + ); + + expect(created.value.mintAttemptId()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(failure.mock.calls).toEqual([['identity_generation_failed']]); + expect(created.value.snapshotOrdinalForTest()).toEqual([0, 0]); + }); + + it('fails closed when a mint view silently leaves ordinal bytes unwritten', () => { + const failure = vi.fn(); + const { source } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); + const created = createTestNavigationIdentityIssuer({ + getRandomValues: source, + onFailure: failure, + }); + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + vi.stubGlobal( + 'DataView', + class { + public setUint32(): void {} + } + ); + + expect(created.value.mintAttemptId()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(failure.mock.calls).toEqual([['identity_generation_failed']]); + expect(created.value.snapshotOrdinalForTest()).toEqual([0, 0]); + }); + + it('issues the final ordinal once and then fails forever without wrapping', () => { + const { source } = deterministicSource([8, 7, 6, 5, 4, 3, 2, 1]); + const failure = vi.fn(); + const created = createTestNavigationIdentityIssuer({ + getRandomValues: source, + initialOrdinal: [0xffff_ffff, 0xffff_fffe], + onFailure: failure, + }); + + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an identity issuer'); + + expect(created.value.mintAttemptId()).toMatchObject({ ok: true }); + expect(created.value.snapshotOrdinalForTest()).toEqual([0xffff_ffff, 0xffff_ffff]); + expect(created.value.mintAttemptId()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(created.value.mintAttemptId()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(created.value.snapshotOrdinalForTest()).toEqual([0xffff_ffff, 0xffff_ffff]); + expect(failure).toHaveBeenCalledTimes(2); + expect(failure.mock.calls).toEqual([ + ['identity_generation_failed'], + ['identity_generation_failed'], + ]); + }); + + it('fails before creating an issuer when browser crypto is missing or throws', () => { + vi.stubGlobal('crypto', undefined); + expect(createBrowserNavigationIdentityIssuer()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + + vi.stubGlobal('crypto', { + getRandomValues: () => { + throw new Error('unavailable'); + }, + }); + expect(createBrowserNavigationIdentityIssuer()).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + }); + + it('reports prefix failures without exposing raw bytes or identities', () => { + const failure = vi.fn(); + const created = createTestNavigationIdentityIssuer({ + getRandomValues: () => { + throw new Error('sensitive source failure'); + }, + onFailure: failure, + }); + + expect(created).toEqual({ ok: false, reason: 'identity_generation_failed' }); + expect(failure.mock.calls).toEqual([['identity_generation_failed']]); + }); +}); + +describe('fresh capability identities', () => { + it('encodes each lifecycle ticket from sixteen fresh CSPRNG bytes', () => { + const { source, calls } = deterministicSource([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + ]); + + const first = mintTestLifecycleTicket(source); + const second = mintTestLifecycleTicket(source); + + expect(first).toEqual({ ok: true, value: 't1_AAECAwQFBgcICQoLDA0ODw' }); + expect(second).toEqual({ ok: true, value: 't1_AAECAwQFBgcICQoLDA0ODw' }); + expect(first.ok && first.value).toHaveLength(25); + expect(first.ok && decodeIdentity(first.value)).toHaveLength(16); + expect(calls).toHaveBeenCalledTimes(2); + expect(calls.mock.calls[0]?.[0]).not.toBe(calls.mock.calls[1]?.[0]); + }); + + it('encodes each renderer nonce from sixteen fresh CSPRNG bytes', () => { + const { source, calls } = deterministicSource([ + 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, + ]); + + const result = mintTestRendererNonce(source); + + expect(result).toEqual({ ok: true, value: 'n1_Dw4NDAsKCQgHBgUEAwIBAA' }); + expect(result.ok && result.value).toHaveLength(25); + expect(result.ok && decodeIdentity(result.value)).toHaveLength(16); + expect(calls).toHaveBeenCalledOnce(); + expect(calls.mock.calls[0]?.[0]).toHaveLength(16); + }); + + it('maps ticket and nonce source failures without leaking source values', () => { + const failure = vi.fn(); + const source = () => { + throw new Error('sensitive source failure'); + }; + + expect(mintTestLifecycleTicket(source, failure)).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(mintTestRendererNonce(source, failure)).toEqual({ + ok: false, + reason: 'identity_generation_failed', + }); + expect(failure.mock.calls).toEqual([ + ['identity_generation_failed'], + ['identity_generation_failed'], + ]); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts new file mode 100644 index 000000000..3b2af5a21 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts @@ -0,0 +1,1752 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BootManifestV1 } from '../../src/core/types'; +import { + createIntegrationRegistry as createIntegrationRegistryOwner, + type IntegrationPrepareContext, + type IntegrationRegistration, + type IntegrationRegistryOptions, +} from '../../src/kernel/integration_registry'; +import { snapshotPersistentFirstDisplayAdoptionV1 } from '../../src/shared/takeover'; + +const RELEASE_ID = 'a'.repeat(64); +const OTHER_RELEASE_ID = 'b'.repeat(64); + +type TestRegistryOptions = Omit & { + readonly knownIntegrationIds?: readonly string[]; +}; + +function manifestIds(candidate: unknown): readonly string[] { + if (typeof candidate !== 'object' || candidate === null) return Object.freeze([]); + const integrations = (candidate as { integrations?: unknown }).integrations; + if (!Array.isArray(integrations)) return Object.freeze([]); + + const ids: string[] = []; + for (let index = 0; index < integrations.length; index += 1) { + const entry = integrations[index] as { id?: unknown } | undefined; + if (typeof entry?.id === 'string') ids.push(entry.id); + } + return Object.freeze([...new Set(ids)]); +} + +function createIntegrationRegistry(options: TestRegistryOptions) { + const knownIntegrationIds = options.knownIntegrationIds ?? manifestIds(options.manifest); + return createIntegrationRegistryOwner({ + ...options, + knownIntegrationIds, + catalog: Object.freeze( + knownIntegrationIds.map((id) => + Object.freeze({ + id, + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }) + ) + ), + }); +} + +function manifest(ids: readonly string[]): BootManifestV1 { + return { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => ({ id, phase: 'critical' as const })), + }; +} + +function registration( + id: string, + hooks: Partial = {} +): IntegrationRegistration { + return { + abi: 1, + id, + phase: 'critical', + releaseId: RELEASE_ID, + prepare: () => ({ activate: () => undefined }), + ...hooks, + }; +} + +async function install( + registry: ReturnType, + order: string[] = [] +) { + return registry.install({ + activateCore: () => undefined, + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); +} + +afterEach(() => { + vi.useRealTimers(); + document.head.replaceChildren(); + Object.defineProperty(document, 'currentScript', { configurable: true, value: null }); +}); + +describe('integration manifest and registration admission', () => { + it('offers one synchronous activation/commit barrier after every preparation completes', async () => { + const order: string[] = []; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({}), + identities: Object.freeze([{}]), + }); + expect(snapshotPersistentFirstDisplayAdoptionV1(adoption)).toBe(adoption); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => { + order.push('module:prepare'); + return { + activate: ({ adoption: received, afterCommit }) => { + expect(received).toBe(adoption); + order.push('module:activate'); + afterCommit(() => order.push('after-commit')); + }, + }; + }, + }) + ); + + const result = await registry.install({ + prepareCore: () => order.push('core:prepare'), + activateCore: () => order.push('core:activate'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + coordinateTakeover: (prepared) => { + expect(Object.isFrozen(prepared)).toBe(true); + expect(order).toEqual(['core:prepare', 'module:prepare']); + prepared.activate(adoption); + expect(() => prepared.activate()).toThrow(); + expect(order).toEqual([ + 'core:prepare', + 'module:prepare', + 'core:activate', + 'module:activate', + ]); + prepared.commit(); + }, + }); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'core:prepare', + 'module:prepare', + 'core:activate', + 'module:activate', + 'publish', + 'after-commit', + 'drain', + ]); + }); + + it('fails closed when a takeover coordinator returns without committing', async () => { + const activate = vi.fn(); + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ activate }), + }) + ); + + await expect( + registry.install({ + activateCore: () => undefined, + publish, + drainPreload: () => undefined, + coordinateTakeover: () => undefined, + }) + ).resolves.toEqual({ state: 'fallback', reason: 'bundle_partial' }); + expect(activate).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('accepts only the exact five-field release-bound registrar ABI', () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + const exact = registration('gpt'); + + expect(Reflect.ownKeys(exact)).toEqual(['abi', 'id', 'phase', 'releaseId', 'prepare']); + expect(registry.register(exact)).toBe(true); + }); + + it.each([ + ['old three-field ABI', { id: 'gpt', release: RELEASE_ID, prepare: vi.fn() }], + ['missing abi', { id: 'gpt', phase: 'critical', releaseId: RELEASE_ID, prepare: vi.fn() }], + ['unknown field', { ...registration('gpt'), unexpected: true }], + ['wrong phase', { ...registration('gpt'), phase: 'deferred' }], + ['custom prototype', Object.assign(Object.create({ inherited: true }), registration('gpt'))], + ['null prototype', Object.assign(Object.create(null), registration('gpt'))], + ])('rejects %s without invoking module code', async (_name, candidate) => { + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(candidate)).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + const prepare = (candidate as { prepare?: unknown }).prepare; + if (vi.isMockFunction(prepare)) expect(prepare).not.toHaveBeenCalled(); + }); + + it('authenticates every critical registration to the captured connected core script', () => { + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + document.head.append(criticalScript); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: criticalScript, + }); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + ]), + criticalScript, + document, + knownIntegrationIds: Object.freeze(['gpt']), + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('gpt'))).toBe(true); + }); + + it.each(['different current script', 'disconnected script', 'wrong exact source'])( + 'rejects a critical registration from a %s', + (failure) => { + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + document.head.append(criticalScript); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: criticalScript, + }); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + ]), + criticalScript, + document, + knownIntegrationIds: Object.freeze(['gpt']), + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + if (failure === 'different current script') { + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: document.createElement('script'), + }); + } else if (failure === 'disconnected script') { + criticalScript.remove(); + } else { + criticalScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + } + + expect(registry.register(registration('gpt'))).toBe(false); + expect(registry.state).toBe('failed'); + } + ); + + it('exposes only a frozen facade while mutable registry state stays in a closure', () => { + const registry = createIntegrationRegistry({ + manifest: manifest([]), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(Object.isFrozen(registry)).toBe(true); + expect(Reflect.ownKeys(registry).sort()).toEqual([ + 'dispose', + 'install', + 'manifest', + 'prepareDeferred', + 'register', + 'state', + ]); + expect('registrations' in registry).toBe(false); + expect('prepared' in registry).toBe(false); + registry.dispose(); + }); + + it('rejects an integration array with executable iteration without invoking it', async () => { + const iterator = vi.fn(function* () { + for (let index = 0; index < 21; index += 1) { + yield { id: `module_${index}`, phase: 'critical' }; + } + }); + const integrations: unknown[] = []; + Object.defineProperty(integrations, Symbol.iterator, { value: iterator }); + const registry = createIntegrationRegistry({ + manifest: { version: 1, releaseId: RELEASE_ID, integrations }, + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(iterator).not.toHaveBeenCalled(); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it.each([ + ['non-object', null], + ['wrong version', { ...manifest([]), version: 2 }], + ['extra manifest field', { ...manifest([]), unexpected: true }], + ['wrong release grammar', { ...manifest([]), releaseId: 'ABC' }], + ['malformed id', { ...manifest([]), integrations: [{ id: 'Uppercase', required: true }] }], + [ + 'unknown integration field', + { ...manifest([]), integrations: [{ id: 'gpt', required: true, optional: false }] }, + ], + ['non-required entry', { ...manifest([]), integrations: [{ id: 'gpt', required: false }] }], + [ + 'duplicate id', + { + ...manifest([]), + integrations: [ + { id: 'gpt', required: true }, + { id: 'gpt', required: true }, + ], + }, + ], + ['over capacity', manifest(Array.from({ length: 21 }, (_, index) => `module_${index}`))], + ])('rejects a malformed manifest: %s', async (_name, candidate) => { + const registry = createIntegrationRegistry({ + manifest: candidate, + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('gpt'))).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it('requires the embedded release, manifest release, and bundle release to match', async () => { + const registry = createIntegrationRegistry({ + manifest: { ...manifest(['gpt']), releaseId: OTHER_RELEASE_ID }, + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('gpt'))).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it('rejects a syntactically valid manifest id outside the frozen core bundle inventory', async () => { + const prepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['evil']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('evil', { prepare }))).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect(prepare).not.toHaveBeenCalled(); + }); + + it.each([ + ['unknown id', registration('unknown')], + ['wrong bundle release', registration('gpt', { releaseId: OTHER_RELEASE_ID })], + ])('quarantines %s before prepare is called', async (_name, candidate) => { + const prepare = vi.fn(candidate.prepare); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register({ ...candidate, prepare })).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect(prepare).not.toHaveBeenCalled(); + }); + + it('rejects registration accessors without invoking bundle code during collection', async () => { + const prepareGetter = vi.fn(() => () => ({ activate: () => undefined })); + const candidate = Object.defineProperties( + {}, + { + id: { value: 'gpt', enumerable: true }, + release: { value: RELEASE_ID, enumerable: true }, + prepare: { get: prepareGetter, enumerable: true }, + } + ); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(candidate)).toBe(false); + expect(prepareGetter).not.toHaveBeenCalled(); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it('rejects duplicate registration without invoking either module', async () => { + const firstPrepare = vi.fn(() => ({ activate: () => undefined })); + const secondPrepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('gpt', { prepare: firstPrepare }))).toBe(true); + expect(registry.register(registration('gpt', { prepare: secondPrepare }))).toBe(false); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect(firstPrepare).not.toHaveBeenCalled(); + expect(secondPrepare).not.toHaveBeenCalled(); + }); + + it('rejects a critical registration that skips the next manifest entry', async () => { + const prepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(registration('prebid', { prepare }))).toBe(false); + expect(registry.state).toBe('failed'); + expect(prepare).not.toHaveBeenCalled(); + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it('snapshots accepted registration code so retained objects cannot swap it later', async () => { + const acceptedPrepare = vi.fn(() => ({ activate: () => undefined })); + const swappedPrepare = vi.fn(() => ({ + activate: () => { + throw new Error('must never execute'); + }, + })); + const candidate = { + abi: 1 as const, + id: 'gpt', + phase: 'critical' as const, + releaseId: RELEASE_ID, + prepare: acceptedPrepare, + }; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + expect(registry.register(candidate)).toBe(true); + candidate.id = 'unknown'; + candidate.releaseId = OTHER_RELEASE_ID; + candidate.prepare = swappedPrepare; + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + expect(acceptedPrepare).toHaveBeenCalledTimes(1); + expect(swappedPrepare).not.toHaveBeenCalled(); + }); + + it('waits for required modules registered after install starts without early execution', async () => { + const order: string[] = []; + const gptPrepare = vi.fn(() => { + order.push('prepare:gpt'); + return { activate: () => order.push('activate:gpt') }; + }); + const prebidPrepare = vi.fn(() => { + order.push('prepare:prebid'); + return { activate: () => order.push('activate:prebid') }; + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register(registration('gpt', { prepare: gptPrepare })); + + const installed = install(registry, order); + await Promise.resolve(); + expect(registry.state).toBe('collecting'); + expect(order).toEqual([]); + expect(registry.register(registration('prebid', { prepare: prebidPrepare }))).toBe(true); + + await expect(installed).resolves.toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'prepare:gpt', + 'prepare:prebid', + 'activate:gpt', + 'activate:prebid', + 'publish', + 'drain', + ]); + }); + + it('fails missing required modules only at the shared boot deadline', async () => { + vi.useFakeTimers(); + let now = 0; + const prepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => now, + }); + registry.register(registration('gpt', { prepare })); + + const installed = install(registry); + await vi.advanceTimersByTimeAsync(9_999); + expect(registry.state).toBe('collecting'); + expect(prepare).not.toHaveBeenCalled(); + now = 10_000; + await vi.advanceTimersByTimeAsync(1); + + await expect(installed).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(prepare).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('accepts exactly 14 critical modules in manifest order', async () => { + const ids = Array.from({ length: 14 }, (_, index) => `module_${index}`); + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(ids), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + for (const id of ids) { + expect( + registry.register( + registration(id, { + prepare: () => { + order.push(`prepare:${id}`); + return { activate: () => order.push(`activate:${id}`) }; + }, + }) + ) + ).toBe(true); + } + + await expect(install(registry, order)).resolves.toMatchObject({ state: 'kernel' }); + expect(order.slice(0, 14)).toEqual(ids.map((id) => `prepare:${id}`)); + expect(order.slice(14, 28)).toEqual(ids.map((id) => `activate:${id}`)); + expect(order.slice(28)).toEqual(['publish', 'drain']); + }); +}); + +describe('integration preparation and activation transaction', () => { + it('stages only declared provider capabilities for later critical consumers', async () => { + const gpt = Object.freeze({ kind: 'gpt' }); + let consumerInterfaces: Readonly> | undefined; + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['gpt.v1']), + }), + Object.freeze({ + id: 'prebid', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze(['gpt.v1']), + provides: Object.freeze([]), + }), + ]), + knownIntegrationIds: Object.freeze(['gpt', 'prebid']), + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + runtimeCapability: Object.freeze({ kind: 'runtime' }), + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: ({ interfaces }) => { + expect(Reflect.ownKeys(interfaces)).toEqual(['runtime.v1']); + return { activate: () => undefined, interfaces: Object.freeze({ 'gpt.v1': gpt }) }; + }, + }) + ); + registry.register( + registration('prebid', { + prepare: ({ interfaces }) => { + consumerInterfaces = interfaces; + return { activate: () => undefined, interfaces: Object.freeze({}) }; + }, + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + expect(consumerInterfaces).toEqual(Object.freeze({ 'gpt.v1': gpt })); + expect(Object.isFrozen(consumerInterfaces)).toBe(true); + expect(Reflect.ownKeys(consumerInterfaces ?? {})).toEqual(['gpt.v1']); + }); + + it('prepares a deferred consumer from committed critical capabilities only', async () => { + const gpt = Object.freeze({ kind: 'gpt' }); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze(['gpt.v1']), + }), + Object.freeze({ + id: 'gpt_later', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + consumes: Object.freeze(['gpt.v1']), + provides: Object.freeze([]), + }), + ]), + knownIntegrationIds: Object.freeze(['gpt', 'gpt_later']), + manifest: { + ...manifest(['gpt']), + integrations: Object.freeze([ + Object.freeze({ id: 'gpt', phase: 'critical' as const }), + Object.freeze({ + id: 'gpt_later', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=tsjs-gpt_later.min.js?v=${'d'.repeat(64)}`, + }), + ]), + }, + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: () => undefined, + interfaces: Object.freeze({ 'gpt.v1': gpt }), + }), + }) + ); + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + + const prepare = vi.fn(({ interfaces }: IntegrationPrepareContext) => { + expect(interfaces).toEqual(Object.freeze({ 'gpt.v1': gpt })); + return { activate: () => undefined }; + }); + const prepared = registry.prepareDeferred( + { ...registration('gpt_later', { prepare }), phase: 'deferred' }, + Object.freeze({ + signal: new AbortController().signal, + onDispose: vi.fn(), + }) + ); + + expect(prepare).toHaveBeenCalledTimes(1); + expect(prepared).toMatchObject({ activate: expect.any(Function) }); + }); + + it.each([ + ['missing declared key', Object.freeze({})], + ['unknown key', Object.freeze({ 'gpt.v1': Object.freeze({}), 'other.v1': Object.freeze({}) })], + ['mutable facade', Object.freeze({ 'gpt.v1': {} })], + [ + 'custom facade prototype', + Object.freeze({ 'gpt.v1': Object.freeze(Object.create({ inherited: true })) }), + ], + ])('rejects provider interfaces with a %s', async (_name, interfaces) => { + const prepareConsumer = vi.fn(() => ({ + activate: () => undefined, + interfaces: Object.freeze({}), + })); + const registry = createIntegrationRegistryOwner({ + catalog: Object.freeze([ + Object.freeze({ + id: 'gpt', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze(['gpt.v1']), + }), + Object.freeze({ + id: 'prebid', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze(['gpt.v1']), + provides: Object.freeze([]), + }), + ]), + knownIntegrationIds: Object.freeze(['gpt', 'prebid']), + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ activate: () => undefined, interfaces }), + }) + ); + registry.register(registration('prebid', { prepare: prepareConsumer })); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(prepareConsumer).not.toHaveBeenCalled(); + }); + + it('prepares core-owned bindings before module preparation and activates afterward', async () => { + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + getBindings: () => { + order.push('bindings'); + return { config: Object.freeze({}), interfaces: Object.freeze({}) }; + }, + }); + registry.register( + registration('gpt', { + prepare: () => { + order.push('module:prepare'); + return { activate: () => order.push('module:activate') }; + }, + }) + ); + + const result = await registry.install({ + prepareCore: () => order.push('core:prepare'), + activateCore: () => order.push('core:activate'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'core:prepare', + 'bindings', + 'module:prepare', + 'core:activate', + 'module:activate', + 'publish', + 'drain', + ]); + }); + + it('unwinds core-prepared resources when later module preparation fails', async () => { + const release = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => { + throw new Error('fictional preparation failure'); + }, + }) + ); + + const result = await registry.install({ + prepareCore: ({ onDispose }) => onDispose(release), + activateCore: vi.fn(), + publish: vi.fn(), + drainPreload: vi.fn(), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('collects without execution, prepares sequentially, and commits in exact order', async () => { + const order: string[] = []; + const contexts: IntegrationPrepareContext[] = []; + let finishGpt: (() => void) | undefined; + const gptPrepared = new Promise((resolve) => { + finishGpt = resolve; + }); + const frozenConfig = Object.freeze({ enabled: true }); + const frozenInterfaces = Object.freeze({ adapter: Object.freeze({ kind: 'fake' }) }); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + getBindings: (id) => ({ + config: id === 'gpt' ? frozenConfig : Object.freeze({ enabled: false }), + interfaces: frozenInterfaces, + }), + }); + registry.register( + registration('gpt', { + prepare: async (context) => { + contexts.push(context); + order.push('prepare:gpt:start'); + await gptPrepared; + order.push('prepare:gpt:end'); + return { + activate: (activation) => { + order.push('activate:gpt'); + activation.afterCommit(() => order.push('after:gpt')); + }, + }; + }, + }) + ); + registry.register( + registration('prebid', { + prepare: (context) => { + contexts.push(context); + order.push('prepare:prebid'); + return { + activate: (activation) => { + order.push('activate:prebid'); + activation.afterCommit(() => order.push('after:prebid')); + }, + }; + }, + }) + ); + + expect(order).toEqual([]); + const installed = install(registry, order); + await vi.waitFor(() => expect(order).toEqual(['prepare:gpt:start'])); + expect(order).not.toContain('prepare:prebid'); + finishGpt?.(); + + await expect(installed).resolves.toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'prepare:gpt:start', + 'prepare:gpt:end', + 'prepare:prebid', + 'activate:gpt', + 'activate:prebid', + 'publish', + 'after:gpt', + 'after:prebid', + 'drain', + ]); + expect(contexts).toHaveLength(2); + expect(Object.isFrozen(contexts[0])).toBe(true); + expect(contexts[0]?.config).toBe(frozenConfig); + expect(contexts[0]?.interfaces).toBe(frozenInterfaces); + }); + + it('closes a synchronous preparation context before detached microtasks can use it', async () => { + const lateDisposer = vi.fn(); + let lateError: unknown; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: (context) => { + queueMicrotask(() => { + try { + context.onDispose(lateDisposer); + } catch (error) { + lateError = error; + } + }); + return { activate: () => undefined }; + }, + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + await Promise.resolve(); + expect(lateError).toBeInstanceOf(Error); + expect(lateDisposer).not.toHaveBeenCalled(); + }); + + it('rejects a prepared activation accessor without invoking it or publishing', async () => { + const owner = new AbortController(); + const activateGetter = vi.fn(() => { + owner.abort(); + return () => undefined; + }); + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + signal: owner.signal, + }); + registry.register( + registration('gpt', { + prepare: () => + Object.defineProperty({}, 'activate', { + get: activateGetter, + enumerable: true, + }) as { activate: () => void }, + }) + ); + + const result = await registry.install({ + activateCore: () => undefined, + publish, + drainPreload: vi.fn(), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(activateGetter).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('rejects a frozen interface container that exposes a mutable adapter facade', async () => { + const prepare = vi.fn(() => ({ activate: () => undefined })); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ adapter: { mutable: true } }), + }), + }); + registry.register(registration('gpt', { prepare })); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(prepare).not.toHaveBeenCalled(); + }); + + it('snapshots each prepared activation before preparing a later module', async () => { + const acceptedActivate = vi.fn(); + const swappedActivate = vi.fn(() => { + throw new Error('must never execute'); + }); + const prepared = { activate: acceptedActivate }; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register(registration('gpt', { prepare: () => prepared })); + registry.register( + registration('prebid', { + prepare: () => { + prepared.activate = swappedActivate; + return { activate: () => undefined }; + }, + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + expect(acceptedActivate).toHaveBeenCalledTimes(1); + expect(swappedActivate).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'synchronous throw', + () => { + throw new Error('fictional prepare throw'); + }, + ], + ['asynchronous rejection', () => Promise.reject(new Error('fictional prepare rejection'))], + ])('unwinds a preparation %s as bundle_partial', async (_name, prepare) => { + const disposed: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: (context) => { + context.onDispose(() => disposed.push('prepared')); + return prepare(); + }, + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(disposed).toEqual(['prepared']); + }); + + it('aborts a pending preparation at the shared deadline and ignores its late continuation', async () => { + vi.useFakeTimers(); + let now = 0; + let finishPrepare: ((value: { activate: () => void }) => void) | undefined; + let context: IntegrationPrepareContext | undefined; + const activate = vi.fn(); + const lateDispose = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => now, + }); + registry.register( + registration('gpt', { + prepare: (receivedContext) => { + context = receivedContext; + return new Promise((resolve) => { + finishPrepare = resolve; + }); + }, + }) + ); + + const installed = install(registry); + await vi.advanceTimersByTimeAsync(9_999); + expect(registry.state).toBe('preparing'); + now = 10_000; + await vi.advanceTimersByTimeAsync(1); + await expect(installed).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(context?.signal.aborted).toBe(true); + context?.onDispose(lateDispose); + expect(lateDispose).toHaveBeenCalledTimes(1); + + finishPrepare?.({ activate }); + await Promise.resolve(); + expect(activate).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('aborts preparation through the caller signal and leaves no late activation', async () => { + const owner = new AbortController(); + const activate = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + signal: owner.signal, + }); + registry.register( + registration('gpt', { + prepare: ({ signal }) => + new Promise((resolve) => { + signal.addEventListener('abort', () => resolve({ activate })); + }), + }) + ); + + const installed = install(registry); + owner.abort(); + await expect(installed).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); + + it('observes a rejected preparation promise returned after synchronous abort', async () => { + const owner = new AbortController(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + signal: owner.signal, + }); + registry.register( + registration('gpt', { + prepare: () => { + owner.abort(); + return Promise.reject(new Error('fictional late preparation rejection')); + }, + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + await Promise.resolve(); + }); + + it('turns a registration attempt during preparation into abi_mismatch', async () => { + let finishPrepare: ((value: { activate: () => void }) => void) | undefined; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => + new Promise((resolve) => { + finishPrepare = resolve; + }), + }) + ); + + const installed = install(registry); + await vi.waitFor(() => expect(registry.state).toBe('preparing')); + expect(registry.register(registration('unknown'))).toBe(false); + finishPrepare?.({ activate: () => undefined }); + + await expect(installed).resolves.toMatchObject({ + state: 'fallback', + reason: 'abi_mismatch', + }); + }); + + it('unwinds activated and prepared resources in reverse order on activation failure', async () => { + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + for (const id of ['gpt', 'prebid']) { + registry.register( + registration(id, { + prepare: (preparation) => { + preparation.onDispose(() => order.push(`dispose:prepare:${id}`)); + return { + activate: (activation) => { + activation.onDispose(() => order.push(`dispose:activate:${id}`)); + order.push(`activate:${id}`); + if (id === 'prebid') throw new Error('fictional activation failure'); + }, + }; + }, + }) + ); + } + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(order).toEqual([ + 'activate:gpt', + 'activate:prebid', + 'dispose:activate:prebid', + 'dispose:prepare:prebid', + 'dispose:activate:gpt', + 'dispose:prepare:gpt', + ]); + }); + + it('activates reversible core effects first and unwinds them after every module', async () => { + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + for (const id of ['gpt', 'prebid']) { + registry.register( + registration(id, { + prepare: () => ({ + activate: ({ onDispose }) => { + onDispose(() => order.push(`dispose:${id}`)); + order.push(`activate:${id}`); + if (id === 'prebid') throw new Error('fictional later activation failure'); + }, + }), + }) + ); + } + + const result = await registry.install({ + activateCore: ({ onDispose }) => { + onDispose(() => order.push('dispose:core')); + order.push('activate:core'); + }, + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(order).toEqual([ + 'activate:core', + 'activate:gpt', + 'activate:prebid', + 'dispose:prebid', + 'dispose:gpt', + 'dispose:core', + ]); + }); + + it.each([ + ['deadline crossing', ({ setNow }: { setNow: (value: number) => void }) => setNow(10_000)], + ['async rejection', () => Promise.reject(new Error('fictional core rejection'))], + ])('rejects a core activation %s before module activation', async (_name, activate) => { + let now = 0; + const moduleActivate = vi.fn(); + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => now, + }); + registry.register(registration('gpt', { prepare: () => ({ activate: moduleActivate }) })); + + const result = await registry.install({ + activateCore: () => activate({ setNow: (value) => (now = value) }), + publish, + drainPreload: vi.fn(), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(moduleActivate).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('cannot commit after activation synchronously aborts the owner', async () => { + const owner = new AbortController(); + const live = { wrapper: 'publisher' }; + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + signal: owner.signal, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: ({ onDispose }) => { + const previous = live.wrapper; + onDispose(() => { + if (live.wrapper === 'tsjs') live.wrapper = previous; + }); + live.wrapper = 'tsjs'; + owner.abort(); + live.wrapper = 'tsjs'; + }, + }), + }) + ); + + const result = await registry.install({ + activateCore: () => undefined, + publish, + drainPreload: vi.fn(), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(publish).not.toHaveBeenCalled(); + expect(live.wrapper).toBe('publisher'); + }); + + it('cannot commit after an activation attempts late bundle registration', async () => { + const live = { wrapper: 'publisher' }; + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: ({ onDispose }) => { + const previous = live.wrapper; + onDispose(() => { + if (live.wrapper === 'tsjs') live.wrapper = previous; + }); + live.wrapper = 'tsjs'; + expect(registry.register(registration('unknown'))).toBe(false); + live.wrapper = 'tsjs'; + }, + }), + }) + ); + + const result = await registry.install({ + activateCore: () => undefined, + publish, + drainPreload: vi.fn(), + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'abi_mismatch' }); + expect(publish).not.toHaveBeenCalled(); + expect(live.wrapper).toBe('publisher'); + }); + + it('restores reversible effects before fallback publication', async () => { + const live = { wrapper: 'publisher' }; + const observations: string[] = []; + const irreversibleWork = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => { + observations.push(`prepare:${live.wrapper}`); + return { + activate: ({ onDispose }) => { + const previous = live.wrapper; + onDispose(() => { + if (live.wrapper === 'tsjs') live.wrapper = previous; + }); + live.wrapper = 'tsjs'; + }, + }; + }, + }) + ); + registry.register( + registration('prebid', { + prepare: () => ({ + activate: ({ afterCommit }) => { + afterCommit(irreversibleWork); + throw new Error('later fictional failure'); + }, + }), + }) + ); + + const result = await registry.install({ + activateCore: () => undefined, + publish: () => observations.push(`publish:${live.wrapper}`), + drainPreload: () => observations.push('drain'), + }); + + expect(result).toMatchObject({ state: 'fallback' }); + expect(live.wrapper).toBe('publisher'); + expect(observations).toEqual(['prepare:publisher']); + expect(irreversibleWork).not.toHaveBeenCalled(); + }); + + it('rejects asynchronous kernel publication and observes its rejection', async () => { + const drainPreload = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest([]), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + + const result = await registry.install({ + activateCore: () => undefined, + publish: async () => { + throw new Error('fictional asynchronous publication rejection'); + }, + drainPreload, + }); + + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(drainPreload).not.toHaveBeenCalled(); + await Promise.resolve(); + }); + + it.each([9_999, 10_000, 10_001])( + 'checks the monotonic deadline after activation at %i ms', + async (activationReturnMs) => { + let now = 0; + const order: string[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => now, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: () => { + order.push('activate'); + now = activationReturnMs; + }, + }), + }) + ); + + const result = await install(registry, order); + if (activationReturnMs < 10_000) { + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['activate', 'publish', 'drain']); + } else { + expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(order).toEqual(['activate']); + } + } + ); + + it('checks the deadline again immediately before handoff', async () => { + let checks = 0; + const activateCore = vi.fn(); + const publish = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest([]), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => (checks++ < 5 ? 9_999 : 10_000), + }); + + await expect( + registry.install({ + activateCore, + publish, + drainPreload: vi.fn(), + }) + ).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activateCore).toHaveBeenCalledOnce(); + expect(publish).not.toHaveBeenCalled(); + expect(checks).toBe(6); + }); + + it('treats an asynchronous activation as a synchronous barrier violation', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: async () => { + throw new Error('fictional async activation rejection'); + }, + }), + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); + + it('turns a second afterCommit registration into bundle_partial', async () => { + const staged = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: ({ afterCommit }) => { + afterCommit(staged); + afterCommit(staged); + }, + }), + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(staged).not.toHaveBeenCalled(); + }); + + it('latches duplicate afterCommit as bundle_partial even when module code catches the throw', async () => { + const first = vi.fn(); + const second = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: ({ afterCommit }) => { + try { + afterCommit(first); + afterCommit(second); + } catch { + // A bundle cannot swallow a registry contract violation and commit. + } + }, + }), + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + }); + + it('isolates afterCommit failure to its module and keeps the committed kernel', async () => { + const order: string[] = []; + const runtimeFailures: unknown[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt', 'prebid']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + onRuntimeFailure: (failure) => runtimeFailures.push(failure), + }); + registry.register( + registration('gpt', { + prepare: ({ onDispose }) => { + onDispose(() => order.push('dispose:gpt')); + return { + activate: ({ afterCommit }) => + afterCommit(() => { + order.push('after:gpt'); + throw new Error('fictional post-commit failure'); + }), + }; + }, + }) + ); + registry.register( + registration('prebid', { + prepare: () => ({ + activate: ({ afterCommit }) => afterCommit(() => order.push('after:prebid')), + }), + }) + ); + + const result = await install(registry, order); + + expect(result).toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'gpt', phase: 'after_commit' }], + }); + expect(runtimeFailures).toEqual([{ id: 'gpt', phase: 'after_commit' }]); + expect(Object.isFrozen(runtimeFailures[0])).toBe(true); + expect(order).toEqual(['publish', 'after:gpt', 'dispose:gpt', 'after:prebid', 'drain']); + expect(registry.state).toBe('committed'); + }); + + it('observes a rejecting asynchronous preload drain without undoing commit', async () => { + const onDisposalError = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest([]), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + onDisposalError, + }); + + const result = await registry.install({ + activateCore: () => undefined, + publish: () => undefined, + drainPreload: async () => { + throw new Error('fictional asynchronous preload rejection'); + }, + }); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(registry.state).toBe('committed'); + await vi.waitFor(() => expect(onDisposalError).toHaveBeenCalledTimes(1)); + expect(registry.state).toBe('committed'); + }); + + it('refuses late registration after fallback or commit without invoking module code', async () => { + const fallbackRegistry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 10_000, + }); + await install(fallbackRegistry); + const fallbackPrepare = vi.fn(); + expect(fallbackRegistry.register(registration('gpt', { prepare: fallbackPrepare }))).toBe( + false + ); + + const committedRegistry = createIntegrationRegistry({ + manifest: manifest([]), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + await install(committedRegistry); + const committedPrepare = vi.fn(); + expect(committedRegistry.register(registration('gpt', { prepare: committedPrepare }))).toBe( + false + ); + + expect(fallbackPrepare).not.toHaveBeenCalled(); + expect(committedPrepare).not.toHaveBeenCalled(); + }); + + it('documents the same-thread limitation by completing only after activate returns', async () => { + let returned = false; + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + registry.register( + registration('gpt', { + prepare: () => ({ + activate: () => { + expect(registry.state).toBe('activating'); + returned = true; + }, + }), + }) + ); + + await expect(install(registry)).resolves.toMatchObject({ state: 'kernel' }); + expect(returned).toBe(true); + }); + + it('memoizes installation before any synchronous callback can reenter it', async () => { + const phases: string[] = []; + const reentrantPromises: Promise[] = []; + const ignoredPublish = vi.fn(); + const ignoredDrain = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + startedAtMs: 0, + now: () => 0, + }); + const reenter = () => { + reentrantPromises.push( + registry.install({ + activateCore: vi.fn(), + publish: ignoredPublish, + drainPreload: ignoredDrain, + }) + ); + }; + registry.register( + registration('gpt', { + prepare: () => { + phases.push('prepare'); + reenter(); + return { + activate: () => { + phases.push('activate'); + reenter(); + }, + }; + }, + }) + ); + + const installed = registry.install({ + activateCore: () => { + phases.push('core'); + reenter(); + }, + publish: () => { + phases.push('publish'); + reenter(); + }, + drainPreload: () => phases.push('drain'), + }); + + await expect(installed).resolves.toMatchObject({ state: 'kernel' }); + expect(reentrantPromises).toHaveLength(4); + for (const promise of reentrantPromises) expect(promise).toBe(installed); + expect(phases).toEqual(['prepare', 'core', 'activate', 'publish', 'drain']); + expect(ignoredPublish).not.toHaveBeenCalled(); + expect(ignoredDrain).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts new file mode 100644 index 000000000..1e57e3827 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, +} from '../../src/kernel/integration_registry'; +import { createLifecycleIntegrationRegistration } from '../../src/kernel/lifecycle_module'; + +const RELEASE_ID = 'a'.repeat(64); +const CRITICAL_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; +const TEST_INTEGRATION_ID = 'datadome'; + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +function registry(config: unknown, runtime: unknown) { + return createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + criticalSrc: CRITICAL_SRC, + integrations: [{ id: TEST_INTEGRATION_ID, phase: 'critical' }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze([TEST_INTEGRATION_ID]), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ [TEST_INTEGRATION_ID]: runtime }), + }), + }); +} + +describe('shared integration lifecycle module', () => { + it('prepares inertly, activates reversibly, and starts only after publication', async () => { + const order: string[] = []; + const config = Object.freeze({ nested: Object.freeze({ enabled: true }) }); + const release = vi.fn(() => order.push('release')); + const activate = vi.fn((received: unknown) => { + expect(received).toBe(config); + order.push('activate'); + return release; + }); + const start = vi.fn((received: unknown) => { + expect(received).toBe(config); + order.push('start'); + }); + const runtime = Object.freeze({ activate, start }); + const owner = registry(config, runtime); + owner.register(createLifecycleIntegrationRegistration(TEST_INTEGRATION_ID, RELEASE_ID)); + + const result = await owner.install(callbacks(order)); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'activate', 'publish', 'start', 'drain']); + if (result.state === 'kernel') result.dispose(); + expect(release).toHaveBeenCalledOnce(); + }); + + it.each([ + ['mutable root', { enabled: true }], + ['mutable nested value', Object.freeze({ nested: { enabled: true } })], + ['accessor', Object.freeze(Object.defineProperty({}, 'enabled', { get: () => true }))], + ['function', Object.freeze(() => undefined)], + ])('rejects %s configuration before activation', async (_name, config) => { + const activate = vi.fn(() => vi.fn()); + const owner = registry(config, Object.freeze({ activate, start: vi.fn() })); + owner.register(createLifecycleIntegrationRegistration(TEST_INTEGRATION_ID, RELEASE_ID)); + + await expect(owner.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); + + it('rejects extra runtime authority and unwinds activation when startup peers fail', async () => { + const activate = vi.fn(() => vi.fn()); + const owner = registry( + Object.freeze({}), + Object.freeze({ activate, start: vi.fn(), publish: vi.fn() }) + ); + owner.register(createLifecycleIntegrationRegistration(TEST_INTEGRATION_ID, RELEASE_ID)); + + await expect(owner.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts b/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts new file mode 100644 index 000000000..957144cf6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts @@ -0,0 +1,533 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BootManifestV1 } from '../../src/core/types'; +import { + createDeferredPhaseLoader, + createProtectedFirstDisplayGate, + type DeferredPhaseLoaderOptions, + type PhaseScheduler, +} from '../../src/kernel/phase_loader'; + +const RELEASE_ID = 'a'.repeat(64); +const HASH = 'b'.repeat(64); + +function scheduler(options: { idle?: boolean } = {}): { + readonly frames: FrameRequestCallback[]; + readonly idle: Array<() => void>; + readonly value: PhaseScheduler; +} { + const frames: FrameRequestCallback[] = []; + const idle: Array<() => void> = []; + return { + frames, + idle, + value: { + cancelAnimationFrame: vi.fn(), + clearTimeout, + requestAnimationFrame: (callback) => { + frames.push(callback); + return frames.length; + }, + ...(options.idle + ? { + cancelIdleCallback: vi.fn(), + requestIdleCallback: (callback: () => void) => { + idle.push(callback); + return idle.length; + }, + } + : {}), + setTimeout, + }, + }; +} + +function deferredManifest(ids: readonly string[]): BootManifestV1 { + return Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${HASH}`, + integrations: Object.freeze([ + Object.freeze({ id: 'render_runtime', phase: 'critical' as const }), + ...ids.map((id) => + Object.freeze({ + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=tsjs-${id}.min.js?v=${HASH}`, + }) + ), + ]), + }); +} + +function deferredRegistration( + id: string, + prepare = vi.fn(() => Object.freeze({ activate: () => undefined })) +): object { + return Object.freeze({ + abi: 1, + id, + phase: 'deferred', + releaseId: RELEASE_ID, + prepare, + }); +} + +afterEach(() => { + vi.useRealTimers(); + document.head.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe('protected first-display paint gate', () => { + it('releases a no-attempt page only at 10 seconds, after two frames and idle', async () => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + const marks: string[] = []; + const gate = createProtectedFirstDisplayGate({ + document, + markPaint: () => marks.push('paint'), + scheduler: platform.value, + }); + let released = false; + void gate.ready.then(() => (released = true)); + + gate.commit(); + await vi.advanceTimersByTimeAsync(9_999); + expect(released).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(platform.frames).toHaveLength(1); + platform.frames.shift()?.(10_000); + expect(platform.frames).toHaveLength(1); + expect(marks).toEqual([]); + platform.frames.shift()?.(10_016); + expect(marks).toEqual(['paint']); + expect(released).toBe(false); + expect(platform.idle).toHaveLength(1); + platform.idle.shift()?.(); + await Promise.resolve(); + expect(released).toBe(true); + }); + + it('protects the first batch created at 9,999 ms until every terminal latch settles', async () => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + let settle: (() => void) | undefined; + const terminal = new Promise((resolve) => (settle = resolve)); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + + gate.commit(); + await vi.advanceTimersByTimeAsync(9_999); + expect(gate.protectAttemptBatch(Object.freeze([terminal]))).toBe(true); + await vi.advanceTimersByTimeAsync(10_001); + expect(platform.frames).toEqual([]); + settle?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(platform.frames).toHaveLength(1); + platform.frames.shift()?.(20_000); + platform.frames.shift()?.(20_016); + platform.idle.shift()?.(); + await expect(gate.ready).resolves.toBe(true); + }); + + it('uses a post-paint 50 ms fallback only when requestIdleCallback is unavailable', async () => { + vi.useFakeTimers(); + const platform = scheduler(); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + let released = false; + void gate.ready.then(() => (released = true)); + + gate.commit(); + await vi.advanceTimersByTimeAsync(10_000); + platform.frames.shift()?.(10_000); + platform.frames.shift()?.(10_016); + await vi.advanceTimersByTimeAsync(49); + expect(released).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(released).toBe(true); + }); + + it.each([10_000, 10_001])( + 'does not protect a first attempt created at %i ms after the no-attempt release', + async (createdAtMs) => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + gate.commit(); + + await vi.advanceTimersByTimeAsync(createdAtMs); + expect(gate.protectAttemptBatch([Promise.resolve()])).toBe(false); + } + ); + + it('freezes the first protected batch and waits for every one of its members', async () => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + let settleFirst: (() => void) | undefined; + let settleSecond: (() => void) | undefined; + const first = new Promise((resolve) => (settleFirst = resolve)); + const second = new Promise((resolve) => (settleSecond = resolve)); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + gate.commit(); + + expect(gate.protectAttemptBatch([first, second])).toBe(true); + expect(gate.protectAttemptBatch([Promise.resolve()])).toBe(false); + settleFirst?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(platform.frames).toEqual([]); + settleSecond?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(platform.frames).toHaveLength(1); + }); + + it('waits for visibility and two frames when a hidden page becomes visible first', async () => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + gate.commit(); + await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(1_999); + expect(platform.frames).toEqual([]); + + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + document.dispatchEvent(new Event('visibilitychange')); + expect(platform.frames).toHaveLength(1); + platform.frames.shift()?.(12_000); + platform.frames.shift()?.(12_016); + platform.idle.shift()?.(); + await expect(gate.ready).resolves.toBe(true); + }); + + it('uses the two-second hidden timeout without requesting a frame', async () => { + vi.useFakeTimers(); + const platform = scheduler({ idle: true }); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + const gate = createProtectedFirstDisplayGate({ document, scheduler: platform.value }); + gate.commit(); + await vi.advanceTimersByTimeAsync(11_999); + expect(platform.idle).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(platform.frames).toEqual([]); + expect(platform.idle).toHaveLength(1); + platform.idle.shift()?.(); + await expect(gate.ready).resolves.toBe(true); + }); +}); + +describe('authenticated deferred module loading', () => { + it('starts every module in manifest order without awaiting a sibling', async () => { + const prepare = vi.fn(); + const critical = document.createElement('script'); + critical.nonce = 'response-nonce'; + const loader = createDeferredPhaseLoader({ + criticalScript: critical, + document, + prepare, + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later', 'prebid_later']), + releaseId: RELEASE_ID, + }); + + await Promise.resolve(); + const scripts = [...document.head.querySelectorAll('script')]; + expect(scripts.map((script) => new URL(script.src).pathname)).toEqual([ + '/static/tsjs=tsjs-gpt_later.min.js', + '/static/tsjs=tsjs-prebid_later.min.js', + ]); + expect(scripts.every((script) => script.async && script.nonce === 'response-nonce')).toBe(true); + expect(loader.state('gpt_later')).toBe('loading'); + expect(loader.state('prebid_later')).toBe('loading'); + }); + + it('requires one exact registration from the exact connected current script', async () => { + const prepare = vi.fn((registration, owner) => + registration.prepare( + Object.freeze({ + config: Object.freeze({}), + interfaces: Object.freeze({}), + signal: owner.signal, + onDispose: owner.onDispose, + }) + ) + ); + const critical = document.createElement('script'); + const loader = createDeferredPhaseLoader({ + criticalScript: critical, + document, + prepare, + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const script = document.head.querySelector('script'); + expect(script).not.toBeNull(); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + + const registration = deferredRegistration('gpt_later'); + expect(loader.register(registration)).toBe(true); + script?.dispatchEvent(new Event('load')); + await vi.waitFor(() => expect(prepare).toHaveBeenCalledOnce()); + expect(loader.state('gpt_later')).toBe('ready'); + expect(loader.register(registration)).toBe(false); + }); + + it('isolates a failed module while a sibling reaches ready', async () => { + const prepare = vi.fn(async (registration: { readonly id: string }) => { + if (registration.id === 'gpt_later') throw new Error('fictional module failure'); + return Object.freeze({ activate: () => undefined }); + }); + const loader = createDeferredPhaseLoader({ + criticalScript: document.createElement('script'), + document, + prepare, + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later', 'prebid_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const scripts = [...document.head.querySelectorAll('script')]; + for (const [index, id] of ['gpt_later', 'prebid_later'].entries()) { + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: scripts[index], + }); + expect(loader.register(deferredRegistration(id))).toBe(true); + scripts[index]?.dispatchEvent(new Event('load')); + } + + await vi.waitFor(() => expect(loader.state('prebid_later')).toBe('ready')); + expect(loader.state('gpt_later')).toBe('unavailable'); + expect(loader.reason('gpt_later')).toBe('prepare_failed'); + }); + + it('classifies exact URL mutation before insertion as policy_blocked', async () => { + const critical = document.createElement('script'); + const originalCreate = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation(((name: string) => { + const element = originalCreate(name); + if (name === 'script') { + Object.defineProperty(element, 'src', { + configurable: true, + get: () => 'https://publisher.example/mutated.js', + set: () => undefined, + }); + } + return element; + }) as typeof document.createElement); + const loader = createDeferredPhaseLoader({ + criticalScript: critical, + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + + await vi.waitFor(() => expect(loader.state('gpt_later')).toBe('unavailable')); + expect(loader.reason('gpt_later')).toBe('policy_blocked'); + expect(document.head.querySelector('script')).toBeNull(); + }); + + it.each([ + ['error', 'load_error'], + ['load', 'load_without_registration'], + ] as const)('classifies a script %s without accepted registration', async (event, reason) => { + const loader = createDeferredPhaseLoader({ + criticalScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + document.head.querySelector('script')?.dispatchEvent(new Event(event)); + + await vi.waitFor(() => expect(loader.state('gpt_later')).toBe('unavailable')); + expect(loader.reason('gpt_later')).toBe(reason); + }); + + it('rejects registration after the expected node is removed or replaced', async () => { + const loader = createDeferredPhaseLoader({ + criticalScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const expected = document.head.querySelector('script'); + const replacement = document.createElement('script'); + expected?.replaceWith(replacement); + Object.defineProperty(document, 'currentScript', { configurable: true, value: replacement }); + + expect(loader.register(deferredRegistration('gpt_later'))).toBe(false); + expect(loader.reason('gpt_later')).toBe('registration_rejected'); + }); + + it.each([ + [ + 'activation', + () => + Object.freeze({ + activate: () => { + throw new Error('activation'); + }, + }), + 'activation_failed', + ], + [ + 'after commit', + () => + Object.freeze({ + activate: ({ afterCommit }: { afterCommit: (callback: () => void) => void }) => + afterCommit(() => { + throw new Error('after commit'); + }), + }), + 'after_commit_failed', + ], + ] as const)('classifies an %s failure at its exact stage', async (_name, prepared, reason) => { + const loader = createDeferredPhaseLoader({ + criticalScript: document.createElement('script'), + document, + prepare: () => prepared(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const script = document.head.querySelector('script'); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + expect(loader.register(deferredRegistration('gpt_later'))).toBe(true); + script?.dispatchEvent(new Event('load')); + + await vi.waitFor(() => expect(loader.state('gpt_later')).toBe('unavailable')); + expect(loader.reason('gpt_later')).toBe(reason); + }); + + it('keeps the shared module alive after one caller deadline expires', async () => { + vi.useFakeTimers(); + const loader = createDeferredPhaseLoader({ + criticalScript: document.createElement('script'), + document, + prepare: () => Object.freeze({ activate: () => undefined }), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const caller = loader.waitFor('gpt_later', 100); + await vi.advanceTimersByTimeAsync(100); + await expect(caller).resolves.toBe('caller_timeout'); + expect(loader.state('gpt_later')).toBe('loading'); + + const script = document.head.querySelector('script'); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + expect(loader.register(deferredRegistration('gpt_later'))).toBe(true); + script?.dispatchEvent(new Event('load')); + await expect(loader.waitFor('gpt_later', 100)).resolves.toBe('ready'); + }); + + it('retires a hung shared module at its independent ten-second deadline', async () => { + vi.useFakeTimers(); + const loader = createDeferredPhaseLoader({ + criticalScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(9_999); + expect(loader.state('gpt_later')).toBe('loading'); + await vi.advanceTimersByTimeAsync(1); + expect(loader.state('gpt_later')).toBe('unavailable'); + expect(loader.reason('gpt_later')).toBe('module_timeout'); + }); + + it('does not start after the owning gate is disposed', async () => { + const gate = createProtectedFirstDisplayGate({ document, scheduler: scheduler().value }); + const loader = createDeferredPhaseLoader({ + criticalScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: gate.ready, + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + + gate.dispose(); + await Promise.resolve(); + await Promise.resolve(); + expect(document.head.querySelector('script')).toBeNull(); + expect(loader.reason('gpt_later')).toBe('disposed'); + }); + + it('uses window origin rather than a hostile document base URL', async () => { + const base = document.createElement('base'); + base.href = 'https://attacker.example/subtree/'; + document.head.append(base); + createDeferredPhaseLoader({ + criticalScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + + expect(document.head.querySelector('script')?.src).toBe( + `${window.location.origin}/static/tsjs=tsjs-gpt_later.min.js?v=${HASH}` + ); + }); + + it('creates the fixed Trusted Types policy once and admits only canonical absolute URLs', async () => { + const createPolicy = vi.fn((_name: string, rules: { createScriptURL(value: string): string }) => + Object.freeze({ createScriptURL: rules.createScriptURL }) + ); + Object.defineProperty(window, 'trustedTypes', { + configurable: true, + value: Object.freeze({ createPolicy }), + }); + createDeferredPhaseLoader({ + criticalScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later', 'prebid_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + + expect(createPolicy).toHaveBeenCalledOnce(); + expect(createPolicy).toHaveBeenCalledWith( + 'trusted-server#tsjs-v1', + expect.objectContaining({ createScriptURL: expect.any(Function) }) + ); + const rules = createPolicy.mock.calls[0]?.[1]; + expect(() => rules?.createScriptURL('https://attacker.example/x.js')).toThrow(); + }); + + it('copies no nonce when the critical script has no nonempty nonce', async () => { + createDeferredPhaseLoader({ + criticalScript: document.createElement('script'), + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + expect(document.head.querySelector('script')?.nonce).toBe(''); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts b/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts new file mode 100644 index 000000000..34ad87a91 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from 'vitest'; + +import { FIRST_DISPLAY_CONTRACT_IDS } from '../../src/first_display/contracts'; +import * as releaseCatalog from '../../src/kernel/release_catalog'; +import { + FIRST_DISPLAY_CATALOG, + MAX_FIRST_DISPLAY_SLICES, + MAX_CRITICAL_MODULES, + MAX_MANIFEST_MODULES, + MINIMAL_CRITICAL_IDS, + REFERENCE_CRITICAL_IDS, + RELEASE_CATALOG, + selectFirstDisplayCatalog, + selectReleaseCatalog, + validateReleaseCatalog, + type ReleaseCatalogEntry, +} from '../../src/kernel/release_catalog'; + +const EXPECTED = [ + ['render_runtime', 'runtime', 'critical', null, 'always'], + ['aps', 'APS', 'critical', null, 'integration:aps'], + ['creative', 'creative', 'critical', null, 'creative_guard'], + ['datadome', 'DataDome', 'critical', null, 'integration:datadome'], + ['didomi', 'Didomi', 'critical', null, 'integration:didomi'], + ['google_tag_manager', 'GTM/GA', 'critical', null, 'integration:google_tag_manager'], + ['gpt', 'GPT', 'critical', null, 'integration:gpt'], + ['gpt_diagnostics', 'diagnostics', 'critical', null, 'gpt_diagnostics_active'], + ['lockr', 'Lockr', 'critical', null, 'integration:lockr'], + ['osano_consent', 'Osano', 'critical', null, 'integration:osano'], + ['permutive_context', 'Permutive', 'critical', null, 'integration:permutive'], + ['sourcepoint_consent', 'Sourcepoint', 'critical', null, 'integration:sourcepoint'], + ['prebid', 'Prebid', 'critical', null, 'integration:prebid'], + ['testlight', 'Testlight', 'critical', null, 'integration:testlight'], + [ + 'diagnostics_presentation', + 'diagnostics', + 'deferred', + 'first_display_or_idle', + 'diagnostics_presentation', + ], + ['gpt_later', 'GPT', 'deferred', 'first_display_or_idle', 'integration:gpt'], + ['osano_lifecycle', 'Osano', 'deferred', 'first_display_or_idle', 'integration:osano'], + [ + 'permutive_lifecycle', + 'Permutive', + 'deferred', + 'first_display_or_idle', + 'integration:permutive', + ], + ['prebid_later', 'Prebid', 'deferred', 'first_display_or_idle', 'prebid_and_gpt'], + [ + 'sourcepoint_lifecycle', + 'Sourcepoint', + 'deferred', + 'first_display_or_idle', + 'integration:sourcepoint', + ], +] as const; + +describe('canonical release catalog', () => { + it('pins the exact thirteen first-display rows and closed server-owned selection', () => { + expect(FIRST_DISPLAY_CONTRACT_IDS).toEqual(FIRST_DISPLAY_CATALOG.map(({ id }) => id)); + expect(FIRST_DISPLAY_CATALOG.map(({ order, id }) => [order, id])).toEqual([ + [1, 'first_display'], + [2, 'aps_initial'], + [3, 'creative_initial'], + [4, 'datadome_initial'], + [5, 'didomi_initial'], + [6, 'google_tag_manager_initial'], + [7, 'gpt_initial'], + [8, 'lockr_initial'], + [9, 'osano_initial'], + [10, 'permutive_initial'], + [11, 'sourcepoint_initial'], + [12, 'prebid_initial'], + [13, 'testlight_initial'], + ]); + expect(MAX_FIRST_DISPLAY_SLICES).toBe(13); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['aps', 'gpt', 'prebid'], + apsParticipates: true, + prebidParticipates: true, + }).map(({ id }) => id) + ).toEqual(['first_display', 'aps_initial', 'gpt_initial', 'prebid_initial']); + expect(selectFirstDisplayCatalog({ eligibleBatch: false, integrations: [] })).toEqual([]); + expect(() => + selectFirstDisplayCatalog({ eligibleBatch: true, integrations: ['unknown'] }) + ).toThrow(/unknown/i); + expect( + FIRST_DISPLAY_CATALOG.every( + ({ allowedImports, inputs, outputs, obligation }) => + allowedImports.length > 0 && + inputs.length > 0 && + outputs.length > 0 && + obligation.length > 0 + ) + ).toBe(true); + }); + it('pins the exact twenty rows, phases, triggers, products, predicates, and order', () => { + expect( + RELEASE_CATALOG.map(({ id, product, phase, trigger, include }) => [ + id, + product, + phase, + trigger, + include, + ]) + ).toEqual(EXPECTED); + expect(RELEASE_CATALOG.map(({ order }) => order)).toEqual( + Array.from({ length: 20 }, (_, index) => index + 1) + ); + }); + + it('pins the exact capability graph and named scopes', () => { + expect(RELEASE_CATALOG.map(({ id, provides, consumes }) => [id, provides, consumes])).toEqual([ + [ + 'render_runtime', + [ + 'slots.v1', + 'auction.v1', + 'render.v1', + 'messages.v1', + 'trace.v1', + 'trace.presentation.v1', + 'direct.v1', + ], + ['runtime.v1'], + ], + ['aps', ['aps.v1'], ['runtime.v1', 'slots.v1', 'render.v1', 'messages.v1', 'trace.v1']], + ['creative', [], ['runtime.v1']], + ['datadome', [], ['runtime.v1']], + ['didomi', [], ['runtime.v1']], + ['google_tag_manager', [], ['runtime.v1']], + [ + 'gpt', + ['gpt.v1', 'gpt.events.v1', 'pbs_cache.baseline.v1'], + ['runtime.v1', 'slots.v1', 'auction.v1', 'render.v1', 'messages.v1', 'trace.v1'], + ], + ['gpt_diagnostics', ['gpt_diag.v1'], ['runtime.v1', 'gpt.events.v1']], + ['lockr', [], ['runtime.v1']], + ['osano_consent', ['osano_consent.v1'], ['runtime.v1']], + ['permutive_context', ['permutive_context.v1'], ['runtime.v1']], + ['sourcepoint_consent', ['sourcepoint_consent.v1'], ['runtime.v1']], + [ + 'prebid', + ['prebid.v1'], + ['runtime.v1', 'slots.v1', 'render.v1', 'messages.v1', 'aps.v1?aps'], + ], + ['testlight', [], ['runtime.v1']], + [ + 'diagnostics_presentation', + [], + ['runtime.v1', 'trace.presentation.v1', 'gpt_diag.v1?gpt_diagnostics_active'], + ], + [ + 'gpt_later', + [], + ['runtime.v1', 'slots.v1', 'auction.v1', 'render.v1', 'gpt.v1', 'trace.v1'], + ], + ['osano_lifecycle', [], ['runtime.v1', 'osano_consent.v1']], + ['permutive_lifecycle', [], ['runtime.v1', 'permutive_context.v1']], + ['prebid_later', [], ['runtime.v1', 'slots.v1', 'gpt.v1', 'prebid.v1']], + ['sourcepoint_lifecycle', [], ['runtime.v1', 'sourcepoint_consent.v1']], + ]); + expect(RELEASE_CATALOG.every(({ obligation }) => obligation.length > 0)).toBe(true); + }); + + it('derives capacity and budget vectors without an internal diagnostics subscriber cap', () => { + expect(MAX_CRITICAL_MODULES).toBe(14); + expect(MAX_MANIFEST_MODULES).toBe(20); + expect('MAX_INTERNAL_DIAGNOSTICS_SUBSCRIPTIONS' in releaseCatalog).toBe(false); + expect(MINIMAL_CRITICAL_IDS).toEqual(['core', 'render_runtime']); + expect(REFERENCE_CRITICAL_IDS).toEqual([ + 'core', + 'render_runtime', + 'creative', + 'gpt', + 'prebid', + 'datadome', + ]); + expect(() => validateReleaseCatalog(RELEASE_CATALOG.slice(0, 13))).not.toThrow(); + expect(() => validateReleaseCatalog(RELEASE_CATALOG.slice(0, 14))).not.toThrow(); + expect(() => validateReleaseCatalog(RELEASE_CATALOG.slice(0, 15))).not.toThrow(); + expect(() => validateReleaseCatalog(RELEASE_CATALOG.slice(0, 19))).not.toThrow(); + expect(() => validateReleaseCatalog(RELEASE_CATALOG.slice(0, 20))).not.toThrow(); + const fifteenCritical = [ + ...RELEASE_CATALOG.slice(0, 14), + { + ...RELEASE_CATALOG[14]!, + phase: 'critical' as const, + trigger: null, + }, + ]; + expect(() => validateReleaseCatalog(fifteenCritical)).toThrow( + /critical capacity|phase override/i + ); + expect(() => validateReleaseCatalog([...RELEASE_CATALOG, RELEASE_CATALOG[0]!])).toThrow(); + }); + + it('selects rows only through deny-unknown server-owned predicates', () => { + expect(selectReleaseCatalog({ integrations: [] }).map(({ id }) => id)).toEqual([ + 'render_runtime', + ]); + expect( + selectReleaseCatalog({ + integrations: ['aps', 'gpt', 'prebid'], + creative: { enabled: true, clickGuard: false, renderGuard: true }, + gptDiagnosticsActive: true, + renderTraceOverlay: true, + }).map(({ id }) => id) + ).toEqual([ + 'render_runtime', + 'aps', + 'creative', + 'gpt', + 'gpt_diagnostics', + 'prebid', + 'diagnostics_presentation', + 'gpt_later', + 'prebid_later', + ]); + expect( + selectReleaseCatalog({ + integrations: [], + gptDiagnosticsActive: true, + renderTraceOverlay: false, + }).map(({ id }) => id) + ).toEqual(['render_runtime', 'gpt_diagnostics', 'diagnostics_presentation']); + expect( + selectReleaseCatalog({ + integrations: [], + gptDiagnosticsActive: false, + renderTraceOverlay: true, + }).map(({ id }) => id) + ).toEqual(['render_runtime', 'diagnostics_presentation']); + expect( + selectReleaseCatalog({ + integrations: [], + gptDiagnosticsActive: false, + renderTraceOverlay: false, + }).map(({ id }) => id) + ).toEqual(['render_runtime']); + expect(() => selectReleaseCatalog({ integrations: ['unknown'] })).toThrow(/unknown/i); + }); + + it('rejects duplicate providers, undeclared edges, cycles, deferred providers, and bad order', () => { + const clone = (): ReleaseCatalogEntry[] => RELEASE_CATALOG.map((entry) => ({ ...entry })); + + const duplicateProvider = clone(); + duplicateProvider[2] = { ...duplicateProvider[2]!, provides: ['aps.v1'] }; + expect(() => validateReleaseCatalog(duplicateProvider)).toThrow(/provider/i); + + const unknownEdge = clone(); + unknownEdge[2] = { ...unknownEdge[2]!, consumes: ['missing.v1'] }; + expect(() => validateReleaseCatalog(unknownEdge)).toThrow(/capability/i); + + const deferredProvider = clone(); + deferredProvider[14] = { ...deferredProvider[14]!, provides: ['later.v1'] }; + expect(() => validateReleaseCatalog(deferredProvider)).toThrow(/deferred provider/i); + + const cycle = clone(); + cycle[0] = { ...cycle[0]!, consumes: ['aps.v1'] }; + expect(() => validateReleaseCatalog(cycle)).toThrow(/order|cycle/i); + + const wrongOrder = clone(); + [wrongOrder[0], wrongOrder[1]] = [wrongOrder[1]!, wrongOrder[0]!]; + expect(() => validateReleaseCatalog(wrongOrder)).toThrow(/order/i); + + const phaseOverride = clone(); + phaseOverride[13] = { + ...phaseOverride[13]!, + phase: 'deferred', + trigger: 'first_display_or_idle', + }; + expect(() => validateReleaseCatalog(phaseOverride)).toThrow(/phase override/i); + + const invalidConditionalEdge = clone(); + invalidConditionalEdge[12] = { + ...invalidConditionalEdge[12]!, + consumes: ['runtime.v1', 'aps.v1?publisher_choice'], + }; + expect(() => validateReleaseCatalog(invalidConditionalEdge)).toThrow(/conditional/i); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts new file mode 100644 index 000000000..70b6c889a --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -0,0 +1,2478 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import { + AdUnitRegistrationError, + RequestAdsInputError, + TsjsUnavailableError, + type AdUnitRegistrationErrorCode, +} from '../../src/kernel/fallback'; +import { createRuntime as createRuntimeOwner, type RuntimeOptions } from '../../src/kernel/runtime'; +import { createDiagnosticsPresentationIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/presentation'; +import { createLifecycleIntegrationRegistration } from '../../src/kernel/lifecycle_module'; + +const RELEASE = 'a'.repeat(64); +const TRUSTED_CRITICAL_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; + +function installTestCriticalScript(runtimeDocument: Document, takeover = false): void { + if (runtimeDocument.currentScript) return; + const script = runtimeDocument.createElement('script'); + script.id = takeover ? 'trustedserver-js-runtime' : 'trustedserver-js'; + script.src = new URL(TRUSTED_CRITICAL_SRC, runtimeDocument.location.origin).href; + runtimeDocument.head.insertBefore(script, null); + Object.defineProperty(runtimeDocument, 'currentScript', { + configurable: true, + value: script, + }); +} + +function createRuntime(options: RuntimeOptions) { + installTestCriticalScript(options.document ?? document, options.coordinateTakeover !== undefined); + return createRuntimeOwner(options); +} + +function boot(results: readonly object[] = []) { + return { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'boot', results }, + slots: results.map((result) => { + const slot = (result as { readonly slot?: unknown }).slot; + return { + slot, + gamUnitPath: `/123/${String(slot)}`, + divId: String(slot), + formats: [[300, 250]], + targeting: {}, + }; + }), + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }; +} + +function manifest(ids: readonly string[]) { + const deferredIds = new Set([ + 'diagnostics_presentation', + 'gpt_later', + 'osano_lifecycle', + 'permutive_lifecycle', + 'prebid_later', + 'sourcepoint_lifecycle', + ]); + return { + version: 1, + releaseId: RELEASE, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`, + integrations: ids.map((id) => + deferredIds.has(id) + ? { + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=tsjs-${id}.min.js?v=${'d'.repeat(64)}`, + } + : { id, phase: 'critical' as const } + ), + }; +} + +type ReflectionTrap = 'getPrototypeOf' | 'ownKeys' | 'getOwnPropertyDescriptor'; + +function hostileRecord(trap: ReflectionTrap, target: object = {}): object { + const fail = () => { + throw new Error(`hostile ${trap}`); + }; + const handler: ProxyHandler = {}; + if (trap === 'getPrototypeOf') handler.getPrototypeOf = fail; + if (trap === 'ownKeys') handler.ownKeys = fail; + if (trap === 'getOwnPropertyDescriptor') handler.getOwnPropertyDescriptor = fail; + return new Proxy(target, handler); +} + +function thrownBy(callback: () => unknown): unknown { + try { + callback(); + } catch (error) { + return error; + } + throw new Error('Expected callback to throw'); +} + +describe('Runtime bootstrap owner', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.head.replaceChildren(); + Object.defineProperty(document, 'currentScript', { configurable: true, value: null }); + }); + + it('exports the exact programmatic registration error taxonomy', () => { + type ExpectedCode = + | 'invalid_units' + | 'invalid_unit' + | 'invalid_code' + | 'duplicate_code' + | 'slot_collision' + | 'invalid_media_types' + | 'invalid_dimensions' + | 'dimensions_out_of_range' + | 'invalid_bids' + | 'invalid_bidder' + | 'invalid_params' + | 'request_body_too_large' + | 'registry_capacity'; + + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it.each([ + { + boundary: 'no document', + arrange: () => { + vi.stubGlobal('document', undefined); + return undefined; + }, + }, + { + boundary: 'no critical tag', + arrange: () => document, + }, + { + boundary: 'wrong realm and owner document', + arrange: () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const foreignDocument = frame.contentDocument; + if (!foreignDocument) throw new Error('should expose an iframe document'); + const script = foreignDocument.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(TRUSTED_CRITICAL_SRC, window.location.origin).href; + foreignDocument.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'wrong id', + arrange: () => { + const script = document.createElement('script'); + script.id = 'publisher-script'; + script.src = new URL(TRUSTED_CRITICAL_SRC, window.location.origin).href; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'disconnected tag', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(TRUSTED_CRITICAL_SRC, window.location.origin).href; + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'duplicate tag', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL(TRUSTED_CRITICAL_SRC, window.location.origin).href; + const duplicate = script.cloneNode() as HTMLScriptElement; + document.head.append(script, duplicate); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'cross-origin source', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = `https://attacker.example${TRUSTED_CRITICAL_SRC}`; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'fragment source', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = `${new URL(TRUSTED_CRITICAL_SRC, window.location.origin).href}#publisher`; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'wrong route', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL( + `/static/tsjs=tsjs-publisher.min.js?v=${'c'.repeat(64)}`, + window.location.origin + ).href; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + { + boundary: 'malformed artifact hash', + arrange: () => { + const script = document.createElement('script'); + script.id = 'trustedserver-js'; + script.src = new URL( + `/static/tsjs=tsjs-unified.min.js?v=${'C'.repeat(64)}`, + window.location.origin + ).href; + document.head.append(script); + Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + return document; + }, + }, + ])('rejects caller-supplied critical source at the $boundary boundary', ({ arrange }) => { + const runtimeDocument = arrange(); + const queued = vi.fn(); + const target = { boot: boot(), que: [queued] }; + const bootDescriptor = Object.getOwnPropertyDescriptor(target, 'boot'); + const queueDescriptor = Object.getOwnPropertyDescriptor(target, 'que'); + const options: RuntimeOptions & Record = { + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([] as string[]), + boot: target.boot, + ...(runtimeDocument ? { document: runtimeDocument } : {}), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }; + options['trustedCriticalSrc'] = TRUSTED_CRITICAL_SRC; + const runtime = createRuntimeOwner(options); + + expect(runtime.start()).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(Object.getOwnPropertyDescriptor(target, 'boot')).toEqual(bootDescriptor); + expect(Object.getOwnPropertyDescriptor(target, 'que')).toEqual(queueDescriptor); + expect(target).not.toHaveProperty('_registerIntegration'); + expect(target).not.toHaveProperty('_internal'); + expect(queued).not.toHaveBeenCalled(); + }); + + it('commits one kernel after core/integration activation and afterCommit before queue drain', async () => { + const order: string[] = []; + const target = { que: [() => order.push('queued')], config: { publisher: true } }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + activateCore: () => order.push('core'), + kernel: { + addAdUnits: () => ({ registered: [] }), + diagnostics: Object.freeze({}), + requestAds: async () => ({ slots: [] }), + }, + }); + + expect(runtime.state).toBe('unclaimed'); + expect(runtime.start()).toBe(true); + expect(runtime.state).toBe('installing'); + expect(target.config).toEqual({ publisher: true }); + expect( + runtime.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: () => ({ + activate: ({ afterCommit }: { afterCommit(callback: () => void): void }) => { + order.push('integration'); + afterCommit(() => order.push('after-commit')); + }, + }), + }) + ).toBe(true); + + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(runtime.state).toBe('kernel'); + expect(order).toEqual(['core', 'integration', 'after-commit', 'queued']); + expect(target).toMatchObject({ version: '1.0.0', releaseId: RELEASE }); + expect(Object.isFrozen(target.que)).toBe(true); + expect(Object.getOwnPropertyDescriptor(target, '_internal')).toMatchObject({ + enumerable: false, + writable: false, + configurable: false, + }); + expect( + (target as { _registerIntegration?: (value: unknown) => boolean })._registerIntegration?.({ + id: 'late', + }) + ).toBe(false); + }); + + it('publishes direct.v1 through stable public closures only after provider activation', async () => { + const target: Record = {}; + const addAdUnits = vi.fn((candidate: unknown) => Object.freeze({ candidate })); + const requestAds = vi.fn(async (_candidate?: unknown) => + Object.freeze({ slots: Object.freeze([]) }) + ); + let active = false; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['render_runtime']), + knownIntegrationIds: Object.freeze(['render_runtime']), + catalog: Object.freeze([ + Object.freeze({ + id: 'render_runtime', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['direct.v1']), + }), + ]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration({ + abi: 1, + id: 'render_runtime', + phase: 'critical', + releaseId: RELEASE, + prepare: ({ interfaces }: { interfaces: Readonly> }) => { + expect(Reflect.ownKeys(interfaces)).toEqual(['runtime.v1']); + return Object.freeze({ + activate: ({ onDispose }: { onDispose(callback: () => void): void }) => { + active = true; + onDispose(() => { + active = false; + }); + }, + interfaces: Object.freeze({ + 'direct.v1': Object.freeze({ + addAdUnits: (candidate: unknown) => { + if (!active) throw new Error('inactive'); + return addAdUnits(candidate); + }, + requestAds: async (candidate?: unknown) => { + if (!active) throw new Error('inactive'); + return requestAds(candidate); + }, + diagnostics: Object.freeze({ owner: 'render_runtime' }), + }), + }), + }); + }, + }) + ).toBe(true); + + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const api = target as { + addAdUnits: (candidate: unknown) => unknown; + requestAds: (candidate?: unknown) => Promise; + diagnostics: unknown; + }; + expect(api.addAdUnits('unit')).toEqual({ candidate: 'unit' }); + await expect(api.requestAds()).resolves.toEqual({ slots: [] }); + expect(api.diagnostics).toEqual({ owner: 'render_runtime' }); + runtime.dispose(); + expect(() => api.addAdUnits('late')).toThrow('inactive'); + }); + + it('publishes the staged critical GPT diagnostics API without waiting for presentation', async () => { + const target: Record = {}; + const renderTrace = Object.freeze({ current: vi.fn(), history: vi.fn(), subscribe: vi.fn() }); + const gpt = Object.freeze({ + snapshot: vi.fn(() => Object.freeze({ slots: Object.freeze([]) })), + export: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + show: vi.fn(), + hide: vi.fn(), + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['render_runtime', 'gpt_diagnostics', 'diagnostics_presentation']), + knownIntegrationIds: Object.freeze([ + 'render_runtime', + 'gpt_diagnostics', + 'diagnostics_presentation', + ]), + catalog: Object.freeze([ + Object.freeze({ + id: 'render_runtime', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['direct.v1']), + }), + Object.freeze({ + id: 'gpt_diagnostics', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['gpt_diag.v1']), + }), + Object.freeze({ + id: 'diagnostics_presentation', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + consumes: Object.freeze(['runtime.v1', 'gpt_diag.v1']), + provides: Object.freeze([]), + }), + ]), + boot: { + ...boot(), + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration({ + abi: 1, + id: 'render_runtime', + phase: 'critical', + releaseId: RELEASE, + prepare: () => + Object.freeze({ + activate: () => undefined, + interfaces: Object.freeze({ + 'direct.v1': Object.freeze({ + addAdUnits: vi.fn(), + requestAds: vi.fn(), + diagnostics: Object.freeze({ renderTrace }), + }), + }), + }), + }) + ).toBe(true); + expect( + runtime.registerIntegration({ + abi: 1, + id: 'gpt_diagnostics', + phase: 'critical', + releaseId: RELEASE, + prepare: () => + Object.freeze({ + activate: () => undefined, + interfaces: Object.freeze({ + 'gpt_diag.v1': Object.freeze({ api: gpt, attachPresentation: vi.fn() }), + }), + }), + }) + ).toBe(true); + + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const diagnostics = target['diagnostics'] as Readonly>; + expect(Object.isFrozen(diagnostics)).toBe(true); + expect(Reflect.ownKeys(diagnostics)).toEqual(['renderTrace', 'gpt']); + expect(diagnostics['renderTrace']).toBe(renderTrace); + expect(diagnostics['gpt']).toBe(gpt); + }); + + it('binds creative and GPT diagnostics from the private validated boot snapshot', async () => { + const target: Record = {}; + const prepared = new Map(); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['creative', 'gpt_diagnostics', 'diagnostics_presentation']), + knownIntegrationIds: Object.freeze([ + 'creative', + 'gpt_diagnostics', + 'diagnostics_presentation', + ]), + catalog: Object.freeze([ + Object.freeze({ + id: 'creative', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze([]), + }), + Object.freeze({ + id: 'gpt_diagnostics', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze(['runtime.v1']), + provides: Object.freeze(['gpt_diag.v1']), + }), + Object.freeze({ + id: 'diagnostics_presentation', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + consumes: Object.freeze(['runtime.v1', 'gpt_diag.v1']), + provides: Object.freeze([]), + }), + ]), + boot: { + ...boot(), + creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + getBindings: () => + Object.freeze({ + config: Object.freeze({ publisherControlled: true }), + interfaces: Object.freeze({}), + }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + for (const id of ['creative', 'gpt_diagnostics']) { + expect( + runtime.registerIntegration({ + abi: 1, + id, + phase: 'critical', + releaseId: RELEASE, + prepare: ({ config }: { config: unknown }) => { + prepared.set(id, config); + return id === 'gpt_diagnostics' + ? Object.freeze({ + activate: () => undefined, + interfaces: Object.freeze({ + 'gpt_diag.v1': Object.freeze({ + api: Object.freeze({ + snapshot: vi.fn(), + export: vi.fn(), + subscribe: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + }), + attachPresentation: vi.fn(), + }), + }), + }) + : Object.freeze({ activate: () => undefined }); + }, + }) + ).toBe(true); + } + + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(prepared.get('creative')).toEqual({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }); + expect(prepared.get('gpt_diagnostics')).toEqual({ active: true }); + expect(Object.isFrozen(prepared.get('creative'))).toBe(true); + expect(Object.isFrozen(prepared.get('gpt_diagnostics'))).toBe(true); + }); + + it('keeps the authenticated registrar live and starts deferred loading only after the gate', async () => { + vi.useFakeTimers(); + const frames: FrameRequestCallback[] = []; + const idle: Array<() => void> = []; + const criticalHash = 'c'.repeat(64); + const deferredHash = 'd'.repeat(64); + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${criticalHash}`; + document.head.insertBefore(criticalScript, null); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: criticalScript, + }); + const target: Record = {}; + const deferredPrepare = vi.fn(() => Object.freeze({ activate: () => undefined })); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + document, + manifest: { + version: 1, + releaseId: RELEASE, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${criticalHash}`, + integrations: [ + { id: 'render_runtime', phase: 'critical' }, + { + id: 'gpt_later', + phase: 'deferred', + trigger: 'first_display_or_idle', + src: `/static/tsjs=tsjs-gpt_later.min.js?v=${deferredHash}`, + }, + ], + }, + knownIntegrationIds: Object.freeze(['render_runtime', 'gpt_later']), + catalog: Object.freeze([ + Object.freeze({ + id: 'render_runtime', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + Object.freeze({ + id: 'gpt_later', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }), + ]), + boot: boot(), + getBindings: () => Object.freeze({ config: undefined, interfaces: Object.freeze({}) }), + phaseScheduler: { + cancelAnimationFrame: vi.fn(), + cancelIdleCallback: vi.fn(), + clearTimeout, + requestAnimationFrame: (callback) => { + frames.push(callback); + return frames.length; + }, + requestIdleCallback: (callback) => { + idle.push(callback); + return idle.length; + }, + setTimeout, + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration({ + abi: 1, + id: 'render_runtime', + phase: 'critical', + releaseId: RELEASE, + prepare: () => Object.freeze({ activate: () => undefined }), + }) + ).toBe(true); + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(Object.getOwnPropertyDescriptor(target, '_registerIntegration')).toMatchObject({ + configurable: false, + enumerable: true, + writable: false, + }); + expect(document.head.querySelectorAll('script')).toHaveLength(1); + + expect(runtime.protectFirstDisplayAttemptBatch([Promise.resolve()])).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + frames.shift()?.(1); + frames.shift()?.(2); + idle.shift()?.(); + await Promise.resolve(); + await Promise.resolve(); + const deferredScript = [...document.head.querySelectorAll('script')].find( + (script) => script !== criticalScript + ); + expect(deferredScript?.src).toContain('tsjs-gpt_later.min.js'); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: deferredScript, + }); + const register = target['_registerIntegration']; + expect(typeof register).toBe('function'); + expect( + Reflect.apply(register as (...args: unknown[]) => unknown, target, [ + { + abi: 1, + id: 'gpt_later', + phase: 'deferred', + releaseId: RELEASE, + prepare: deferredPrepare, + }, + ]) + ).toBe(true); + deferredScript?.dispatchEvent(new Event('load')); + await vi.waitFor(() => expect(deferredPrepare).toHaveBeenCalledOnce()); + }); + + it('loads overlay-only presentation, GPT later, and Prebid later as separate authenticated artifacts', async () => { + vi.useFakeTimers(); + const criticalHash = 'c'.repeat(64); + const deferredHash = 'd'.repeat(64); + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = `${window.location.origin}/static/tsjs=tsjs-unified.min.js?v=${criticalHash}`; + document.head.insertBefore(criticalScript, null); + let executingScript: HTMLScriptElement | null = criticalScript; + vi.spyOn(document, 'currentScript', 'get').mockImplementation(() => executingScript); + const frames: FrameRequestCallback[] = []; + const idle: Array<() => void> = []; + const target: Record = {}; + const traceAttach = vi.fn(() => vi.fn()); + const traceDiagnostics = Object.freeze({ + current: vi.fn(() => Object.freeze({})), + history: vi.fn(() => Object.freeze([])), + subscribe: vi.fn(() => vi.fn()), + }); + const traceDataCapability = Object.freeze({ diagnostics: traceDiagnostics }); + const tracePresentationCapability = Object.freeze({ attachPresentation: traceAttach }); + const gptLaterRelease = vi.fn(); + const prebidLaterRelease = vi.fn(); + const gptLater = Object.freeze({ + activate: vi.fn(() => gptLaterRelease), + start: vi.fn(), + }); + const prebidLater = Object.freeze({ + activate: vi.fn(() => prebidLaterRelease), + start: vi.fn(), + }); + const deferredIds = Object.freeze(['diagnostics_presentation', 'gpt_later', 'prebid_later']); + const manifestEntries = deferredIds.map((id) => + Object.freeze({ + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + src: `/static/tsjs=tsjs-${id}.min.js?v=${deferredHash}`, + }) + ); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + document, + manifest: { + version: 1, + releaseId: RELEASE, + criticalSrc: `/static/tsjs=tsjs-unified.min.js?v=${criticalHash}`, + integrations: [{ id: 'trace_provider', phase: 'critical' }, ...manifestEntries], + }, + knownIntegrationIds: Object.freeze([ + 'trace_provider', + 'optional_gpt_diag_provider', + ...deferredIds, + ]), + catalog: Object.freeze([ + Object.freeze({ + id: 'trace_provider', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze(['trace.v1', 'trace.presentation.v1']), + }), + Object.freeze({ + id: 'optional_gpt_diag_provider', + phase: 'critical' as const, + trigger: null, + consumes: Object.freeze([]), + provides: Object.freeze(['gpt_diag.v1']), + }), + Object.freeze({ + id: 'diagnostics_presentation', + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + consumes: Object.freeze([ + 'runtime.v1', + 'trace.presentation.v1', + 'gpt_diag.v1?gpt_diagnostics_active', + ]), + provides: Object.freeze([]), + }), + ...deferredIds.slice(1).map((id) => + Object.freeze({ + id, + phase: 'deferred' as const, + trigger: 'first_display_or_idle' as const, + consumes: Object.freeze([]), + provides: Object.freeze([]), + }) + ), + ]), + boot: { + ...boot(), + diagnostics: { version: 1, renderTraceOverlay: true, gpt: { active: false } }, + }, + getBindings: (id) => + Object.freeze({ + config: id === 'gpt_later' || id === 'prebid_later' ? Object.freeze({}) : undefined, + interfaces: Object.freeze( + id === 'gpt_later' + ? { gpt_later: gptLater } + : id === 'prebid_later' + ? { prebid_later: prebidLater } + : {} + ), + }), + phaseScheduler: { + cancelAnimationFrame: vi.fn(), + cancelIdleCallback: vi.fn(), + clearTimeout, + requestAnimationFrame: (callback) => { + frames.push(callback); + return frames.length; + }, + requestIdleCallback: (callback) => { + idle.push(callback); + return idle.length; + }, + setTimeout, + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration({ + abi: 1, + id: 'trace_provider', + phase: 'critical', + releaseId: RELEASE, + prepare: () => + Object.freeze({ + activate: () => undefined, + interfaces: Object.freeze({ + 'trace.v1': traceDataCapability, + 'trace.presentation.v1': tracePresentationCapability, + }), + }), + }) + ).toBe(true); + expect(Reflect.ownKeys(traceDataCapability)).toEqual(['diagnostics']); + expect(traceDataCapability).not.toHaveProperty('attachPresentation'); + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(document.head.querySelectorAll('script')).toHaveLength(1); + expect(traceAttach).not.toHaveBeenCalled(); + expect(gptLater.activate).not.toHaveBeenCalled(); + expect(prebidLater.activate).not.toHaveBeenCalled(); + + const loadedSources: string[] = []; + const originalHeadAppend = document.head.append.bind(document.head); + vi.spyOn(document.head, 'append').mockImplementation((...nodes) => { + originalHeadAppend(...nodes); + for (const node of nodes) { + if (!(node instanceof HTMLScriptElement) || node === criticalScript) continue; + const entry = manifestEntries.find(({ src }) => node.src.endsWith(src)); + if (!entry) throw new Error('Unexpected deferred artifact source'); + executingScript = node; + loadedSources.push(node.src); + const registration = + entry.id === 'diagnostics_presentation' + ? createDiagnosticsPresentationIntegrationRegistration(RELEASE) + : createLifecycleIntegrationRegistration(entry.id, RELEASE); + expect(runtime.registerIntegration(registration)).toBe(true); + node.onload?.(new Event('load')); + executingScript = criticalScript; + } + }); + + expect(runtime.protectFirstDisplayAttemptBatch([Promise.resolve()])).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + frames.shift()?.(1); + frames.shift()?.(2); + idle.shift()?.(); + await vi.waitFor(() => { + expect(traceAttach).toHaveBeenCalledOnce(); + expect(gptLater.start).toHaveBeenCalledOnce(); + expect(prebidLater.start).toHaveBeenCalledOnce(); + }); + expect(loadedSources).toEqual( + manifestEntries.map(({ src }) => new URL(src, window.location.origin).href) + ); + expect(new Set(loadedSources)).toHaveLength(3); + expect(loadedSources.every((source) => !source.includes('tsjs-unified'))).toBe(true); + expect(gptLater.activate).toHaveBeenCalledOnce(); + expect(prebidLater.activate).toHaveBeenCalledOnce(); + + runtime.dispose(); + expect(gptLaterRelease).toHaveBeenCalledOnce(); + expect(prebidLaterRelease).toHaveBeenCalledOnce(); + }); + + it('resolves the frozen diagnostics namespace only after core and module activation', async () => { + const target: Record = {}; + const diagnostics = Object.freeze({ renderTrace: Object.freeze({}) }); + let activated = false; + const getDiagnosticsForPublish = vi.fn(() => { + expect(activated).toBe(true); + return diagnostics; + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + activateCore: () => { + activated = true; + }, + getDiagnosticsForPublish, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({ premature: true }), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(getDiagnosticsForPublish).toHaveBeenCalledOnce(); + expect(target['diagnostics']).toBe(diagnostics); + }); + + it('prepares inert owner interfaces before module preparation and activates afterward', async () => { + const order: string[] = []; + let prepared = false; + const runtime = createRuntime({ + target: { que: [() => order.push('drain')] }, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + prepareOwner: ({ boot: acceptedBoot, onDispose }) => { + expect(Object.isFrozen(acceptedBoot)).toBe(true); + prepared = true; + order.push('owner:prepare'); + onDispose(() => order.push('owner:dispose')); + }, + getBindings: () => { + expect(prepared).toBe(true); + order.push('bindings'); + return { config: Object.freeze({}), interfaces: Object.freeze({}) }; + }, + activateOwner: () => order.push('owner:activate'), + activateCore: () => order.push('core:activate'), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: ({ onDispose }: { onDispose(callback: () => void): void }) => { + order.push('module:prepare'); + onDispose(() => order.push('module:dispose')); + return { + activate: ({ afterCommit }: { afterCommit(callback: () => void): void }) => { + order.push('module:activate'); + afterCommit(() => order.push('after-commit')); + }, + }; + }, + }) + ).toBe(true); + + const result = await runtime.install(); + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'owner:prepare', + 'bindings', + 'module:prepare', + 'owner:activate', + 'core:activate', + 'module:activate', + 'after-commit', + 'drain', + ]); + + if (result.state === 'kernel') result.dispose(); + expect(order.slice(-2)).toEqual(['module:dispose', 'owner:dispose']); + }); + + it('keeps persistent activation and publication inside the supplied takeover call stack', async () => { + const order: string[] = []; + const runtime = createRuntime({ + target: { que: [() => order.push('drain')] }, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + prepareOwner: () => order.push('prepare'), + activateOwner: () => order.push('activate'), + coordinateTakeover: (prepared) => { + order.push('takeover:begin'); + prepared.activate(); + order.push('takeover:activated'); + prepared.commit(); + order.push('takeover:committed'); + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'prepare', + 'takeover:begin', + 'activate', + 'takeover:activated', + 'drain', + 'takeover:committed', + ]); + }); + + it('stops activation when owner activation disposes the installing runtime', async () => { + const activateCore = vi.fn(); + const activateModule = vi.fn(); + const disposeOwner = vi.fn(); + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + activateOwner: ({ onDispose }) => { + onDispose(disposeOwner); + runtime.dispose(); + }, + activateCore, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: () => ({ activate: activateModule }), + }) + ).toBe(true); + + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activateCore).not.toHaveBeenCalled(); + expect(activateModule).not.toHaveBeenCalled(); + expect(disposeOwner).toHaveBeenCalledOnce(); + expect(runtime.state).toBe('fallback'); + expect(target).toMatchObject({ + _internal: { state: 'fallback', releaseId: RELEASE, reason: 'bundle_partial' }, + }); + }); + + it('runs queued work at the exact activation, commit, afterCommit, and FIFO drain boundaries', async () => { + const order: string[] = []; + let commitPushInstalled = false; + const backing: { que?: unknown[]; version?: string } = { + que: [ + function (this: unknown) { + expect(this).toBe(target); + order.push('preload-start'); + target.que?.push(() => order.push('preload-nested')); + order.push('preload-end'); + }, + ], + }; + const target = new Proxy(backing, { + defineProperty(object, key, descriptor) { + if (key === 'version' && !commitPushInstalled) { + commitPushInstalled = true; + object.que?.push(() => order.push('commit-enqueued')); + } + return Reflect.defineProperty(object, key, descriptor); + }, + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + activateCore: () => { + order.push('core-activation'); + target.que?.push(() => order.push('core-enqueued')); + }, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + expect( + runtime.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: () => ({ + activate: ({ afterCommit }: { afterCommit(callback: () => void): void }) => { + order.push('module-activation'); + target.que?.push(() => order.push('module-enqueued')); + afterCommit(() => { + order.push('after-commit-start'); + target.que?.push(() => order.push('after-commit-enqueued')); + order.push('after-commit-end'); + }); + }, + }), + }) + ).toBe(true); + + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + expect(order).toEqual([ + 'core-activation', + 'module-activation', + 'commit-enqueued', + 'after-commit-start', + 'after-commit-enqueued', + 'after-commit-end', + 'preload-start', + 'preload-nested', + 'preload-end', + 'core-enqueued', + 'module-enqueued', + ]); + }); + + it.each([ + ['invalid manifest', { version: 2 }, 'abi_mismatch'], + ['missing bundle', manifest(['gpt']), 'bundle_partial'], + ] as const)('commits terminal fallback for %s', async (_name, candidateManifest, reason) => { + vi.useFakeTimers(); + const queued = vi.fn(); + const activateCore = vi.fn(); + const target = { que: [queued], boot: boot() }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: candidateManifest, + knownIntegrationIds: Object.freeze(['gpt']), + boot: target.boot, + activateCore, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + runtime.start(); + const installed = runtime.install(); + if (reason === 'bundle_partial') await vi.advanceTimersByTimeAsync(10_000); + await expect(installed).resolves.toEqual({ state: 'fallback', reason }); + + expect(runtime.state).toBe('fallback'); + expect(activateCore).not.toHaveBeenCalled(); + expect(queued).toHaveBeenCalledOnce(); + expect(target).toMatchObject({ version: '1.0.0', releaseId: RELEASE }); + expect((target as { _internal?: unknown })._internal).toEqual({ + state: 'fallback', + releaseId: RELEASE, + reason, + }); + await expect( + (target as unknown as { requestAds(options?: unknown): Promise }).requestAds() + ).resolves.toEqual({ slots: [] }); + expect( + (target as unknown as { _registerIntegration(value: unknown): boolean })._registerIntegration( + { + id: 'gpt', + releaseId: RELEASE, + prepare: vi.fn(), + } + ) + ).toBe(false); + }); + + it('publishes the captured exact critical source when the manifest field is missing', async () => { + const criticalSrc = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = new URL(criticalSrc, window.location.origin).href; + document.head.insertBefore(criticalScript, null); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: criticalScript, + }); + const candidateManifest = { + version: 1, + releaseId: RELEASE, + integrations: [], + }; + const target = { + boot: { + ...boot(), + manifest: candidateManifest, + }, + }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + document, + manifest: candidateManifest, + knownIntegrationIds: Object.freeze([] as string[]), + boot: target.boot, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect((target as { boot: { manifest: unknown } }).boot.manifest).toEqual({ + version: 1, + releaseId: RELEASE, + criticalSrc, + integrations: [], + }); + }); + + it('publishes the captured exact critical source when the manifest field is malformed', async () => { + const criticalSrc = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; + const criticalScript = document.createElement('script'); + criticalScript.id = 'trustedserver-js'; + criticalScript.src = new URL(criticalSrc, window.location.origin).href; + document.head.insertBefore(criticalScript, null); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: criticalScript, + }); + const candidateManifest = { + version: 1, + releaseId: RELEASE, + criticalSrc: `${criticalSrc}&publisher=1`, + integrations: [], + }; + const target = { + boot: { + ...boot(), + manifest: candidateManifest, + }, + }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + document, + manifest: candidateManifest, + knownIntegrationIds: Object.freeze([] as string[]), + boot: target.boot, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect((target as { boot: { manifest: unknown } }).boot.manifest).toEqual({ + version: 1, + releaseId: RELEASE, + criticalSrc, + integrations: [], + }); + }); + + it('leaves the namespace unclaimed when no trusted critical source exists', () => { + const target = { + boot: boot(), + que: [vi.fn()], + }; + const bootDescriptor = Object.getOwnPropertyDescriptor(target, 'boot'); + const queueDescriptor = Object.getOwnPropertyDescriptor(target, 'que'); + const runtime = createRuntimeOwner({ + target, + releaseId: RELEASE, + manifest: { version: 1, releaseId: RELEASE, integrations: [] }, + knownIntegrationIds: Object.freeze([] as string[]), + boot: target.boot, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(Object.getOwnPropertyDescriptor(target, 'boot')).toEqual(bootDescriptor); + expect(Object.getOwnPropertyDescriptor(target, 'que')).toEqual(queueDescriptor); + expect(target).not.toHaveProperty('_registerIntegration'); + expect(target).not.toHaveProperty('_internal'); + }); + + it('publishes an exact terminal namespace with no publisher-owned fields', async () => { + const target = { + que: [] as unknown[], + diagnostics: { legacy: true }, + adInit: vi.fn(), + renderAdUnit: vi.fn(), + setConfig: vi.fn(), + publisher: { retained: true }, + }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + + expect(Object.prototype.hasOwnProperty.call(target, 'diagnostics')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(target, 'adInit')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(target, 'renderAdUnit')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(target, 'setConfig')).toBe(false); + expect(target).not.toHaveProperty('publisher'); + }); + + it('allows exactly one bootstrap owner for a namespace', () => { + const target = {}; + const options = { + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([] as string[]), + boot: boot(), + kernel: { + addAdUnits: () => ({ registered: [] }), + diagnostics: Object.freeze({}), + requestAds: async () => ({ slots: [] }), + }, + }; + const first = createRuntime(options); + const second = createRuntime(options); + + expect(first.start()).toBe(true); + expect(second.start()).toBe(false); + expect(first.generation).not.toBe(second.generation); + }); + + it('self-discards a stale async preparation before activation when a later owner commits', async () => { + const target = {}; + const staleCoreActivation = vi.fn(); + const staleModuleActivation = vi.fn(); + const staleDisposal = vi.fn(); + let resolveStalePreparation: (() => void) | undefined; + const stalePreparation = new Promise((resolve) => { + resolveStalePreparation = resolve; + }); + const first = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + activateCore: staleCoreActivation, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + const secondRequestAds = vi.fn(); + const second = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: secondRequestAds, + }, + }); + + expect(first.start()).toBe(true); + expect( + first.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: ({ onDispose }: { onDispose(callback: () => void): void }) => { + onDispose(staleDisposal); + return stalePreparation.then(() => ({ activate: staleModuleActivation })); + }, + }) + ).toBe(true); + const staleInstall = first.install(); + await Promise.resolve(); + + expect(Reflect.deleteProperty(target, '_registerIntegration')).toBe(true); + expect(second.start()).toBe(true); + expect( + second.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: () => ({ activate: vi.fn() }), + }) + ).toBe(true); + await expect(second.install()).resolves.toMatchObject({ state: 'kernel' }); + + resolveStalePreparation?.(); + await expect(staleInstall).resolves.toEqual({ state: 'fallback', reason: 'bundle_partial' }); + + expect(staleCoreActivation).not.toHaveBeenCalled(); + expect(staleModuleActivation).not.toHaveBeenCalled(); + expect(staleDisposal).toHaveBeenCalledOnce(); + expect(first.state).toBe('failed'); + expect(second.state).toBe('kernel'); + expect((target as { requestAds?: unknown }).requestAds).toBe(secondRequestAds); + expect((target as { _internal?: unknown })._internal).toEqual({ + state: 'kernel', + releaseId: RELEASE, + }); + }); + + it('rejects registration when candidate reflection replaces the owner handshake', async () => { + const target = {}; + const staleCoreActivation = vi.fn(); + const staleModuleActivation = vi.fn(); + const options = { + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }; + const first = createRuntime({ ...options, activateCore: staleCoreActivation }); + const second = createRuntime(options); + + expect(first.start()).toBe(true); + const registration = new Proxy( + { + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: () => ({ activate: staleModuleActivation }), + }, + { + ownKeys(candidate) { + expect(Reflect.deleteProperty(target, '_registerIntegration')).toBe(true); + return Reflect.ownKeys(candidate); + }, + } + ); + + expect(first.registerIntegration(registration)).toBe(false); + expect(second.start()).toBe(true); + expect( + second.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: () => ({ activate: vi.fn() }), + }) + ).toBe(true); + await expect(second.install()).resolves.toMatchObject({ state: 'kernel' }); + await expect(first.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + + expect(staleCoreActivation).not.toHaveBeenCalled(); + expect(staleModuleActivation).not.toHaveBeenCalled(); + expect(first.state).toBe('failed'); + expect(second.state).toBe('kernel'); + }); + + it('allows exactly one bootstrap owner across independently evaluated core modules', async () => { + const target = {}; + const options = { + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([] as string[]), + boot: boot(), + kernel: { + addAdUnits: () => ({ registered: [] }), + diagnostics: Object.freeze({}), + requestAds: async () => ({ slots: [] }), + }, + }; + const firstModule = await import('../../src/kernel/runtime'); + vi.resetModules(); + const secondModule = await import('../../src/kernel/runtime'); + installTestCriticalScript(document); + const first = firstModule.createRuntime(options); + const second = secondModule.createRuntime(options); + + expect(first.start()).toBe(true); + expect(second.start()).toBe(false); + expect(first.generation).not.toBe(second.generation); + }); + + it('refuses a conflicting terminal namespace before constructing an installing generation', () => { + const target: { que: unknown[]; version?: string } = { que: [] }; + Object.defineProperty(target, 'version', { + configurable: false, + enumerable: true, + value: 'publisher', + writable: false, + }); + const queueDescriptor = Object.getOwnPropertyDescriptor(target, 'que'); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([] as string[]), + boot: boot(), + kernel: { + addAdUnits: () => ({ registered: [] }), + diagnostics: Object.freeze({}), + requestAds: async () => ({ slots: [] }), + }, + }); + + expect(runtime.start()).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(Object.getOwnPropertyDescriptor(target, 'que')).toEqual(queueDescriptor); + expect(Reflect.ownKeys(target)).toEqual(['que', 'version']); + }); + + it.each([ + [ + 'wrong release', + { + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: 'b'.repeat(64), + prepare: vi.fn(), + }, + ], + ['unknown id', { abi: 1, id: 'aps', phase: 'critical', releaseId: RELEASE, prepare: vi.fn() }], + ])( + 'classifies %s registration as abi_mismatch without invoking module code', + async (_name, registration) => { + const runtime = createRuntime({ + target: {}, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + runtime.start(); + + expect(runtime.registerIntegration(registration)).toBe(false); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect(registration.prepare).not.toHaveBeenCalled(); + } + ); + + it('classifies duplicate registration as abi_mismatch', async () => { + const prepare = vi.fn(() => ({ activate: vi.fn() })); + const registration = { + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare, + }; + const runtime = createRuntime({ + target: {}, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + runtime.start(); + expect(runtime.registerIntegration(registration)).toBe(true); + expect(runtime.registerIntegration(registration)).toBe(false); + + await expect(runtime.install()).resolves.toEqual({ state: 'fallback', reason: 'abi_mismatch' }); + expect(prepare).not.toHaveBeenCalled(); + }); + + it.each(['prepare_throw', 'prepare_reject', 'activate_throw'] as const)( + 'unwinds %s as bundle_partial', + async (checkpoint) => { + const disposed = vi.fn(); + const prepare = + checkpoint === 'prepare_throw' + ? () => { + throw new Error('prepare'); + } + : checkpoint === 'prepare_reject' + ? async ({ onDispose }: { onDispose(callback: () => void): void }) => { + onDispose(disposed); + throw new Error('prepare'); + } + : ({ onDispose }: { onDispose(callback: () => void): void }) => { + onDispose(disposed); + return { + activate: () => { + throw new Error('activate'); + }, + }; + }; + const runtime = createRuntime({ + target: {}, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + runtime.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare, + }); + + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + if (checkpoint !== 'prepare_throw') expect(disposed).toHaveBeenCalledOnce(); + } + ); + + it('shares the ten-second watchdog with a hung preparation and ignores its late continuation', async () => { + vi.useFakeTimers(); + let finish: ((value: { activate(): void }) => void) | undefined; + const lateActivate = vi.fn(); + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + runtime.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: () => + new Promise<{ activate(): void }>((resolve) => { + finish = resolve; + }), + }); + const installed = runtime.install(); + + await vi.advanceTimersByTimeAsync(10_000); + await expect(installed).resolves.toEqual({ state: 'fallback', reason: 'bundle_partial' }); + finish?.({ activate: lateActivate }); + await Promise.resolve(); + expect(lateActivate).not.toHaveBeenCalled(); + expect(runtime.state).toBe('fallback'); + }); + + it('isolates afterCommit failure after kernel publication', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest(['gpt']), + knownIntegrationIds: Object.freeze(['gpt']), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + runtime.registerIntegration({ + abi: 1, + id: 'gpt', + phase: 'critical', + releaseId: RELEASE, + prepare: () => ({ + activate: ({ afterCommit }: { afterCommit(callback: () => void): void }) => + afterCommit(() => { + throw new Error('post commit'); + }), + }), + }); + + await expect(runtime.install()).resolves.toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'gpt', phase: 'after_commit' }], + }); + expect(runtime.state).toBe('kernel'); + }); + + it('validates fallback calls and settles known, unknown, and aborted slots', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot([{ slot: 'known', outcome: 'no_bid' }]), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const api = target as unknown as { + addAdUnits(units: unknown): unknown; + requestAds(options?: unknown): Promise; + boot: unknown; + }; + + await expect(api.requestAds({ slots: ['known', 'unknown'] })).resolves.toEqual({ + slots: [ + { slot: 'known', path: 'primary', outcome: 'failed', reason: 'abi_mismatch' }, + { slot: 'unknown', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + ], + }); + const controller = new AbortController(); + controller.abort(); + await expect(api.requestAds({ slots: ['known'], signal: controller.signal })).resolves.toEqual({ + slots: [{ slot: 'known', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + await expect(api.requestAds({ slots: [] })).rejects.toBeInstanceOf(RequestAdsInputError); + expect(() => api.addAdUnits({ code: '', mediaTypes: {} })).toThrow(AdUnitRegistrationError); + expect(() => + api.addAdUnits({ + code: 'programmatic', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }) + ).toThrow(TsjsUnavailableError); + expect(Object.isFrozen(api.boot)).toBe(true); + }); + + it('substitutes the exact safe auction projection when boot data is hostile', async () => { + const getter = vi.fn(() => ({ version: 1 })); + const hostile = {}; + Object.defineProperty(hostile, 'auctionProjection', { enumerable: true, get: getter }); + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: hostile, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + + expect(getter).not.toHaveBeenCalled(); + expect( + (target as unknown as { boot: { auctionProjection: unknown } }).boot.auctionProjection + ).toEqual({ + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }); + }); + + it('snapshots fallback boot before publisher mutation during installation', async () => { + const target = { boot: boot([{ slot: 'initial', outcome: 'no_bid' }]) }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + target.boot = boot([{ slot: 'mutated', outcome: 'no_bid' }]); + + await runtime.install(); + const api = target as unknown as { + boot: { auctionProjection: { auction: { results: readonly { slot: string }[] } } }; + requestAds(options: unknown): Promise; + }; + expect(api.boot.auctionProjection.auction.results).toEqual([ + { slot: 'initial', outcome: 'no_bid' }, + ]); + await expect(api.requestAds({ slots: ['initial', 'mutated'] })).resolves.toEqual({ + slots: [ + { slot: 'initial', path: 'primary', outcome: 'failed', reason: 'abi_mismatch' }, + { slot: 'mutated', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + ], + }); + }); + + it.each(['getPrototypeOf', 'ownKeys', 'getOwnPropertyDescriptor'] as const)( + 'maps a hostile request options %s trap to invalid_options', + async (trap) => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const requestAds = (target as unknown as { requestAds(value: unknown): Promise }) + .requestAds; + const optionsTarget = trap === 'getOwnPropertyDescriptor' ? { slots: ['known'] } : {}; + + await expect(requestAds(hostileRecord(trap, optionsTarget))).rejects.toMatchObject({ + code: 'invalid_options', + }); + } + ); + + it.each(['getPrototypeOf', 'ownKeys', 'getOwnPropertyDescriptor'] as const)( + 'maps a hostile addAdUnits unit %s trap to invalid_unit', + async (trap) => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + const unit = hostileRecord(trap, { + code: 'hostile', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }); + + expect(() => addAdUnits(unit)).toThrow( + expect.objectContaining({ code: 'invalid_unit', unitIndex: 0 }) + ); + } + ); + + it('maps hostile outer addAdUnits Array reflection to invalid_units', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + const units = new Proxy([], { + ownKeys() { + throw new Error('hostile outer Array'); + }, + }); + + const error = thrownBy(() => addAdUnits(units)); + expect(error).toMatchObject({ code: 'invalid_units' }); + expect(Object.prototype.hasOwnProperty.call(error, 'unitIndex')).toBe(false); + }); + + it('maps a revoked outer addAdUnits Array proxy to invalid_units', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const { proxy, revoke } = Proxy.revocable([], {}); + revoke(); + + const error = thrownBy(() => + (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits(proxy) + ); + expect(error).toMatchObject({ code: 'invalid_units' }); + expect(Object.prototype.hasOwnProperty.call(error, 'unitIndex')).toBe(false); + }); + + it.each(['getPrototypeOf', 'ownKeys', 'getOwnPropertyDescriptor'] as const)( + 'substitutes exact safe boot for a hostile boot %s trap', + async (trap) => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: hostileRecord(trap, trap === 'getOwnPropertyDescriptor' ? boot() : {}), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect( + (target as unknown as { boot: { auctionProjection: unknown } }).boot.auctionProjection + ).toEqual({ + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }); + } + ); + + it('substitutes exact safe boot when nested boot contract proxies throw', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: { + cachePolicy: hostileRecord('ownKeys'), + auctionProjection: hostileRecord('getPrototypeOf'), + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect( + (target as unknown as { boot: { auctionProjection: unknown } }).boot.auctionProjection + ).toEqual({ + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }); + }); + + it('rejects a full boot whose server manifest disagrees with the accepted bundle manifest', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: { abi: 1, releaseId: RELEASE, manifest: manifest(['gpt']), ...boot() }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + + await expect(runtime.install()).resolves.toEqual({ state: 'fallback', reason: 'abi_mismatch' }); + }); + + it('binds validation and fallback publication to the embedded bundle release', async () => { + const serverRelease = 'b'.repeat(64); + const serverManifest = { version: 1, releaseId: serverRelease, integrations: [] }; + const target = {}; + const runtime = createRuntime({ + target, + releaseId: serverRelease, + manifest: serverManifest, + knownIntegrationIds: Object.freeze([]), + boot: { + abi: 1, + releaseId: serverRelease, + manifest: serverManifest, + ...boot(), + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + + await expect(runtime.install()).resolves.toEqual({ state: 'fallback', reason: 'abi_mismatch' }); + expect(target).toMatchObject({ + releaseId: RELEASE, + boot: { releaseId: RELEASE, manifest: { releaseId: RELEASE } }, + _internal: { state: 'fallback', releaseId: RELEASE, reason: 'abi_mismatch' }, + }); + }); + + it('does not invoke hostile Array iterators at fallback input boundaries', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot([{ slot: 'known', outcome: 'no_bid' }]), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const iterator = vi.fn(); + const slots = ['known']; + Object.defineProperty(slots, Symbol.iterator, { value: iterator }); + + await expect( + (target as unknown as { requestAds(value: unknown): Promise }).requestAds({ slots }) + ).rejects.toMatchObject({ code: 'invalid_slots' }); + expect(iterator).not.toHaveBeenCalled(); + }); + + it('uses exact addAdUnits dimension and bidder validation before refusing valid input', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + + expect(() => + addAdUnits({ code: 'zero', mediaTypes: { banner: { sizes: [[0, 250]] } } }) + ).toThrow(expect.objectContaining({ code: 'invalid_dimensions' })); + expect(() => + addAdUnits({ code: 'large', mediaTypes: { banner: { sizes: [[4097, 250]] } } }) + ).toThrow(expect.objectContaining({ code: 'dimensions_out_of_range' })); + expect(() => + addAdUnits({ + code: 'bad-bidder', + mediaTypes: { banner: { sizes: [[1, 1]] } }, + bids: [{ bidder: 'x'.repeat(65) }], + }) + ).toThrow(expect.objectContaining({ code: 'invalid_bidder' })); + expect(() => + addAdUnits({ + code: 'valid', + mediaTypes: { banner: { sizes: [[1, 4096]] } }, + bids: [{ bidder: 'aps', params: { placement: 'one' } }], + }) + ).toThrow(TsjsUnavailableError); + }); + + it.each([ + ['high', '\ud800'], + ['low', '\udc00'], + ] as const)( + 'rejects a lone %s UTF-16 surrogate in a programmatic slot code', + async (_kind, code) => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + + expect(() => addAdUnits({ code, mediaTypes: { banner: { sizes: [[300, 250]] } } })).toThrow( + expect.objectContaining({ code: 'invalid_code', unitIndex: 0 }) + ); + } + ); + + it('applies fallback slot collision and combined registry capacity validation', async () => { + const makeFallback = async (slots: readonly string[]) => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(slots.map((slot) => ({ slot, outcome: 'no_bid' }))), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + return (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + }; + const collision = await makeFallback(['server']); + expect(() => + collision({ code: 'server', mediaTypes: { banner: { sizes: [[300, 250]] } } }) + ).toThrow(expect.objectContaining({ code: 'slot_collision', unitIndex: 0 })); + + const full = await makeFallback(Array.from({ length: 256 }, (_, index) => `slot-${index}`)); + const capacityError = thrownBy(() => + full({ code: 'overflow', mediaTypes: { banner: { sizes: [[300, 250]] } } }) + ); + expect(capacityError).toMatchObject({ code: 'registry_capacity' }); + expect(Object.prototype.hasOwnProperty.call(capacityError, 'unitIndex')).toBe(false); + }); + + it('reports aggregate request overflow before combined registry capacity', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot( + Array.from({ length: 256 }, (_, index) => ({ + slot: `server-${index}`, + outcome: 'no_bid', + })) + ), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + + expect(() => + addAdUnits({ + code: 'programmatic-overflow', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'aps', params: { payload: 'x'.repeat(256 * 1024) } }], + }) + ).toThrow(expect.objectContaining({ code: 'request_body_too_large' })); + }); + + it('accepts contract-valid large collections and deep params before refusing availability', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + const sizes = Array.from({ length: 257 }, () => [1, 1]); + const bids = Array.from({ length: 257 }, () => ({ bidder: 'aps' })); + const paramsArray = Array.from({ length: 4097 }, () => 0); + let deepParams: object = { leaf: true }; + for (let depth = 0; depth < 128; depth += 1) deepParams = { child: deepParams }; + + for (const unit of [ + { code: 'many-sizes', mediaTypes: { banner: { sizes } } }, + { code: 'many-bids', mediaTypes: { banner: { sizes: [[1, 1]] } }, bids }, + { + code: 'large-params-array', + mediaTypes: { banner: { sizes: [[1, 1]] } }, + bids: [{ bidder: 'aps', params: { values: paramsArray } }], + }, + { + code: 'deep-params', + mediaTypes: { banner: { sizes: [[1, 1]] } }, + bids: [{ bidder: 'aps', params: deepParams }], + }, + ]) { + expect(() => addAdUnits(unit)).toThrow(TsjsUnavailableError); + } + }); + + it('classifies an empty banner size list as invalid_media_types', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + + expect(() => + (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits({ + code: 'empty-sizes', + mediaTypes: { banner: { sizes: [] } }, + }) + ).toThrow(expect.objectContaining({ code: 'invalid_media_types', unitIndex: 0 })); + }); + + it('bounds an exponentially expanded shared params DAG without revisiting nodes', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const addAdUnits = (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits; + const descriptorReads: number[] = []; + let shared: object = { value: 'leaf' }; + for (let depth = 0; depth < 24; depth += 1) { + const node = { left: shared, right: shared }; + const nodeIndex = descriptorReads.length; + descriptorReads.push(0); + shared = new Proxy(node, { + getOwnPropertyDescriptor(object, key) { + descriptorReads[nodeIndex] = (descriptorReads[nodeIndex] ?? 0) + 1; + if ((descriptorReads[nodeIndex] ?? 0) > Reflect.ownKeys(object).length) { + throw new Error('shared DAG node was expanded more than once'); + } + return Reflect.getOwnPropertyDescriptor(object, key); + }, + }); + } + + expect(() => + addAdUnits({ + code: 'shared-dag', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'aps', params: shared }], + }) + ).toThrow(expect.objectContaining({ code: 'request_body_too_large' })); + expect(descriptorReads.every((reads) => reads <= 2)).toBe(true); + }); + + it('measures addAdUnits input without invoking inherited toJSON hooks', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const hook = vi.fn(() => { + throw new Error('publisher toJSON'); + }); + Object.defineProperty(Object.prototype, 'toJSON', { configurable: true, value: hook }); + Object.defineProperty(Array.prototype, 'toJSON', { configurable: true, value: hook }); + try { + expect(() => + (target as unknown as { addAdUnits(value: unknown): unknown }).addAdUnits({ + code: 'valid', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'aps', params: { placement: 'one' } }], + }) + ).toThrow(TsjsUnavailableError); + expect(hook).not.toHaveBeenCalled(); + } finally { + Reflect.deleteProperty(Object.prototype, 'toJSON'); + Reflect.deleteProperty(Array.prototype, 'toJSON'); + } + }); + + it('publishes an immutable exact logger facade', async () => { + const target = {}; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: { version: 2 }, + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + runtime.start(); + await runtime.install(); + const publicLog = (target as unknown as { log: object }).log; + + expect(Object.isFrozen(publicLog)).toBe(true); + expect(Object.keys(publicLog)).toEqual([ + 'setLevel', + 'getLevel', + 'error', + 'warn', + 'info', + 'debug', + ]); + expect(Reflect.set(publicLog, 'warn', vi.fn())).toBe(false); + }); + + it('returns false when queue descriptor reflection becomes hostile after preflight', () => { + let queueDescriptorReads = 0; + const backing = {}; + const target = new Proxy(backing, { + getOwnPropertyDescriptor(object, key) { + if (key === 'que') { + queueDescriptorReads += 1; + if (queueDescriptorReads === 2) throw new Error('hostile second queue reflection'); + } + return Reflect.getOwnPropertyDescriptor(object, key); + }, + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + let started: boolean | undefined; + + expect(() => { + started = runtime.start(); + }).not.toThrow(); + expect(started).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(queueDescriptorReads).toBe(2); + expect(Object.prototype.hasOwnProperty.call(backing, '_registerIntegration')).toBe(false); + }); + + it('returns false when a claim mutation and its rollback restoration both throw', () => { + const ingress: unknown[] = []; + const backing = { que: ingress, boot: boot() }; + let queueDefinitionCalls = 0; + const target = new Proxy(backing, { + defineProperty(object, key, descriptor) { + if (key === 'que') { + queueDefinitionCalls += 1; + if (queueDefinitionCalls === 1) { + Reflect.defineProperty(object, key, descriptor); + throw new Error('hostile claim definition'); + } + if (queueDefinitionCalls === 2) throw new Error('hostile rollback definition'); + } + return Reflect.defineProperty(object, key, descriptor); + }, + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: backing.boot, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + let started: boolean | undefined; + + expect(() => { + started = runtime.start(); + }).not.toThrow(); + expect(started).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(queueDefinitionCalls).toBe(2); + expect(Object.prototype.hasOwnProperty.call(backing, '_registerIntegration')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(backing, 'version')).toBe(false); + expect(Object.getOwnPropertyDescriptor(backing, 'que')).toMatchObject({ + configurable: true, + enumerable: true, + value: ingress, + writable: false, + }); + }); + + it('rolls back a failed start claim without leaving a partial owner', () => { + let fail = true; + const backing = {}; + const target = new Proxy(backing, { + defineProperty(object, key, descriptor) { + if (fail) { + fail = false; + throw new Error('transient define failure'); + } + return Reflect.defineProperty(object, key, descriptor); + }, + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + + expect(runtime.start()).toBe(false); + expect(runtime.state).toBe('unclaimed'); + expect(Object.prototype.hasOwnProperty.call(target, '_registerIntegration')).toBe(false); + expect( + createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }).start() + ).toBe(true); + }); + + it('captures the monotonic start before queue normalization work', async () => { + let time = 0; + const backing = {}; + const target = new Proxy(backing, { + defineProperty(object, key, descriptor) { + time = 10_000; + return Reflect.defineProperty(object, key, descriptor); + }, + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + now: () => time, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }); + expect(runtime.start()).toBe(true); + + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts new file mode 100644 index 000000000..5f305a647 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { + createRuntimeSession, + type NavigationIdentityIssuerFactory, +} from '../../src/kernel/sessions'; + +function identityFactory(seed = 1): NavigationIdentityIssuerFactory { + let navigation = seed; + return () => { + const value = navigation; + navigation += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(value); + return target; + }, + }); + }; +} + +function frozenProjection(id: string): Readonly { + return Object.freeze({ + version: 1, + auction: Object.freeze({ version: 1, auctionId: id, results: Object.freeze([]) }), + bids: Object.freeze([]), + }); +} + +describe('runtime and navigation sessions', () => { + it('reports every navigation generation exactly once at its disposal boundary', () => { + const onNavigationDispose = vi.fn(); + const runtime = createRuntimeSession({ + createIdentityIssuer: identityFactory(), + onNavigationDispose, + }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + + const replacement = runtime.replaceNavigation(); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(onNavigationDispose).toHaveBeenCalledExactlyOnceWith(initial.value.generation); + + runtime.dispose(); + runtime.dispose(); + expect(onNavigationDispose).toHaveBeenCalledTimes(2); + expect(onNavigationDispose).toHaveBeenLastCalledWith(replacement.value.generation); + }); + + it('owns one current navigation and replaces it atomically before reverse disposal', () => { + const order: string[] = []; + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + + expect(initial.ok).toBe(true); + if (!initial.ok) throw new Error('Expected initial navigation'); + expect(runtime.currentNavigation).toBe(initial.value); + initial.value.onDispose('first', () => order.push('first')); + initial.value.onDispose('second', () => { + expect(runtime.currentNavigation).not.toBe(initial.value); + order.push('second'); + }); + + const replacement = runtime.replaceNavigation(); + + expect(replacement.ok).toBe(true); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(runtime.currentNavigation).toBe(replacement.value); + expect(initial.value.disposed).toBe(true); + expect(replacement.value.currentAuctionProjection).toBeUndefined(); + expect(order).toEqual(['second', 'first']); + expect(runtime.snapshotInventoryForTest()).toMatchObject({ + currentNavigationGeneration: replacement.value.generation, + disposedNavigations: 1, + navigationCount: 1, + }); + }); + + it('makes late old-generation callbacks inert and allows the same DOM alias on a new route', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + const mutation = vi.fn(); + const oldCallback = initial.value.capture(mutation); + + expect(initial.value.claimAlias('shared-dom-id')).toBe(true); + expect(oldCallback('before')).toBe(true); + const replacement = runtime.replaceNavigation(); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + + expect(replacement.value.claimAlias('shared-dom-id')).toBe(true); + expect(oldCallback('late')).toBe(false); + expect(mutation).toHaveBeenCalledExactlyOnceWith('before'); + expect(initial.value.snapshotInventoryForTest().aliases).toBe(0); + expect(replacement.value.snapshotInventoryForTest().aliases).toBe(1); + }); + + it('does not publish the replacement while old-navigation disposers are running', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + let disposerSawCurrent = true; + let aliasMutation: boolean | undefined; + initial.value.onDispose('reentrant-alias', () => { + disposerSawCurrent = runtime.currentNavigation !== undefined; + aliasMutation = runtime.currentNavigation?.claimAlias('must-not-cross-generation'); + }); + + const replacement = runtime.replaceNavigation(); + + expect(replacement).toMatchObject({ ok: true }); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(disposerSawCurrent).toBe(false); + expect(aliasMutation).toBeUndefined(); + expect(runtime.currentNavigation).toBe(replacement.value); + expect(replacement.value.disposed).toBe(false); + expect(replacement.value.snapshotInventoryForTest().aliases).toBe(0); + }); + + it('blocks nested replacement from an old-navigation disposer', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + let nestedReplacement: ReturnType | undefined; + initial.value.onDispose('nested-replacement', () => { + nestedReplacement = runtime.replaceNavigation(); + }); + + const replacement = runtime.replaceNavigation(); + + expect(nestedReplacement).toEqual({ + ok: false, + reason: 'navigation_transition_in_progress', + }); + expect(replacement).toMatchObject({ ok: true }); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(runtime.currentNavigation).toBe(replacement.value); + expect(replacement.value.disposed).toBe(false); + + const successive = runtime.replaceNavigation(); + expect(successive).toMatchObject({ ok: true }); + if (!successive.ok) throw new Error('Expected successive replacement'); + expect(runtime.currentNavigation).toBe(successive.value); + expect(successive.value.disposed).toBe(false); + }); + + it('publishes no replacement if runtime disposal occurs during old-navigation unwind', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + initial.value.onDispose('runtime', () => runtime.dispose()); + + expect(runtime.replaceNavigation()).toEqual({ + ok: false, + reason: 'runtime_disposed', + }); + expect(runtime.currentNavigation).toBeUndefined(); + expect(runtime.disposed).toBe(true); + }); + + it('publishes no initial navigation if identity setup disposes the runtime', () => { + const issueIdentity = identityFactory(); + const runtime = createRuntimeSession({ + createIdentityIssuer: () => { + runtime.dispose(); + return issueIdentity(); + }, + }); + + expect(runtime.startInitialNavigation(frozenProjection('initial'))).toEqual({ + ok: false, + reason: 'runtime_disposed', + }); + expect(runtime.currentNavigation).toBeUndefined(); + expect(runtime.disposed).toBe(true); + }); + + it('blocks nested initial-navigation creation from identity setup', () => { + const issueIdentity = identityFactory(); + let nested: ReturnType['startInitialNavigation']>; + let firstCall = true; + const runtime = createRuntimeSession({ + createIdentityIssuer: () => { + if (firstCall) { + firstCall = false; + nested = runtime.startInitialNavigation(frozenProjection('nested')); + } + return issueIdentity(); + }, + }); + + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + + expect(nested!).toEqual({ + ok: false, + reason: 'navigation_transition_in_progress', + }); + expect(initial).toMatchObject({ ok: true }); + if (!initial.ok) throw new Error('Expected initial navigation'); + expect(runtime.currentNavigation).toBe(initial.value); + expect(initial.value.disposed).toBe(false); + }); + + it('cleans timers, listeners, and ports exactly once across double disposal', () => { + const cleanup = { + timer: vi.fn(), + listener: vi.fn(), + port: vi.fn(), + }; + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const navigation = runtime.startInitialNavigation(frozenProjection('initial')); + if (!navigation.ok) throw new Error('Expected initial navigation'); + + navigation.value.onDispose('timer', cleanup.timer); + navigation.value.onDispose('listener', cleanup.listener); + navigation.value.onDispose('port', cleanup.port); + navigation.value.dispose(); + navigation.value.dispose(); + + expect(cleanup.port).toHaveBeenCalledOnce(); + expect(cleanup.listener).toHaveBeenCalledOnce(); + expect(cleanup.timer).toHaveBeenCalledOnce(); + expect(navigation.value.snapshotInventoryForTest()).toMatchObject({ + disposed: true, + activeDisposers: 0, + disposedByKind: { listener: 1, port: 1, timer: 1 }, + }); + }); + + it('clears an exactly current navigation after direct child disposal', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + + initial.value.dispose(); + + expect(runtime.currentNavigation).toBeUndefined(); + expect(runtime.snapshotInventoryForTest()).toMatchObject({ + currentNavigationGeneration: undefined, + disposedNavigations: 1, + navigationCount: 0, + }); + const replacement = runtime.replaceNavigation(); + expect(replacement).toMatchObject({ ok: true }); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(runtime.currentNavigation).toBe(replacement.value); + expect(replacement.value.disposed).toBe(false); + }); + + it('blocks replacement before remaining direct-disposal callbacks can mutate a new route', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + let replacement: ReturnType | undefined; + let disposerSawCurrent = true; + let aliasMutation: boolean | undefined; + initial.value.onDispose('old-mutator', () => { + disposerSawCurrent = runtime.currentNavigation !== undefined; + aliasMutation = runtime.currentNavigation?.claimAlias('must-not-cross-generation'); + }); + initial.value.onDispose('replacement', () => { + replacement = runtime.replaceNavigation(); + }); + + initial.value.dispose(); + + expect(replacement).toEqual({ + ok: false, + reason: 'navigation_transition_in_progress', + }); + expect(disposerSawCurrent).toBe(false); + expect(aliasMutation).toBeUndefined(); + expect(runtime.currentNavigation).toBeUndefined(); + expect(runtime.snapshotInventoryForTest()).toMatchObject({ + currentNavigationGeneration: undefined, + disposedNavigations: 1, + navigationCount: 0, + }); + + const successive = runtime.replaceNavigation(); + expect(successive).toMatchObject({ ok: true }); + if (!successive.ok) throw new Error('Expected successive navigation'); + expect(runtime.currentNavigation).toBe(successive.value); + expect(successive.value.snapshotInventoryForTest().aliases).toBe(0); + }); + + it('owns auction batches and render attempts in nested child scopes', () => { + const order: string[] = []; + const staleMutation = vi.fn(); + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory(7) }); + const navigation = runtime.startInitialNavigation(frozenProjection('initial')); + if (!navigation.ok) throw new Error('Expected initial navigation'); + const batch = navigation.value.createAuctionBatch('batch-one'); + const overlappingBatch = navigation.value.createAuctionBatch('batch-two'); + + expect(batch).toBeDefined(); + if (!batch) throw new Error('Expected auction batch'); + if (!overlappingBatch) throw new Error('Expected overlapping auction batch'); + const attempt = batch.createRenderAttempt('slot-one'); + const secondAttempt = batch.createRenderAttempt('slot-two'); + expect(attempt).toMatchObject({ ok: true }); + if (!attempt.ok) throw new Error('Expected render attempt'); + expect(secondAttempt).toMatchObject({ ok: true }); + if (!secondAttempt.ok) throw new Error('Expected second render attempt'); + expect(overlappingBatch.createRenderAttempt('slot-one')).toEqual({ + ok: false, + reason: 'attempt_exists', + }); + expect(attempt.value.id).toMatch(/^a1_[A-Za-z0-9_-]{22}$/); + expect(attempt.value.navigationGeneration).toBe(navigation.value.generation); + expect(attempt.value.navigationGeneration).not.toBe(attempt.value.generation); + batch.onDispose('batch', () => order.push('batch')); + batch.onDispose('late-callback', navigation.value.capture(staleMutation)); + attempt.value.onDispose('attempt-first', () => order.push('attempt-first')); + attempt.value.onDispose('attempt-second', () => order.push('attempt-second')); + expect(navigation.value.snapshotInventoryForTest()).toMatchObject({ + attempts: 2, + batches: 2, + retainedAttemptScopes: 2, + retainedBatchScopes: 2, + }); + + secondAttempt.value.dispose(); + overlappingBatch.dispose(); + expect(navigation.value.snapshotInventoryForTest()).toMatchObject({ + attempts: 1, + batches: 1, + retainedAttemptScopes: 1, + retainedBatchScopes: 1, + }); + + navigation.value.dispose(); + + expect(staleMutation).not.toHaveBeenCalled(); + expect(order).toEqual(['attempt-second', 'attempt-first', 'batch']); + expect(batch.disposed).toBe(true); + expect(attempt.value.disposed).toBe(true); + expect(navigation.value.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + batches: 0, + }); + }); + + it('prepares, commits, and rolls back one immutable winner-context admission', () => { + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('Expected navigation'); + const batch = navigation.value.createAuctionBatch('winner-context'); + if (!batch) throw new Error('Expected auction batch'); + const attempt = batch.createRenderAttempt('fictional-slot'); + if (!attempt.ok) throw new Error('Expected render attempt'); + const accepted = Object.freeze({ selectedCpm: 1.25 }); + + expect(attempt.value.winnerContext).toBeUndefined(); + const first = attempt.value.prepareWinnerContext(accepted); + expect(first).toBeDefined(); + expect(attempt.value.winnerContext).toBeUndefined(); + expect(first?.commit()).toBe(true); + expect(attempt.value.winnerContext).toBe(accepted); + expect(first?.rollback()).toBe(true); + expect(attempt.value.winnerContext).toBeUndefined(); + + const committed = attempt.value.prepareWinnerContext(accepted); + expect(committed?.commit()).toBe(true); + expect(attempt.value.winnerContext).toBe(accepted); + expect(attempt.value.prepareWinnerContext(accepted)?.commit()).toBe(true); + expect( + attempt.value.prepareWinnerContext(Object.freeze({ selectedCpm: 1.25 })) + ).toBeUndefined(); + + attempt.value.dispose(); + expect(attempt.value.prepareWinnerContext(Object.freeze({ selectedCpm: 2 }))).toBeUndefined(); + expect(attempt.value.winnerContext).toBe(accepted); + }); + + it('refuses identity failure before replacing or creating route work', () => { + const firstIssuer = identityFactory(); + const createIdentityIssuer = vi + .fn() + .mockImplementationOnce(firstIssuer) + .mockReturnValue({ ok: false, reason: 'identity_generation_failed' }); + const runtime = createRuntimeSession({ createIdentityIssuer }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + const disposer = vi.fn(); + initial.value.onDispose('route', disposer); + + const replacement = runtime.replaceNavigation(); + + expect(replacement).toEqual({ ok: false, reason: 'identity_generation_failed' }); + expect(runtime.currentNavigation).toBe(initial.value); + expect(initial.value.disposed).toBe(false); + expect(disposer).not.toHaveBeenCalled(); + expect(runtime.snapshotInventoryForTest()).toMatchObject({ + disposedNavigations: 0, + navigationCount: 1, + }); + }); + + it('owns aliases, intents, targeting, batches, attempts, and one immutable projection', () => { + const projection = frozenProjection('initial'); + const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); + const navigation = runtime.startInitialNavigation(projection); + if (!navigation.ok) throw new Error('Expected initial navigation'); + + expect(navigation.value.claimAlias('slot-alias')).toBe(true); + expect(navigation.value.claimAlias('slot-alias')).toBe(false); + expect(navigation.value.claimIntent('slot-one')).toBe(true); + expect(navigation.value.claimTargeting('slot-one')).toBe(true); + expect(navigation.value.currentAuctionProjection).toBe(projection); + expect(Object.isFrozen(navigation.value.currentAuctionProjection)).toBe(true); + expect(navigation.value.snapshotInventoryForTest()).toMatchObject({ + aliases: 1, + attempts: 0, + batches: 0, + intents: 1, + targetingOwners: 1, + }); + }); + + it('owns injected interfaces and runtime disposers without exposing mutable inventory', () => { + const order: string[] = []; + const interfaces = Object.freeze({ messaging: Object.freeze({ active: true }) }); + const runtime = createRuntimeSession({ + createIdentityIssuer: identityFactory(), + interfaces, + }); + runtime.onDispose('adapter', () => order.push('adapter')); + runtime.onDispose('service', () => order.push('service')); + + expect(runtime.interfaces).toBe(interfaces); + expect(Object.isFrozen(runtime.interfaces)).toBe(true); + runtime.dispose(); + runtime.dispose(); + + expect(order).toEqual(['service', 'adapter']); + const inventory = runtime.snapshotInventoryForTest(); + expect(Object.isFrozen(inventory)).toBe(true); + expect(inventory).toMatchObject({ disposed: true, activeDisposers: 0 }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 6ee858568..4dff0b588 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -18,6 +18,7 @@ import { JSDOM } from 'jsdom'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { main } from '../build-prebid-external.mjs'; +import { createBrowserPrebidAdapter } from '../src/adapters/prebid'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const libDir = path.resolve(__dirname, '..'); @@ -26,6 +27,19 @@ let outputDirectory; let bundleCode; let shimCode; let prebidVersion; +let artifactManifest; + +function cloneAndDeepFreezeInWindow(pageWindow, value) { + const cloned = pageWindow.JSON.parse(JSON.stringify(value)); + const freeze = (entry) => { + if (entry && typeof entry === 'object') { + for (const key of pageWindow.Object.getOwnPropertyNames(entry)) freeze(entry[key]); + pageWindow.Object.freeze(entry); + } + return entry; + }; + return freeze(cloned); +} beforeAll(async () => { outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-artifacts-')); @@ -38,9 +52,11 @@ beforeAll(async () => { '--out', outputDirectory, ]); - const manifest = JSON.parse(fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8')); - bundleCode = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); - prebidVersion = manifest.prebidVersion; + artifactManifest = JSON.parse( + fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8') + ); + bundleCode = fs.readFileSync(path.join(outputDirectory, artifactManifest.filename), 'utf8'); + prebidVersion = artifactManifest.prebidVersion; const { build } = await import('vite'); await build({ @@ -58,7 +74,6 @@ beforeAll(async () => { format: 'iife', dir: outputDirectory, entryFileNames: 'tsjs-prebid.js', - inlineDynamicImports: true, extend: false, name: 'tsjs_prebid', }, @@ -83,131 +98,375 @@ describe('tsjs-prebid shim artifact', () => { expect(shimCode).not.toContain(prebidVersion); expect(shimCode).not.toContain('_pbjsGlobals'); - // A value-import of Prebid or a private rendering helper would multiply - // the shim size; retain a margin above the normal compact shim output. + // Bundle size is enforced by the role-correct captured ±5% budget gate. + // These checks prove that Prebid remains external while the shim uses only + // the documented public methods needed by the hard-cutover adapter. expect(bundleCode.length).toBeGreaterThan(200_000); - expect(shimCode.length).toBeLessThan(30_000); - expect(shimCode).toContain('markWinningBidAsUsed'); + expect(shimCode).toContain('registerBidAdapter'); + expect(shimCode).toContain('getBidResponsesForAdUnitCode'); }); }); describe('external bundle + served shim evaluated together', () => { - it('populates the public API, installs the shim exactly once, and routes an /auction request', async () => { + it('reuses an exact artifact without replaying factories and keeps one watchdog per wrapper', () => { const dom = new JSDOM('', { url: 'https://pub.example.com/article', runScripts: 'outside-only', - pretendToBeVisual: true, }); const pageWindow = dom.window; - - // Stub the network before any artifact runs: Prebid's ajax module - // captures window.fetch at evaluation time and builds Request objects. - // jsdom ships none of the fetch API, so lend it Node's — with relative - // URLs resolved against the page, as a browser Request would. - const fetchSpy = vi.fn( - async () => - new Response('{}', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ); - pageWindow.fetch = fetchSpy; - pageWindow.Request = class PageRequest extends Request { - constructor(resource, init) { - super( - typeof resource === 'string' - ? new URL(resource, 'https://pub.example.com').href - : resource, - init - ); + const watchdogs = []; + const originalSetTimeout = pageWindow.setTimeout.bind(pageWindow); + pageWindow.setTimeout = (callback, delay, ...arguments_) => { + if (delay === 5_000 && String(callback).includes('__tsWatchdogFired')) { + watchdogs.push(callback); + return 1; } + return originalSetTimeout(callback, delay, ...arguments_); }; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; pageWindow.Headers = Headers; pageWindow.Response = Response; pageWindow.AbortController = AbortController; - if (!('isSecureContext' in pageWindow)) { - pageWindow.isSecureContext = true; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + + pageWindow.eval(bundleCode); + const firstBinding = pageWindow.pbjs; + const firstRequestBids = firstBinding.requestBids; + const firstStamp = firstBinding.__trustedServerArtifactV1; + pageWindow.eval(bundleCode); + + expect(pageWindow.pbjs).toBe(firstBinding); + expect(pageWindow.pbjs.requestBids).toBe(firstRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(firstStamp); + expect(watchdogs).toHaveLength(2); + + const processQueue = vi.fn(firstBinding.processQueue.bind(firstBinding)); + firstBinding.processQueue = processQueue; + for (const watchdog of watchdogs) { + watchdog(); + watchdog(); } + expect(processQueue).toHaveBeenCalledTimes(2); + dom.window.close(); + }); - // Mirror the server's head-injected state, which always precedes the - // bundle script in document order. + it('reuses separately constructed identical artifacts without reporting a conflict', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const watchdogs = []; + const originalSetTimeout = pageWindow.setTimeout.bind(pageWindow); + pageWindow.setTimeout = (callback, delay, ...arguments_) => { + if (delay === 5_000 && String(callback).includes('__tsWatchdogFired')) { + watchdogs.push(callback); + return 1; + } + return originalSetTimeout(callback, delay, ...arguments_); + }; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); - pageWindow.__tsjs_prebid = { clientSideBidders: [] }; + const warn = vi.fn(); + pageWindow.console.warn = warn; + const firstBytes = Buffer.from(bundleCode, 'utf8'); + const duplicateBytes = Buffer.from(bundleCode, 'utf8'); + expect(firstBytes).not.toBe(duplicateBytes); + expect(firstBytes.equals(duplicateBytes)).toBe(true); - pageWindow.eval(bundleCode); + pageWindow.eval(firstBytes.toString('utf8')); + const firstBinding = pageWindow.pbjs; + const firstRequestBids = firstBinding.requestBids; + const firstRegisterBidAdapter = firstBinding.registerBidAdapter; + const firstStamp = firstBinding.__trustedServerArtifactV1; + pageWindow.eval(duplicateBytes.toString('utf8')); - expect(typeof pageWindow.pbjs.requestBids).toBe('function'); - expect(typeof pageWindow.pbjs.registerBidAdapter).toBe('function'); - expect(pageWindow.__tsjs_prebid_bundle.adapters).toEqual(['adf']); - expect([...pageWindow.__tsjs_prebid_bundle.bidderCodes]).toEqual([ - 'adf', - 'adform', - 'adformOpenRTB', - ]); - expect([...pageWindow.__tsjs_prebid_bundle.userIdModules]).toEqual(['sharedIdSystem']); - - // Count trustedServer registrations across repeated shim evaluations. - const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); - const registerSpy = vi.fn(originalRegisterBidAdapter); - pageWindow.pbjs.registerBidAdapter = registerSpy; - - pageWindow.eval(shimCode); - const wrappedRequestBids = pageWindow.pbjs.requestBids; - - // A second evaluation (double script inclusion, or a legacy bundle that - // still carries a baked-in shim running after this one) must be a no-op. - pageWindow.eval(shimCode); - - const trustedServerRegistrations = registerSpy.mock.calls.filter( - ([, bidderCode]) => bidderCode === 'trustedServer' + expect(pageWindow.pbjs).toBe(firstBinding); + expect(pageWindow.pbjs.requestBids).toBe(firstRequestBids); + expect(pageWindow.pbjs.registerBidAdapter).toBe(firstRegisterBidAdapter); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(firstStamp); + expect(warn).not.toHaveBeenCalled(); + expect(watchdogs).toHaveLength(2); + + const processQueue = vi.fn(firstBinding.processQueue.bind(firstBinding)); + firstBinding.processQueue = processQueue; + for (const watchdog of watchdogs) { + watchdog(); + watchdog(); + } + expect(processQueue).toHaveBeenCalledTimes(2); + dom.window.close(); + }); + + it('refuses a different valid artifact without disturbing the working binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const conflictingStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'f'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: artifactManifest.moduleStems, + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }; + pageWindow.eval( + `window.__conflictingRequestBids=function conflictingRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__conflictingRequestBids:function(){};});` ); - expect(trustedServerRegistrations).toHaveLength(1); - expect(pageWindow.pbjs.requestBids).toBe(wrappedRequestBids); - expect(pageWindow.__tsjsPrebidShimInstalled).toBe(true); - - // Drive one real auction through the wrapped requestBids and assert the - // transformed request reaches /auction. - const slot = pageWindow.document.createElement('div'); - slot.id = 'ad-slot-1'; - pageWindow.document.body.appendChild(slot); - - pageWindow.pbjs.requestBids({ - adUnits: [ - { - code: 'ad-slot-1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], - }, - ], - timeout: 1000, + pageWindow.__conflictingStamp = cloneAndDeepFreezeInWindow(pageWindow, conflictingStamp); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__conflictingStamp, + enumerable: false, + writable: false, + configurable: false, }); + const binding = pageWindow.pbjs; + const warn = vi.fn(); + pageWindow.console.warn = warn; - const requestUrl = (resource) => - typeof resource === 'string' ? resource : String(resource?.url ?? resource); + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(pageWindow.pbjs).toBe(binding); + expect(pageWindow.pbjs.requestBids).toBe(pageWindow.__conflictingRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__conflictingStamp); + expect(warn).toHaveBeenCalledTimes(1); + dom.window.close(); + }); - await vi.waitFor( - () => { - expect( - fetchSpy.mock.calls.some(([resource]) => requestUrl(resource).includes('/auction')) - ).toBe(true); - }, - { timeout: 10_000 } + it('does not mistake an exact stamp on a Prebid stub for an initialized duplicate', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.__exactStamp = cloneAndDeepFreezeInWindow(pageWindow, { + abi: artifactManifest.abi, + artifactReleaseId: artifactManifest.artifactReleaseId, + prebidVersion: artifactManifest.prebidVersion, + moduleStems: artifactManifest.moduleStems, + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__exactStamp, + enumerable: false, + writable: false, + configurable: false, + }); + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__exactStamp); + dom.window.close(); + }); + + it('accepts an exact 128-byte non-ASCII artifact name on a real stamped binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const boundaryName = 'é'.repeat(64); + const boundaryStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'e'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: [...artifactManifest.moduleStems, boundaryName].sort(), + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }; + pageWindow.eval( + `window.__fakeRequestBids=function fakeRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__fakeRequestBids:function(){};});` ); + pageWindow.__boundaryStamp = cloneAndDeepFreezeInWindow(pageWindow, boundaryStamp); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__boundaryStamp, + enumerable: false, + writable: false, + configurable: false, + }); + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(pageWindow.pbjs.requestBids).toBe(pageWindow.__fakeRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__boundaryStamp); + dom.window.close(); + }); - const [resource, init] = fetchSpy.mock.calls.find(([target]) => - requestUrl(target).includes('/auction') + it('does not accept a UTF-8-overlong artifact name on a real stamped binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + const overlongName = `${'é'.repeat(64)}a`; + const malformedStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'f'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: [...artifactManifest.moduleStems, overlongName].sort(), + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }; + pageWindow.eval( + `window.__fakeRequestBids=function fakeRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__fakeRequestBids:function(){};});` ); - const body = init?.body ?? (typeof resource === 'object' ? await resource.text() : undefined); - const method = init?.method ?? resource?.method; - expect(method).toBe('POST'); - const payload = JSON.parse(body); - const adUnit = payload.adUnits[0]; - expect(adUnit.code).toBe('ad-slot-1'); - // The server-side bidder was folded into the trustedServer request - // instead of running client-side. - const trustedServerBid = adUnit.bids.find((bid) => bid.bidder === 'trustedServer'); - expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 1 } }); + pageWindow.__malformedStamp = cloneAndDeepFreezeInWindow(pageWindow, malformedStamp); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__malformedStamp, + enumerable: false, + writable: false, + configurable: false, + }); + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(pageWindow.pbjs.requestBids).not.toBe(pageWindow.__fakeRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__malformedStamp); + dom.window.close(); + }); + + it('keeps publisher Prebid usable when a hostile stamp cannot be replaced', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + const hostileStamp = Object.freeze({ abi: 99 }); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: hostileStamp, + enumerable: true, + writable: false, + configurable: false, + }); + const warn = vi.fn(); + pageWindow.console.warn = warn; + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(hostileStamp); + expect(warn).toHaveBeenCalledTimes(1); + dom.window.close(); + }); + it('admits one exact TS bid through the real 10.26.0 response callback', async () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.eval(bundleCode); + + const adapter = createBrowserPrebidAdapter(pageWindow); + let resolveAuction; + const auctionReady = new Promise((resolve) => { + resolveAuction = resolve; + }); + let resolveBidsBack; + const bidsBack = new Promise((resolve) => { + resolveBidsBack = resolve; + }); + const operation = adapter.run((prebid) => { + prebid.registerTrustedServerBidder(resolveAuction); + return prebid.requestBids({ + adUnits: [ + { + code: 'slot-one', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'trustedServer', params: {} }], + }, + ], + timeout: 1_000, + bidsBackHandler: resolveBidsBack, + }); + }); + await operation.result; + const auction = await auctionReady; + expect(Object.isFrozen(auction)).toBe(true); + expect(auction.bids).toHaveLength(1); + + const request = auction.bids[0]; + const reservationId = `r1_${'z'.repeat(22)}`; + const prepared = Object.freeze({ + auctionId: auction.auctionId, + adUnitCode: request.adUnitCode, + bid: Object.freeze({ + requestId: request.requestId, + adId: reservationId, + cpm: 1.25, + width: 300, + height: 250, + ad: '', + ttl: 300, + creativeId: 'creative-one', + netRevenue: true, + currency: 'USD', + bidderCode: 'trustedServer', + meta: Object.freeze({ + advertiserDomains: Object.freeze([]), + tsAuctionId: auction.auctionId, + tsBidId: 'server-bid-one', + }), + }), + }); + + const beforeAdmission = pageWindow.pbjs.getBidResponsesForAdUnitCode('slot-one'); + expect(Array.isArray(beforeAdmission)).toBe(true); + expect(Array.isArray(beforeAdmission.bids)).toBe(true); + expect(beforeAdmission.bids).toHaveLength(0); + expect(adapter.admitTrustedBid(prepared)).toBe('admitted'); + const stored = pageWindow.pbjs.getBidResponsesForAdUnitCode('slot-one').bids; + const admitted = stored.filter((bid) => bid.adId === reservationId); + expect(admitted).toHaveLength(1); + expect(admitted[0]).toMatchObject({ + adId: reservationId, + adUnitCode: 'slot-one', + auctionId: auction.auctionId, + requestId: request.requestId, + adserverTargeting: { hb_adid: reservationId }, + }); + auction.complete(); + await bidsBack; + adapter.dispose(); dom.window.close(); }, 60_000); }); diff --git a/crates/trusted-server-js/lib/test/services/auction_batch.test.ts b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts new file mode 100644 index 000000000..768faffde --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts @@ -0,0 +1,660 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { parseTrustedServerAuctionResponseV1 } from '../../src/core/auction'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { + createRuntimeSession, + type NavigationSession, + type RenderAttemptScope, +} from '../../src/kernel/sessions'; +import { + createAuctionBatchService, + type AuctionBatchFetcher, + type AuctionBatchServiceOptions, +} from '../../src/services/auction_batch'; +import type { + RenderAttempt, + RenderCancellationReason, + RenderFailureReason, + RenderOutcome, +} from '../../src/services/render'; + +function navigation(): NavigationSession { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + }); + const result = runtime.startInitialNavigation(); + if (!result.ok) throw new Error(result.reason); + return result.value; +} + +interface AttemptHarness { + readonly attempt: RenderAttempt; + readonly outcomes: readonly RenderOutcome[]; +} + +function attemptHarness(owner: RenderAttemptScope): AttemptHarness { + const outcomes: RenderOutcome[] = []; + const observers: Array<(outcome: RenderOutcome) => void> = []; + let outcome: RenderOutcome | undefined; + const settle = (next: RenderOutcome): boolean => { + if (outcome) return false; + outcome = Object.freeze(next); + outcomes.push(outcome); + owner.dispose(); + observers.splice(0).forEach((observer) => observer(outcome!)); + return true; + }; + owner.onDispose('test-render-lifecycle', () => { + if (!outcome) settle({ outcome: 'cancelled', reason: 'navigation_disposed' }); + }); + const attempt = { + id: owner.id, + slot: owner.slot, + generation: owner.generation, + navigationGeneration: owner.navigationGeneration, + parentAttemptId: undefined, + renderSource: undefined, + winnerContext: undefined, + admitDirectWinner: vi.fn(() => true), + admitClaimedWinner: vi.fn(() => false), + beginGamClaim: vi.fn(() => false), + ownerClaimed: vi.fn(() => false), + ownerRegistered: vi.fn(() => false), + beginDirect: vi.fn(() => false), + beginApsDocument: vi.fn(() => false), + beginAdm: vi.fn(() => false), + apsDocumentAccepted: vi.fn(() => false), + accept: () => settle({ outcome: 'accepted' }), + noBid: () => settle({ outcome: 'no_bid' }), + fail: (reason: RenderFailureReason) => settle({ outcome: 'failed', reason }), + cancel: (reason: RenderCancellationReason) => settle({ outcome: 'cancelled', reason }), + onSettled: (observer: (terminal: RenderOutcome) => void) => { + if (outcome) observer(outcome); + else observers.push(observer); + return true; + }, + snapshot: () => ({ + history: Object.freeze(outcome ? ['created', outcome.outcome] : ['created']), + outcome, + state: outcome?.outcome ?? ('created' as const), + }), + } as RenderAttempt; + return { attempt, outcomes }; +} + +function candidateId(index: number): string { + return index.toString(36).padStart(12, 'A'); +} + +function reservationId(index: number): string { + return `r1_${index.toString(36).padStart(22, 'A')}`; +} + +type Decision = + | { slot: string; outcome: 'winner'; candidateId: string } + | { slot: string; outcome: 'no_bid' } + | { slot: string; outcome: 'failed'; reason: 'provider_timeout' }; + +function response(decisions: readonly Decision[]): unknown { + const winners = decisions.filter( + (decision): decision is Extract => + decision.outcome === 'winner' + ); + return { + id: 'auction-1', + cur: 'USD', + seatbid: + winners.length === 0 + ? [] + : [ + { + seat: 'prebid', + bid: winners.map((winner, index) => { + const source = { + type: 'adm', + version: 1, + adm: `
${winner.slot}
`, + width: 300, + height: 250, + }; + return { + id: reservationId(index), + impid: winner.slot, + price: index + 1, + adm: source.adm, + w: source.width, + h: source.height, + ext: { + trusted_server: { + candidate_id: winner.candidateId, + slot_id: winner.slot, + render_source: source, + }, + }, + }; + }), + }, + ], + ext: { + trusted_server: { + slot_results: { version: 1, auctionId: 'auction-1', results: decisions }, + }, + }, + }; +} + +function successfulFetcher(body: unknown): AuctionBatchFetcher { + return vi.fn(async () => ({ ok: true, json: async () => body })); +} + +function createService(options: Omit) { + return createAuctionBatchService({ + ...options, + parseResponse: parseTrustedServerAuctionResponseV1, + }); +} + +function abortablePendingFetcher(): { + readonly fetcher: AuctionBatchFetcher; + readonly signals: AbortSignal[]; +} { + const signals: AbortSignal[] = []; + const fetcher: AuctionBatchFetcher = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + const signal = init.signal; + if (!signal) throw new Error('Expected a fetch signal'); + signals.push(signal); + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { + once: true, + }); + }) + ); + return { fetcher, signals }; +} + +describe('auction batch service', () => { + it('rejects a response whose decisions reverse immutable request order', async () => { + const attempts = new Map(); + const fetcher = successfulFetcher( + response([ + { slot: 'slot-a', outcome: 'no_bid' }, + { slot: 'slot-b', outcome: 'winner', candidateId: candidateId(0) }, + ]) + ); + const service = createService({ + createAttempt: (owner) => { + const harness = attemptHarness(owner); + attempts.set(owner.slot, harness); + return { ok: true, value: harness.attempt }; + }, + fetcher, + renderWinner: (attempt) => attempt.accept(), + }); + + const batch = service.create({ + navigation: navigation(), + requestBody: '{"adUnits":[]}', + slots: Object.freeze(['slot-b', 'slot-a']), + timeoutMs: 10_000, + }); + + await expect(batch.result).resolves.toEqual({ + slots: [ + { slot: 'slot-b', path: 'primary', outcome: 'failed', reason: 'invalid_response' }, + { slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'invalid_response' }, + ], + }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledWith( + '/auction', + expect.objectContaining({ + method: 'POST', + body: '{"adUnits":[]}', + signal: expect.any(AbortSignal), + }) + ); + expect(attempts.size).toBe(2); + expect(attempts.get('slot-b')?.attempt.admitDirectWinner).not.toHaveBeenCalled(); + expect(Object.isFrozen(await batch.result)).toBe(true); + expect(Object.isFrozen((await batch.result).slots)).toBe(true); + }); + + it('fails only live children on the shared response deadline and aborts the fetch', async () => { + vi.useFakeTimers(); + try { + const pending = abortablePendingFetcher(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 100, + }); + + await vi.advanceTimersByTimeAsync(99); + expect(pending.signals[0]?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + await expect(batch.result).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'auction_timeout' }, + { slot: 'slot-b', path: 'primary', outcome: 'failed', reason: 'auction_timeout' }, + ], + }); + expect(pending.signals[0]?.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels issued children without fetching for an already-aborted caller', async () => { + const fetcher = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const createAttempt = vi.fn((owner: RenderAttemptScope) => ({ + ok: true as const, + value: attemptHarness(owner).attempt, + })); + const service = createService({ + createAttempt, + fetcher, + renderWinner: () => false, + }); + const caller = new AbortController(); + caller.abort(); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + signal: caller.signal, + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }).result + ).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + expect(createAttempt).toHaveBeenCalledOnce(); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('supersedes only overlapping children and retains the old fetch until all old children settle', async () => { + const firstFetch = abortablePendingFetcher(); + const secondFetch = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const fetchers = [firstFetch.fetcher, secondFetch] as const; + let fetchIndex = 0; + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: (input, init) => fetchers[fetchIndex++]!(input, init), + renderWinner: () => false, + }); + const firstAbort = new AbortController(); + const owner = navigation(); + const first = service.create({ + navigation: owner, + requestBody: '{}', + signal: firstAbort.signal, + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 10_000, + }); + const second = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + + expect(firstFetch.signals[0]?.aborted).toBe(false); + await expect(second.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'no_bid' }], + }); + firstAbort.abort(); + await expect(first.result).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'superseded' }, + { slot: 'slot-b', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }, + ], + }); + expect(firstFetch.signals[0]?.aborted).toBe(true); + }); + + it.each([ + { + name: 'network rejection', + fetcher: vi.fn(async () => Promise.reject(new Error('offline'))), + reason: 'network_error', + }, + { + name: 'non-success response', + fetcher: vi.fn(async () => ({ ok: false, json: async () => ({}) })), + reason: 'http_error', + }, + { + name: 'invalid JSON body', + fetcher: vi.fn(async () => ({ + ok: true, + json: async () => Promise.reject(new SyntaxError('invalid JSON')), + })), + reason: 'invalid_response', + }, + { + name: 'missing slot decision', + fetcher: successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])), + reason: 'invalid_response', + }, + { + name: 'extra slot decision', + fetcher: successfulFetcher( + response([ + { slot: 'slot-a', outcome: 'no_bid' }, + { slot: 'slot-b', outcome: 'no_bid' }, + { slot: 'slot-extra', outcome: 'no_bid' }, + ]) + ), + reason: 'invalid_response', + }, + ] as const)('preserves $name as $reason for every live child', async ({ fetcher, reason }) => { + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher, + renderWinner: () => false, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 10_000, + }).result + ).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'failed', reason }, + { slot: 'slot-b', path: 'primary', outcome: 'failed', reason }, + ], + }); + }); + + it('passes through an exact server failure without inferring no-bid', async () => { + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: successfulFetcher( + response([{ slot: 'slot-a', outcome: 'failed', reason: 'provider_timeout' }]) + ), + renderWinner: () => false, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }).result + ).resolves.toEqual({ + slots: [ + { + slot: 'slot-a', + path: 'primary', + outcome: 'failed', + reason: 'provider_timeout', + }, + ], + }); + }); + + it('ends the shared deadline after parse while retaining caller cancellation during render', async () => { + vi.useFakeTimers(); + try { + let fetchSignal: AbortSignal | undefined; + const settled = vi.fn(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: vi.fn(async (_input, init) => { + fetchSignal = init.signal; + return { + ok: true, + json: async () => + response([{ slot: 'slot-a', outcome: 'winner', candidateId: candidateId(0) }]), + }; + }), + renderWinner: () => true, + }); + const caller = new AbortController(); + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + signal: caller.signal, + slots: Object.freeze(['slot-a']), + timeoutMs: 100, + }); + void batch.result.then(settled); + + await vi.advanceTimersByTimeAsync(1_000); + expect(settled).not.toHaveBeenCalled(); + expect(fetchSignal?.aborted).toBe(false); + + caller.abort(); + await expect(batch.result).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }, + ], + }); + expect(fetchSignal?.aborted).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels every child and the shared fetch when navigation disposes', async () => { + const pending = abortablePendingFetcher(); + const owner = navigation(); + const service = createService({ + createAttempt: (attemptOwner) => ({ + ok: true, + value: attemptHarness(attemptOwner).attempt, + }), + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const batch = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 10_000, + }); + + owner.dispose(); + + await expect(batch.result).resolves.toEqual({ + slots: [ + { + slot: 'slot-a', + path: 'primary', + outcome: 'cancelled', + reason: 'navigation_disposed', + }, + { + slot: 'slot-b', + path: 'primary', + outcome: 'cancelled', + reason: 'navigation_disposed', + }, + ], + }); + expect(pending.signals[0]?.aborted).toBe(true); + }); + + it('aborts the old shared fetch when its only child is superseded', async () => { + const firstFetch = abortablePendingFetcher(); + const owner = navigation(); + const fetchers = [ + firstFetch.fetcher, + successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])), + ] as const; + let fetchIndex = 0; + const service = createService({ + createAttempt: (attemptOwner) => ({ + ok: true, + value: attemptHarness(attemptOwner).attempt, + }), + fetcher: (input, init) => fetchers[fetchIndex++]!(input, init), + renderWinner: () => false, + }); + const first = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + const second = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + + await expect(first.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'superseded' }], + }); + expect(firstFetch.signals[0]?.aborted).toBe(true); + await expect(second.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'no_bid' }], + }); + }); + + it('fails closed without fetching when deadline setup settles reentrantly', async () => { + const fetcher = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const clear = vi.fn(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher, + renderWinner: () => false, + scheduler: { + clear, + set: (callback) => { + callback(); + return Object.freeze({ handle: true }); + }, + }, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 100, + }).result + ).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'auction_timeout' }], + }); + expect(fetcher).not.toHaveBeenCalled(); + expect(clear).toHaveBeenCalled(); + }); + + it('settles and skips transport when an attempt refuses settlement observation', async () => { + const fetcher = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const service = createService({ + createAttempt: (owner) => { + const attempt = attemptHarness(owner).attempt; + return { + ok: true, + value: { + ...attempt, + fail: vi.fn(() => false), + onSettled: vi.fn(() => false), + } as RenderAttempt, + }; + }, + fetcher, + renderWinner: () => false, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 100, + }).result + ).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'internal_error' }], + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('contains an attempt that claims cancellation without notifying its observer', async () => { + const pending = abortablePendingFetcher(); + const service = createService({ + createAttempt: (owner) => { + const attempt = attemptHarness(owner).attempt; + return { + ok: true, + value: { + ...attempt, + cancel: vi.fn(() => true), + } as RenderAttempt, + }; + }, + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + + batch.cancel(); + + await expect(batch.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + expect(pending.signals[0]?.aborted).toBe(true); + }); + + it('observes a branded caller signal without consulting shadowed instance hooks', async () => { + const pending = abortablePendingFetcher(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const caller = new AbortController(); + const publisherHook = vi.fn(() => { + throw new Error('publisher signal hook'); + }); + Object.defineProperties(caller.signal, { + aborted: { configurable: true, get: publisherHook }, + addEventListener: { configurable: true, get: publisherHook }, + removeEventListener: { configurable: true, get: publisherHook }, + }); + + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + signal: caller.signal, + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + caller.abort(); + + await expect(batch.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + expect(publisherHook).not.toHaveBeenCalled(); + expect(pending.signals[0]?.aborted).toBe(true); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/context.test.ts b/crates/trusted-server-js/lib/test/services/context.test.ts new file mode 100644 index 000000000..c2a4725dd --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/context.test.ts @@ -0,0 +1,892 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createAuctionContextRegistry, + type ContextContributorOwner, +} from '../../src/services/context'; + +const MAX_CONTEXT_JSON_BYTES = 256 * 1024; +const MAX_CONTEXT_ENCODED_KEY_BYTES = MAX_CONTEXT_JSON_BYTES - 7; +const MAX_CONTEXT_STRUCTURE_ENTRIES = Math.floor((MAX_CONTEXT_JSON_BYTES - 1) / 2); + +function owner(): ContextContributorOwner & { readonly dispose: () => void } { + const generation = Object.freeze({}); + const disposers: (() => void)[] = []; + let current = true; + return Object.freeze({ + generation, + isCurrent: () => current, + onDispose: (_kind: string, callback: () => void) => { + if (!current) callback(); + else disposers.push(callback); + }, + dispose: () => { + if (!current) return; + current = false; + for (let index = disposers.length - 1; index >= 0; index -= 1) { + disposers[index]?.(); + } + disposers.length = 0; + }, + }); +} + +describe('AuctionContextRegistry', () => { + it('snapshots in manifest order with later-key precedence and recursive freezing', () => { + const runtimeOwner = owner(); + const firstOwner = owner(); + const secondOwner = owner(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['first', 'second']), + runtimeOwner, + }); + + expect( + registry.register('second', () => ({ shared: 'second', nested: { value: 2 } }), secondOwner) + ).toBe(true); + expect(registry.register('first', () => ({ first: true, shared: 'first' }), firstOwner)).toBe( + true + ); + + const snapshot = registry.snapshot(); + + expect(snapshot).toEqual({ first: true, shared: 'second', nested: { value: 2 } }); + expect(Object.keys(snapshot)).toEqual(['first', 'shared', 'nested']); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.nested)).toBe(true); + }); + + it('isolates a throwing contributor and does not retain any of its partial values', () => { + const runtimeOwner = owner(); + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['good-first', 'hostile', 'good-last']), + runtimeOwner, + onContributorFailure: failure, + }); + const partial = { leaked: 'must-not-escape' }; + Object.defineProperty(partial, 'throwing', { + enumerable: true, + get() { + throw new Error('hostile getter'); + }, + }); + registry.register('good-first', () => ({ retained: 'first' }), owner()); + registry.register('hostile', () => partial, owner()); + registry.register('good-last', () => ({ retained: 'last' }), owner()); + + const snapshot = registry.snapshot(); + + expect(snapshot).toEqual({ retained: 'last' }); + expect(snapshot).not.toHaveProperty('leaked'); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'hostile', reason: 'contributor_failed' }], + ]); + expect(Object.isFrozen(failure.mock.calls[0]?.[0])).toBe(true); + }); + + it('removes an owner-scoped contributor before the next batch snapshot', () => { + const runtimeOwner = owner(); + const contributorOwner = owner(); + const contributor = vi.fn(() => ({ active: true })); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + }); + expect(registry.register('integration', contributor, contributorOwner)).toBe(true); + expect(registry.snapshot()).toEqual({ active: true }); + + contributorOwner.dispose(); + + expect(registry.snapshot()).toEqual({}); + expect(contributor).toHaveBeenCalledOnce(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + }); + + it('rejects unknown, duplicate, and stale-owner registrations', () => { + const runtimeOwner = owner(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['known']), + runtimeOwner, + }); + const active = owner(); + const stale = owner(); + stale.dispose(); + + expect(registry.register('unknown', () => ({}), active)).toBe(false); + expect(registry.register('known', () => ({ first: true }), active)).toBe(true); + expect(registry.register('known', () => ({ duplicate: true }), owner())).toBe(false); + active.dispose(); + expect(registry.register('known', () => ({ stale: true }), stale)).toBe(false); + }); + + it('fails closed when the runtime-owner generation getter throws during construction', () => { + const hostileRuntimeOwner = new Proxy(owner(), { + get(target, key, receiver) { + if (key === 'generation') throw new Error('hostile generation getter'); + return Reflect.get(target, key, receiver); + }, + }); + let registry: ReturnType | undefined; + + expect(() => { + registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: hostileRuntimeOwner, + }); + }).not.toThrow(); + + const snapshot = registry?.snapshot(); + expect(snapshot).toEqual({}); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(registry?.register('integration', () => ({ leaked: true }), owner())).toBe(false); + expect(registry?.snapshotInventoryForTest()).toEqual({ + disposed: true, + registrations: [], + }); + }); + + it('fails closed when the runtime-owner generation getter throws during a later snapshot', () => { + const runtimeOwner = owner(); + let throwOnGenerationRead = false; + const hostileRuntimeOwner = new Proxy(runtimeOwner, { + get(target, key, receiver) { + if (key === 'generation' && throwOnGenerationRead) { + throw new Error('hostile generation getter'); + } + return Reflect.get(target, key, receiver); + }, + }); + const contributor = vi.fn(() => ({ leaked: true })); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: hostileRuntimeOwner, + }); + expect(registry.register('integration', contributor, owner())).toBe(true); + + throwOnGenerationRead = true; + let snapshot: Readonly> | undefined; + expect(() => { + snapshot = registry.snapshot(); + }).not.toThrow(); + + expect(snapshot).toEqual({}); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(contributor).not.toHaveBeenCalled(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: true, + registrations: [], + }); + }); + + it('contains a throwing contributor-owner generation getter without retention', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const hostileOwner = new Proxy(owner(), { + get(target, key, receiver) { + if (key === 'generation') throw new Error('hostile generation getter'); + return Reflect.get(target, key, receiver); + }, + }); + + expect(() => + registry.register('integration', () => ({ leaked: true }), hostileOwner) + ).not.toThrow(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + expect(registry.snapshot()).toEqual({}); + }); + + it('reads contributor-owner generation once at each registration checkpoint', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const generation = Object.freeze({}); + const readGeneration = vi.fn(() => generation); + const contributorOwner = { + get generation() { + return readGeneration(); + }, + isCurrent: () => true, + onDispose: vi.fn(), + }; + + expect(registry.register('integration', () => ({ retained: true }), contributorOwner)).toBe( + true + ); + expect(readGeneration).toHaveBeenCalledTimes(2); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['integration'], + }); + }); + + it.each([ + ['finalization', false], + ['throw rollback', true], + ] as const)( + 'does not delete a reentrant replacement record during outer %s', + (_name, throwAfterReplacement) => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const replacementOwner = owner(); + let replacementRegistered: boolean | undefined; + const outerOwner: ContextContributorOwner = { + generation: Object.freeze({}), + isCurrent: () => true, + onDispose: (_kind, cleanup) => { + cleanup(); + replacementRegistered = registry.register( + 'integration', + () => ({ replacement: true }), + replacementOwner + ); + if (throwAfterReplacement) throw new Error('outer onDispose failed'); + }, + }; + + expect(registry.register('integration', () => ({ outer: true }), outerOwner)).toBe(false); + expect(replacementRegistered).toBe(true); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['integration'], + }); + expect(registry.snapshot()).toEqual({ replacement: true }); + } + ); + + it('reports a registration as displaced when final owner reflection installs a replacement', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const generation = Object.freeze({}); + const replacementOwner = owner(); + let generationReads = 0; + let cleanup: (() => void) | undefined; + let replacementRegistered: boolean | undefined; + const reentrantOwner: ContextContributorOwner = { + get generation() { + generationReads += 1; + if (generationReads === 2) { + cleanup?.(); + replacementRegistered = registry.register( + 'integration', + () => ({ replacement: true }), + replacementOwner + ); + } + return generation; + }, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }; + + expect(registry.register('integration', () => ({ displaced: true }), reentrantOwner)).toBe( + false + ); + expect(replacementRegistered).toBe(true); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['integration'], + }); + expect(registry.snapshot()).toEqual({ replacement: true }); + }); + + it('rolls back a registration whose owner generation changes during onDispose', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const firstGeneration = Object.freeze({}); + const secondGeneration = Object.freeze({}); + let generation = firstGeneration; + let rotateGeneration = true; + const readGeneration = vi.fn(() => generation); + const changingOwner: ContextContributorOwner = { + get generation() { + return readGeneration(); + }, + isCurrent: () => true, + onDispose: () => { + if (!rotateGeneration) return; + rotateGeneration = false; + generation = secondGeneration; + }, + }; + + expect(registry.register('integration', () => ({ stale: true }), changingOwner)).toBe(false); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + expect(registry.register('integration', () => ({ current: true }), changingOwner)).toBe(true); + expect(readGeneration).toHaveBeenCalledTimes(4); + expect(registry.snapshot()).toEqual({ current: true }); + }); + + it('rejects a reflected contributor-owner generation that is not an object', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const invalidOwner = { + generation: null as unknown as object, + isCurrent: () => true, + onDispose: vi.fn(), + }; + + expect(registry.register('integration', () => ({ leaked: true }), invalidOwner)).toBe(false); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + }); + + it.each(['isCurrent', 'onDispose'] as const)( + 'contains a throwing contributor-owner %s trap without retention', + (method) => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const contributorOwner = owner(); + const hostileOwner = new Proxy(contributorOwner, { + get(target, key, receiver) { + if (key === method) throw new Error(`hostile ${method} trap`); + return Reflect.get(target, key, receiver); + }, + }); + + expect(() => + registry.register('integration', () => ({ leaked: true }), hostileOwner) + ).not.toThrow(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: [], + }); + } + ); + + it('takes one fresh contributor snapshot per batch call without retaining prior values', () => { + const runtimeOwner = owner(); + const mutable = { value: 1 }; + const contributor = vi.fn(() => ({ nested: mutable })); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + }); + registry.register('integration', contributor, owner()); + + const first = registry.snapshot(); + mutable.value = 2; + const second = registry.snapshot(); + + expect(first).toEqual({ nested: { value: 1 } }); + expect(second).toEqual({ nested: { value: 2 } }); + expect(first).not.toBe(second); + expect(first.nested).not.toBe(second.nested); + expect(contributor).toHaveBeenCalledTimes(2); + }); + + it('does not invoke a record displaced during its owner-currentness reflection', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const generation = Object.freeze({}); + const replacementOwner = owner(); + const displacedContributor = vi.fn(() => ({ displaced: true })); + const replacementContributor = vi.fn(() => ({ replacement: true })); + let generationReads = 0; + let cleanup: (() => void) | undefined; + let replacementRegistered: boolean | undefined; + const reentrantOwner: ContextContributorOwner = { + get generation() { + generationReads += 1; + if (generationReads === 3) { + cleanup?.(); + replacementRegistered = registry.register( + 'integration', + replacementContributor, + replacementOwner + ); + } + return generation; + }, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }; + expect(registry.register('integration', displacedContributor, reentrantOwner)).toBe(true); + + expect(registry.snapshot()).toEqual({}); + expect(replacementRegistered).toBe(true); + expect(displacedContributor).not.toHaveBeenCalled(); + expect(replacementContributor).not.toHaveBeenCalled(); + expect(registry.snapshot()).toEqual({ replacement: true }); + expect(replacementContributor).toHaveBeenCalledOnce(); + }); + + it('does not merge a record displaced during contributor execution', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + const generation = Object.freeze({}); + const replacementOwner = owner(); + const replacementContributor = vi.fn(() => ({ replacement: true })); + let cleanup: (() => void) | undefined; + let replacementRegistered: boolean | undefined; + const reentrantOwner: ContextContributorOwner = { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }; + const displacedContributor = vi.fn(() => { + cleanup?.(); + replacementRegistered = registry.register( + 'integration', + replacementContributor, + replacementOwner + ); + return { displaced: true }; + }); + expect(registry.register('integration', displacedContributor, reentrantOwner)).toBe(true); + + expect(registry.snapshot()).toEqual({}); + expect(replacementRegistered).toBe(true); + expect(displacedContributor).toHaveBeenCalledOnce(); + expect(replacementContributor).not.toHaveBeenCalled(); + expect(registry.snapshot()).toEqual({ replacement: true }); + expect(replacementContributor).toHaveBeenCalledOnce(); + }); + + it('does not merge a record displaced during runtime-currentness reflection', () => { + const runtimeGeneration = Object.freeze({}); + let replaceOnRuntimeReflection = false; + let contributorCleanup: (() => void) | undefined; + let replacementRegistered: boolean | undefined; + const replacementOwner = owner(); + const replacementContributor = vi.fn(() => ({ replacement: true })); + const runtimeOwner: ContextContributorOwner = { + get generation() { + if (replaceOnRuntimeReflection) { + replaceOnRuntimeReflection = false; + contributorCleanup?.(); + replacementRegistered = registry.register( + 'integration', + replacementContributor, + replacementOwner + ); + } + return runtimeGeneration; + }, + isCurrent: () => true, + onDispose: vi.fn(), + }; + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + }); + const displacedContributor = vi.fn(() => { + replaceOnRuntimeReflection = true; + return { displaced: true }; + }); + expect( + registry.register('integration', displacedContributor, { + generation: Object.freeze({}), + isCurrent: () => true, + onDispose: (_kind, callback) => { + contributorCleanup = callback; + }, + }) + ).toBe(true); + + expect(registry.snapshot()).toEqual({}); + expect(replacementRegistered).toBe(true); + expect(displacedContributor).toHaveBeenCalledOnce(); + expect(replacementContributor).not.toHaveBeenCalled(); + expect(registry.snapshot()).toEqual({ replacement: true }); + expect(replacementContributor).toHaveBeenCalledOnce(); + }); + + it('fails closed when final runtime reflection displaces an already accepted record', () => { + const runtimeGeneration = Object.freeze({}); + const contributorGeneration = Object.freeze({}); + const replacementOwner = owner(); + const staleContributor = vi.fn(() => ({ stale: true })); + const replacementContributor = vi.fn(() => ({ replacement: true })); + let contributorGenerationReads = 0; + let contributorCleanup: (() => void) | undefined; + let reflectReplacement = false; + let replacementRegistered: boolean | undefined; + const runtimeOwner: ContextContributorOwner = { + get generation() { + if (reflectReplacement) { + reflectReplacement = false; + contributorCleanup?.(); + replacementRegistered = registry.register( + 'integration', + replacementContributor, + replacementOwner + ); + } + return runtimeGeneration; + }, + isCurrent: () => true, + onDispose: vi.fn(), + }; + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + }); + expect( + registry.register('integration', staleContributor, { + get generation() { + contributorGenerationReads += 1; + if (contributorGenerationReads === 4) reflectReplacement = true; + return contributorGeneration; + }, + isCurrent: () => true, + onDispose: (_kind, callback) => { + contributorCleanup = callback; + }, + }) + ).toBe(true); + + const firstSnapshot = registry.snapshot(); + expect(firstSnapshot).toEqual({}); + expect(Object.isFrozen(firstSnapshot)).toBe(true); + expect(replacementRegistered).toBe(true); + expect(staleContributor).toHaveBeenCalledOnce(); + expect(replacementContributor).not.toHaveBeenCalled(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['integration'], + }); + expect(registry.snapshot()).toEqual({ replacement: true }); + expect(replacementContributor).toHaveBeenCalledOnce(); + }); + + it('fails closed when final runtime reflection disposes the registry after acceptance', () => { + const runtimeGeneration = Object.freeze({}); + const contributorGeneration = Object.freeze({}); + let contributorGenerationReads = 0; + let reflectDisposal = false; + const runtimeOwner: ContextContributorOwner = { + get generation() { + if (reflectDisposal) { + reflectDisposal = false; + registry.dispose(); + } + return runtimeGeneration; + }, + isCurrent: () => true, + onDispose: vi.fn(), + }; + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + }); + expect( + registry.register('integration', () => ({ stale: true }), { + get generation() { + contributorGenerationReads += 1; + if (contributorGenerationReads === 4) reflectDisposal = true; + return contributorGeneration; + }, + isCurrent: () => true, + onDispose: vi.fn(), + }) + ).toBe(true); + + const snapshot = registry.snapshot(); + expect(snapshot).toEqual({}); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: true, + registrations: [], + }); + }); + + it('does not classify primitive clone records through Object.prototype pollution', () => { + const priorDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, 'source'); + try { + Object.defineProperty(Object.prototype, 'source', { + configurable: true, + enumerable: false, + value: 'polluted', + writable: true, + }); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + registry.register( + 'integration', + () => ({ string: 'value', number: 7, boolean: true, nullable: null }), + owner() + ); + + expect(registry.snapshot()).toEqual({ + string: 'value', + number: 7, + boolean: true, + nullable: null, + }); + } finally { + if (priorDescriptor) Object.defineProperty(Object.prototype, 'source', priorDescriptor); + else Reflect.deleteProperty(Object.prototype, 'source'); + } + }); + + it('makes stale callbacks and logger failures inert after runtime disposal', () => { + const runtimeOwner = owner(); + const contributor = vi.fn(() => { + throw new Error('contributor failed'); + }); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner, + onContributorFailure: () => { + throw new Error('logger failed'); + }, + }); + registry.register('integration', contributor, owner()); + + expect(() => registry.snapshot()).not.toThrow(); + runtimeOwner.dispose(); + expect(registry.snapshot()).toEqual({}); + expect(contributor).toHaveBeenCalledOnce(); + expect(registry.snapshotInventoryForTest()).toEqual({ + disposed: true, + registrations: [], + }); + }); + + it('discards the whole batch snapshot if a contributor disposes the runtime', () => { + const runtimeOwner = owner(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['first', 'disposing']), + runtimeOwner, + }); + registry.register('first', () => ({ stale: 'must-not-escape' }), owner()); + registry.register( + 'disposing', + () => { + runtimeOwner.dispose(); + return { late: 'must-not-escape' }; + }, + owner() + ); + + expect(registry.snapshot()).toEqual({}); + }); + + it.each([ + ['just below', MAX_CONTEXT_JSON_BYTES - 1, true], + ['at', MAX_CONTEXT_JSON_BYTES, true], + ['above', MAX_CONTEXT_JSON_BYTES + 1, false], + ] as const)( + 'applies the shared JSON byte budget %s the body ceiling', + (_name, bytes, accepted) => { + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + const payload = 'x'.repeat(bytes - 14); + registry.register('integration', () => ({ payload }), owner()); + + const snapshot = registry.snapshot(); + + if (accepted) { + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength).toBe(bytes); + expect(snapshot).toEqual({ payload }); + expect(failure).not.toHaveBeenCalled(); + } else { + expect(snapshot).toEqual({}); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'integration', reason: 'contributor_failed' }], + ]); + } + } + ); + + it('accounts for multibyte and escaped JSON strings at the exact byte ceiling', () => { + const payloadBytes = MAX_CONTEXT_JSON_BYTES - 14; + const emojiCount = Math.floor((payloadBytes - 4) / 4); + const payload = `${'😀'.repeat(emojiCount)}xx"\n`; + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['integration']), + runtimeOwner: owner(), + }); + registry.register('integration', () => ({ payload }), owner()); + + const snapshot = registry.snapshot(); + + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength).toBe( + MAX_CONTEXT_JSON_BYTES + ); + expect(snapshot).toEqual({ payload }); + }); + + it('shares the byte budget across contributors and rejects an overflowing merge atomically', () => { + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['first', 'overflowing']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + const payload = 'x'.repeat(MAX_CONTEXT_JSON_BYTES - 14); + registry.register('first', () => ({ payload }), owner()); + registry.register('overflowing', () => ({ late: true }), owner()); + + const snapshot = registry.snapshot(); + + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength).toBe( + MAX_CONTEXT_JSON_BYTES + ); + expect(snapshot).toEqual({ payload }); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'overflowing', reason: 'contributor_failed' }], + ]); + }); + + it('subtracts replaced predecessor bytes before admitting a later contributor', () => { + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['first', 'replacement']), + runtimeOwner: owner(), + }); + registry.register( + 'first', + () => ({ shared: 'x'.repeat(MAX_CONTEXT_JSON_BYTES - 13) }), + owner() + ); + registry.register('replacement', () => ({ shared: 'small', later: true }), owner()); + + expect(registry.snapshot()).toEqual({ shared: 'small', later: true }); + }); + + it('retains no replacement values when one prospective contributor exceeds the budget', () => { + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['first', 'overflowing']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + registry.register('first', () => ({ shared: 'original' }), owner()); + registry.register( + 'overflowing', + () => ({ shared: 'must-not-replace', excess: 'x'.repeat(MAX_CONTEXT_JSON_BYTES) }), + owner() + ); + + expect(registry.snapshot()).toEqual({ shared: 'original' }); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'overflowing', reason: 'contributor_failed' }], + ]); + }); + + it('clones and freezes a deeply nested contribution without a recursion cap', () => { + const depth = 12_000; + let deep: Record = { terminal: true }; + for (let index = 0; index < depth; index += 1) deep = { next: deep }; + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['deep']), + runtimeOwner: owner(), + }); + registry.register('deep', () => ({ deep }), owner()); + + const snapshot = registry.snapshot(); + + let cursor = snapshot.deep; + for (let index = 0; index < depth; index += 1) { + expect(Object.isFrozen(cursor)).toBe(true); + cursor = (cursor as { readonly next: unknown }).next; + } + expect(cursor).toEqual({ terminal: true }); + }); + + it('rejects an oversized encoded key before retaining contributor values', () => { + const failure = vi.fn(); + const hugeKey = 'k'.repeat(MAX_CONTEXT_ENCODED_KEY_BYTES + 1); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['huge-key']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + registry.register('huge-key', () => ({ [hugeKey]: 'must-not-escape' }), owner()); + + expect(registry.snapshot()).toEqual({}); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'huge-key', reason: 'contributor_failed' }], + ]); + }); + + it('rejects a huge iterative structure and continues with the next contributor', () => { + let huge: unknown[] = []; + const depth = Math.ceil(MAX_CONTEXT_STRUCTURE_ENTRIES / 2) + 1; + for (let index = 0; index < depth; index += 1) huge = [huge]; + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['huge', 'later']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + registry.register('huge', () => ({ huge }), owner()); + registry.register('later', () => ({ retained: true }), owner()); + + expect(registry.snapshot()).toEqual({ retained: true }); + expect(failure.mock.calls).toEqual([[{ integrationId: 'huge', reason: 'contributor_failed' }]]); + }); + + it('rejects a cyclic graph atomically and continues in manifest order', () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + const failure = vi.fn(); + const registry = createAuctionContextRegistry({ + manifestIntegrationIds: Object.freeze(['cyclic', 'later']), + runtimeOwner: owner(), + onContributorFailure: failure, + }); + registry.register('cyclic', () => ({ leaked: true, cyclic }), owner()); + registry.register('later', () => ({ retained: true }), owner()); + + expect(registry.snapshot()).toEqual({ retained: true }); + expect(failure.mock.calls).toEqual([ + [{ integrationId: 'cyclic', reason: 'contributor_failed' }], + ]); + }); + + it.each([ + ['just below', 19, true], + ['at', 20, true], + ['above', 21, false], + ] as const)('%s the canonical manifest capacity accepts=%s', (_name, count, accepted) => { + const ids = Object.freeze(Array.from({ length: count }, (_, index) => `integration-${index}`)); + const construct = (): void => { + createAuctionContextRegistry({ manifestIntegrationIds: ids, runtimeOwner: owner() }); + }; + + if (accepted) expect(construct).not.toThrow(); + else expect(construct).toThrow(TypeError); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/projections.test.ts b/crates/trusted-server-js/lib/test/services/projections.test.ts new file mode 100644 index 000000000..601e52ffc --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/projections.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { parseBrowserAuctionProjectionV1 } from '../../src/core/contracts/auction_projection'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { createRuntimeSession, type NavigationSession } from '../../src/kernel/sessions'; +import { + createPageBidsController, + prepareInitialAuctionProjection, + type PreparedProjectionSlots, + type ProjectionSlotRegistration, + type ProjectionSlotRegistry, +} from '../../src/services/projections'; + +function runtimeSession() { + let prefix = 0; + return createRuntimeSession({ + createIdentityIssuer: () => { + prefix += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(prefix); + return target; + }, + }); + }, + }); +} + +function projection(slots: readonly string[], auctionId = 'page-bids') { + return { + version: 1, + auction: { + version: 1, + auctionId, + results: slots.map((slot) => ({ slot, outcome: 'no_bid' as const })), + }, + slots: slots.map((slot) => ({ + slot, + gamUnitPath: `/123/${slot}`, + divId: `div-${slot}`, + formats: [[300, 250]], + targeting: {}, + })), + bids: [], + }; +} + +class SlotLedger implements ProjectionSlotRegistry { + public readonly slots = new Set(); + public prepareCalls = 0; + public commitHook: (() => void) | undefined; + + public constructor(programmaticCount = 0) { + for (let index = 0; index < programmaticCount; index += 1) { + this.slots.add(`programmatic-${index}`); + } + } + + public prepareProjectionSlots( + ownerGeneration: object, + slots: readonly ProjectionSlotRegistration[], + maximumActiveSlots: number + ): PreparedProjectionSlots | undefined { + this.prepareCalls += 1; + if ( + this.slots.size + slots.length > maximumActiveSlots || + slots.some((slot) => this.slots.has(slot.registeredSlotId)) + ) { + return undefined; + } + let committed = false; + return Object.freeze({ + ownerGeneration, + commit: () => { + this.commitHook?.(); + for (const slot of slots) this.slots.add(slot.registeredSlotId); + committed = true; + return true; + }, + rollback: () => { + if (!committed) return; + for (const slot of slots) this.slots.delete(slot.registeredSlotId); + committed = false; + }, + }); + } +} + +function controller(navigation: NavigationSession, registry: ProjectionSlotRegistry) { + return createPageBidsController({ + navigation, + parseProjection: parseBrowserAuctionProjectionV1, + slotRegistry: registry, + }); +} + +describe('initial auction projection', () => { + it('deep-copies and recursively freezes boot input without mutating it', () => { + const bootProjection = projection(['server-slot'], 'initial'); + + const prepared = prepareInitialAuctionProjection( + bootProjection, + parseBrowserAuctionProjectionV1 + ); + + expect(prepared).toEqual(bootProjection); + expect(prepared).not.toBe(bootProjection); + expect(Object.isFrozen(prepared)).toBe(true); + expect(Object.isFrozen((prepared as typeof bootProjection).auction)).toBe(true); + expect(Object.isFrozen((prepared as typeof bootProjection).auction.results)).toBe(true); + expect(Object.isFrozen(bootProjection)).toBe(false); + bootProjection.auction.auctionId = 'publisher-mutated'; + expect((prepared as typeof bootProjection).auction.auctionId).toBe('initial'); + }); +}); + +describe('SPA page-bids projection controller', () => { + it('prepares exact placement aliases in the same transaction as projected slot ids', () => { + const runtime = runtimeSession(); + const initial = runtime.startInitialNavigation( + prepareInitialAuctionProjection(projection([], 'initial'), parseBrowserAuctionProjectionV1) + ); + if (!initial.ok) throw new Error(initial.reason); + const replacement = runtime.replaceNavigation(); + if (!replacement.ok) throw new Error(replacement.reason); + const prepareProjectionSlots = vi.fn(() => ({ + ownerGeneration: replacement.value.generation, + commit: () => true, + rollback: vi.fn(), + })); + + expect( + controller(replacement.value, { prepareProjectionSlots }).commit(projection(['server-slot'])) + ).toEqual({ status: 'committed' }); + expect(prepareProjectionSlots).toHaveBeenCalledExactlyOnceWith( + replacement.value.generation, + [ + { + registeredSlotId: 'server-slot', + domAliases: ['div-server-slot'], + }, + ], + 256 + ); + runtime.dispose(); + }); + + it('atomically reserves slots and commits one immutable current-generation projection', () => { + const runtime = runtimeSession(); + const navigation = runtime.startInitialNavigation( + prepareInitialAuctionProjection(projection([], 'initial'), parseBrowserAuctionProjectionV1) + ); + if (!navigation.ok) throw new Error('Expected initial navigation'); + const spa = runtime.replaceNavigation(); + if (!spa.ok) throw new Error('Expected SPA navigation'); + const registry = new SlotLedger(254); + const input = projection(['server-one', 'server-two']); + + expect(controller(spa.value, registry).commit(input)).toEqual({ status: 'committed' }); + expect([...registry.slots].slice(-2)).toEqual(['server-one', 'server-two']); + expect(spa.value.currentAuctionProjection).toEqual(input); + expect(spa.value.currentAuctionProjection).not.toBe(input); + expect(Object.isFrozen(spa.value.currentAuctionProjection)).toBe(true); + expect( + Object.isFrozen((spa.value.currentAuctionProjection as typeof input).auction.results[0]) + ).toBe(true); + input.auction.auctionId = 'publisher-mutated'; + expect((spa.value.currentAuctionProjection as typeof input).auction.auctionId).toBe( + 'page-bids' + ); + }); + + it('rejects a duplicate response without preparing or changing committed state', () => { + const runtime = runtimeSession(); + const spa = runtime.startInitialNavigation(); + if (!spa.ok) throw new Error('Expected navigation'); + const registry = new SlotLedger(); + const pageBids = controller(spa.value, registry); + + expect(pageBids.commit(projection(['first']))).toEqual({ status: 'committed' }); + expect(pageBids.commit(projection(['second']))).toEqual({ + status: 'rejected', + reason: 'duplicate', + }); + expect(registry.prepareCalls).toBe(1); + expect([...registry.slots]).toEqual(['first']); + expect( + (spa.value.currentAuctionProjection as ReturnType).auction.results + ).toEqual([{ slot: 'first', outcome: 'no_bid' }]); + }); + + it('makes a late old-generation response inert after navigation replacement', () => { + const runtime = runtimeSession(); + const initial = runtime.startInitialNavigation(); + if (!initial.ok) throw new Error('Expected navigation'); + const registry = new SlotLedger(); + const pageBids = controller(initial.value, registry); + const replacement = runtime.replaceNavigation(); + if (!replacement.ok) throw new Error('Expected replacement'); + + expect(pageBids.commit(projection(['stale']))).toEqual({ + status: 'rejected', + reason: 'stale', + }); + expect(registry.prepareCalls).toBe(0); + expect(registry.slots.size).toBe(0); + expect(replacement.value.currentAuctionProjection).toBeUndefined(); + }); + + it('rejects malformed input without retaining or reserving it', () => { + const runtime = runtimeSession(); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('Expected navigation'); + const registry = new SlotLedger(); + const malformed = { ...projection(['slot']), extra: true }; + + expect(controller(navigation.value, registry).commit(malformed)).toEqual({ + status: 'rejected', + reason: 'malformed', + }); + expect(registry.prepareCalls).toBe(0); + expect(registry.slots.size).toBe(0); + expect(navigation.value.currentAuctionProjection).toBeUndefined(); + }); + + it.each([ + [255, 1, 'committed'], + [255, 2, 'capacity'], + [256, 1, 'capacity'], + ] as const)( + 'enforces the shared 256 cap with %i programmatic plus %i projected slots', + (programmatic, projected, expected) => { + const runtime = runtimeSession(); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('Expected navigation'); + const registry = new SlotLedger(programmatic); + const slots = Array.from({ length: projected }, (_, index) => `server-${index}`); + + const result = controller(navigation.value, registry).commit(projection(slots)); + + expect(result).toEqual( + expected === 'committed' + ? { status: 'committed' } + : { status: 'rejected', reason: 'capacity' } + ); + expect(registry.slots.size).toBe(expected === 'committed' ? 256 : programmatic); + expect(navigation.value.currentAuctionProjection === undefined).toBe( + expected !== 'committed' + ); + } + ); + + it('rolls back prepared slots if ownership changes during the synchronous commit', () => { + const runtime = runtimeSession(); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('Expected navigation'); + const registry = new SlotLedger(); + registry.commitHook = () => { + runtime.replaceNavigation(); + }; + + expect(controller(navigation.value, registry).commit(projection(['raced']))).toEqual({ + status: 'rejected', + reason: 'stale', + }); + expect(registry.slots.size).toBe(0); + expect(runtime.currentNavigation?.currentAuctionProjection).toBeUndefined(); + }); + + it('does not retain prior-navigation projection after a malformed SPA response', () => { + const runtime = runtimeSession(); + const initialProjection = prepareInitialAuctionProjection( + projection(['old-slot'], 'initial'), + parseBrowserAuctionProjectionV1 + ); + const initial = runtime.startInitialNavigation(initialProjection); + if (!initial.ok) throw new Error('Expected initial navigation'); + const spa = runtime.replaceNavigation(); + if (!spa.ok) throw new Error('Expected SPA navigation'); + + expect(controller(spa.value, new SlotLedger()).commit({ invalid: true })).toEqual({ + status: 'rejected', + reason: 'malformed', + }); + expect(initial.value.currentAuctionProjection).toBeUndefined(); + expect(spa.value.currentAuctionProjection).toBeUndefined(); + }); + + it('isolates a throwing parser and a throwing reservation commit', () => { + const runtime = runtimeSession(); + const first = runtime.startInitialNavigation(); + if (!first.ok) throw new Error('Expected navigation'); + const parser = vi.fn(() => { + throw new Error('hostile parser'); + }); + expect( + createPageBidsController({ + navigation: first.value, + parseProjection: parser, + slotRegistry: new SlotLedger(), + }).commit(projection(['slot'])) + ).toEqual({ status: 'rejected', reason: 'malformed' }); + + const second = runtime.replaceNavigation(); + if (!second.ok) throw new Error('Expected replacement'); + const rollback = vi.fn(); + const throwingRegistry: ProjectionSlotRegistry = { + prepareProjectionSlots: () => ({ + ownerGeneration: second.value.generation, + commit: () => { + throw new Error('commit failed'); + }, + rollback, + }), + }; + expect(controller(second.value, throwingRegistry).commit(projection(['slot']))).toEqual({ + status: 'rejected', + reason: 'capacity', + }); + expect(rollback).toHaveBeenCalledOnce(); + expect(second.value.currentAuctionProjection).toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts new file mode 100644 index 000000000..802d74909 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -0,0 +1,3244 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createBrowserMessagingAdapter } from '../../src/adapters/messaging'; +import { + createPucBridge, + PUC_DYNAMIC_OWNER, + type PucBridgeOptions, + type PucRenderAttempt, +} from '../../src/services/puc_bridge'; +import type { RenderFailureReason, RenderOutcome } from '../../src/services/render'; +import type { + ReservationClaimResult, + ReservationRecognition, + ReservationRenderSource, +} from '../../src/services/reservations'; + +const RESERVATION_ID = 'r1_abcdefghijklmnopqrstuv'; +const LIFECYCLE_TICKET = 't1_abcdefghijklmnopqrstuv'; + +function createPort() { + return { + addEventListener: vi.fn(), + close: vi.fn(), + postMessage: vi.fn(), + removeEventListener: vi.fn(), + start: vi.fn(), + }; +} + +function exactRequest(adId = RESERVATION_ID): string { + return JSON.stringify({ + message: 'Prebid Request', + adId, + adServerDomain: 'ads.example.com', + }); +} + +function exactOwnerRegistration(adId: string, lifecycleTicket = LIFECYCLE_TICKET): string { + return JSON.stringify({ + message: 'TS Render Owner Register', + adId, + version: 1, + lifecycleTicket, + }); +} + +interface HarnessOptions { + readonly claim?: PucBridgeOptions['reservations']['claim']; + readonly messageChannel?: new () => { readonly port1: unknown; readonly port2: unknown }; + readonly mintLifecycleTicket?: PucBridgeOptions['mintLifecycleTicket']; + readonly now?: PucBridgeOptions['now']; + readonly publisherOrigin?: string; + readonly resizeCollapsedShell?: PucBridgeOptions['resizeCollapsedShell']; + readonly rendererNonces?: PucBridgeOptions['rendererNonces']; + readonly rendererUrl?: string; + readonly scheduler?: PucBridgeOptions['scheduler']; +} + +function createHarness( + recognize: (reservationId: unknown) => ReservationRecognition, + options: HarnessOptions = {} +) { + let listener: ((event: MessageEvent) => void) | undefined; + const target = { + addEventListener: vi.fn( + (_type: 'message', next: (event: MessageEvent) => void, _capture: true) => { + listener = next; + } + ), + removeEventListener: vi.fn(), + ...(options.messageChannel ? { MessageChannel: options.messageChannel } : {}), + }; + const bridgeOptions: PucBridgeOptions = { + messaging: createBrowserMessagingAdapter(target, { + ...(options.publisherOrigin ? { expectedPublisherOrigin: options.publisherOrigin } : {}), + ...(options.rendererUrl ? { expectedRendererUrl: options.rendererUrl } : {}), + validateApsRenderer: () => true, + }), + mintLifecycleTicket: + options.mintLifecycleTicket ?? + (() => Object.freeze({ ok: true as const, value: LIFECYCLE_TICKET })), + reservations: { + claim: options.claim ?? (() => ({ recognized: false }) satisfies ReservationClaimResult), + recognize, + }, + ...(options.now ? { now: options.now } : {}), + ...(options.publisherOrigin ? { publisherOrigin: options.publisherOrigin } : {}), + ...(options.resizeCollapsedShell ? { resizeCollapsedShell: options.resizeCollapsedShell } : {}), + ...(options.rendererNonces ? { rendererNonces: options.rendererNonces } : {}), + ...(options.rendererUrl ? { rendererUrl: options.rendererUrl } : {}), + ...(options.scheduler ? { scheduler: options.scheduler } : {}), + }; + const bridge = createPucBridge(bridgeOptions); + const dispatch = (event: Record): void => { + if (!listener) throw new Error('Expected the capture listener to be installed synchronously'); + listener(event as unknown as MessageEvent); + }; + return { bridge, dispatch, target }; +} + +function createGamAttempt(kind: 'aps' | 'adm' = 'aps', index = 0) { + const suffix = index.toString(36).padStart(22, '0').slice(-22); + const id = `a1_${suffix}`; + const reservationId = `r1_${suffix}`; + const navigationGeneration = Object.freeze({ navigation: index }); + const generation = Object.freeze({ attempt: index }); + const winnerContext = Object.freeze({ selectedCpm: 1.25 }); + let state = 'created'; + let outcome: RenderOutcome | undefined; + let renderSource: ReservationRenderSource | undefined; + const settlementObservers: Array<(outcome: RenderOutcome) => void> = []; + const owner = Object.freeze({ + id, + slot: `slot-${index}`, + navigationGeneration, + generation, + winnerContext, + isCurrent: vi.fn(() => outcome === undefined), + prepareWinnerContext: vi.fn(), + }); + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: id, + slot: owner.slot, + navigationGeneration, + dispose: vi.fn(), + }); + const attempt = Object.freeze({ + id, + slot: owner.slot, + generation, + navigationGeneration, + get renderSource() { + return renderSource; + }, + beginGamClaim: vi.fn(() => { + if (state !== 'created' || outcome !== undefined) return false; + state = 'waiting_for_gam_and_claim'; + return true; + }), + admitClaimedWinner: vi.fn(() => { + if (state !== 'waiting_for_gam_and_claim' || outcome !== undefined) return false; + renderSource = Object.freeze( + kind === 'aps' + ? { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + } + : { + type: 'adm', + version: 1, + adm: '
fictional creative
', + width: 300, + height: 250, + } + ) as ReservationRenderSource; + return true; + }), + ownerClaimed: vi.fn(() => { + if (!renderSource || state !== 'waiting_for_gam_and_claim' || outcome !== undefined) { + return false; + } + state = 'waiting_for_owner'; + return true; + }), + ownerRegistered: vi.fn(() => { + if (state !== 'waiting_for_owner' || outcome !== undefined) return false; + state = 'waiting_for_insertion'; + return true; + }), + beginApsDocument: vi.fn(() => { + if (state !== 'waiting_for_insertion' || outcome !== undefined) return false; + state = 'waiting_for_document'; + return true; + }), + beginAdm: vi.fn(() => { + if (state !== 'waiting_for_insertion' || outcome !== undefined) return false; + state = 'waiting_for_adm'; + return true; + }), + apsDocumentAccepted: vi.fn(() => { + if (state !== 'waiting_for_document' || outcome !== undefined) return false; + state = 'waiting_for_aps_completion'; + return true; + }), + accept: vi.fn(() => { + if ( + (state !== 'waiting_for_aps_completion' && state !== 'waiting_for_adm') || + outcome !== undefined + ) { + return false; + } + outcome = Object.freeze({ outcome: 'accepted' }); + state = 'accepted'; + for (const observer of settlementObservers) observer(outcome); + return true; + }), + cancel: vi.fn((reason: 'caller_aborted' | 'superseded' | 'navigation_disposed') => { + if (outcome !== undefined) return false; + outcome = Object.freeze({ outcome: 'cancelled' as const, reason }); + state = 'cancelled'; + for (const observer of settlementObservers) observer(outcome); + return true; + }), + fail: vi.fn((reason: RenderFailureReason) => { + if (outcome !== undefined) return false; + outcome = Object.freeze({ outcome: 'failed', reason }); + state = 'failed'; + for (const observer of settlementObservers) observer(outcome); + return true; + }), + onSettled: vi.fn((callback: (terminal: RenderOutcome) => void) => { + if (outcome !== undefined) return false; + settlementObservers.push(callback); + return true; + }), + snapshot: vi.fn(() => Object.freeze({ state, outcome, history: Object.freeze([state]) })), + }); + return { artifact, attempt, owner, reservationId }; +} + +function dispatchPortMessage( + port: ReturnType, + data: unknown, + ports: readonly unknown[] = [] +): void { + const listener = port.addEventListener.mock.calls.find((call) => call[0] === 'message')?.[1] as + ((event: { data: unknown; ports: readonly unknown[] }) => void) | undefined; + if (!listener) throw new Error('Expected the retained port listener to be installed'); + listener({ data, ports }); +} + +function createClock() { + let now = 0; + let nextHandle = 0; + const tasks = new Map void; deadline: number }>(); + const scheduler = { + set: vi.fn((callback: () => void, milliseconds: number): number => { + nextHandle += 1; + tasks.set(nextHandle, { callback, deadline: now + milliseconds }); + return nextHandle; + }), + clear: vi.fn((handle: unknown): void => { + if (typeof handle === 'number') tasks.delete(handle); + }), + }; + const advance = (milliseconds: number): void => { + now += milliseconds; + for (const [handle, task] of [...tasks]) { + if (task.deadline <= now) { + tasks.delete(handle); + task.callback(); + } + } + }; + return { advance, now: () => now, scheduler }; +} + +function issueReadyTicket( + harness: ReturnType, + gam: ReturnType, + source: object +): void { + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [createPort()], + source, + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); +} + +describe('Universal Creative bridge dispatcher', () => { + it('installs owner iframe lifecycle handlers before assigning either document source', () => { + const admStart = PUC_DYNAMIC_OWNER.indexOf('const insertAdm'); + const apsStart = PUC_DYNAMIC_OWNER.indexOf('const insertAps'); + const controlStart = PUC_DYNAMIC_OWNER.indexOf('const receiveControl'); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, apsStart); + const apsOwner = PUC_DYNAMIC_OWNER.slice(apsStart, controlStart); + + expect(new TextEncoder().encode(PUC_DYNAMIC_OWNER).byteLength).toBeLessThanOrEqual(64 * 1_024); + expect(admStart).toBeGreaterThanOrEqual(0); + expect(apsStart).toBeGreaterThan(admStart); + expect(controlStart).toBeGreaterThan(apsStart); + expect(admOwner.indexOf('next.onload =')).toBeLessThan( + admOwner.indexOf('next.srcdoc = intendedSource;') + ); + expect(admOwner.indexOf('next.onerror =')).toBeLessThan( + admOwner.indexOf('next.srcdoc = intendedSource;') + ); + expect(apsOwner.indexOf('next.onload =')).toBeLessThan( + apsOwner.indexOf('next.src = intendedSource;') + ); + expect(apsOwner.indexOf('next.onerror =')).toBeLessThan( + apsOwner.indexOf('next.src = intendedSource;') + ); + }); + + it('binds owner load and final acceptance to the exact inserted navigation', () => { + const admStart = PUC_DYNAMIC_OWNER.indexOf('const insertAdm'); + const apsStart = PUC_DYNAMIC_OWNER.indexOf('const insertAps'); + const controlStart = PUC_DYNAMIC_OWNER.indexOf('const receiveControl'); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, apsStart); + const apsOwner = PUC_DYNAMIC_OWNER.slice(apsStart, controlStart); + + expect(admOwner).toContain('next.parentNode === creativeWindow.document.body'); + expect(admOwner).toContain('next.srcdoc === intendedSource'); + expect(admOwner).toContain('next.getAttribute("src") === null'); + expect(apsOwner).toContain('next.parentNode === creativeWindow.document.body'); + expect(apsOwner).toContain('next.getAttribute("src") === intendedSource'); + expect(apsOwner).toContain('next.contentWindow === intendedWindow'); + expect(PUC_DYNAMIC_OWNER).toContain('ownerFrameCurrent?.() === true'); + }); + + it.each([ + 'duplicate registration key', + 'accessor-backed registration port', + 'accessor-backed registration ports collection', + 'usable registration port before an accessor', + ])('rejects a %s without binding its owner channel', async (caseName) => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(_listener: ((event: unknown) => void) | null) {}, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + let portAccessorCalls = 0; + let rendered: Promise | undefined; + let observedRejection: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + observedRejection = rendered.then( + () => undefined, + (error: unknown) => error + ); + const ports: unknown[] = [controlPort]; + if (caseName === 'accessor-backed registration port') { + Object.defineProperty(ports, '0', { + configurable: true, + enumerable: true, + get: () => { + portAccessorCalls += 1; + return controlPort; + }, + }); + } + if (caseName === 'usable registration port before an accessor') { + ports[1] = undefined; + Object.defineProperty(ports, '1', { + configurable: true, + enumerable: true, + get: () => { + portAccessorCalls += 1; + return createPort(); + }, + }); + } + const registrationEvent: Record = { + data: + caseName === 'duplicate registration key' + ? `{"message":"TS Render Owner Registered","adId":"${RESERVATION_ID}","version":1,"lifecycleTicket":"${LIFECYCLE_TICKET}","lifecycleTicket":"${LIFECYCLE_TICKET}"}` + : JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports, + }; + if (caseName === 'accessor-backed registration ports collection') { + Object.defineProperty(registrationEvent, 'ports', { + configurable: true, + enumerable: true, + get: () => { + portAccessorCalls += 1; + return ports; + }, + }); + } + registrationCallback?.(registrationEvent); + + expect(controlPort.start).not.toHaveBeenCalled(); + expect(portAccessorCalls).toBe(0); + if ( + caseName === 'duplicate registration key' || + caseName === 'usable registration port before an accessor' + ) { + expect(controlPort.close).toHaveBeenCalledOnce(); + } + await expect(observedRejection).resolves.toEqual( + expect.objectContaining({ message: 'TS render owner registration refused' }) + ); + } finally { + await vi.runAllTimersAsync(); + await observedRejection; + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('closes usable control-message ports without reading a later accessor', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const usable = createPort(); + let accessorCalls = 0; + let observedRejection: Promise | undefined; + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + observedRejection = rendered.then( + () => undefined, + (error: unknown) => error + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + const ports: unknown[] = [usable, undefined]; + Object.defineProperty(ports, '1', { + configurable: true, + enumerable: true, + get: () => { + accessorCalls += 1; + return createPort(); + }, + }); + + controlListener?.({ + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
must not render
', + width: 300, + height: 250, + }, + }, + ports, + }); + + expect(accessorCalls).toBe(0); + expect(usable.close).toHaveBeenCalledOnce(); + expect(controlPort.close).toHaveBeenCalledOnce(); + expect(document.body.querySelector('iframe')).toBeNull(); + await expect(observedRejection).resolves.toEqual( + expect.objectContaining({ message: 'TS render owner control refused' }) + ); + } finally { + await vi.runAllTimersAsync(); + await observedRejection; + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('runs the checked-in PUC owner through helper registration and final ADM settlement', async () => { + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + const stopListening = vi.fn(); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + type: string, + payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return stopListening; + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + + try { + const ownerData = window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>; + const rendered = dynamicWindow.render!(ownerData, { sendMessage }, window); + expect(sendMessage).toHaveBeenCalledWith( + 'TS Render Owner Register', + { version: 1, lifecycleTicket: LIFECYCLE_TICKET }, + expect.any(Function) + ); + registrationCallback?.( + new MessageEvent('message', { + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort as unknown as MessagePort], + }) + ); + expect(stopListening).toHaveBeenCalledOnce(); + expect(controlPort.start).toHaveBeenCalledOnce(); + + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
remote creative
', + width: 300, + height: 250, + }, + }, + ports: [], + }) + ); + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + expect(frame?.srcdoc).toContain('
remote creative
'); + expect(frame?.getAttribute('sandbox')).toBe( + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation' + ); + expect(controlPort.postMessage).toHaveBeenCalledWith({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + + frame?.dispatchEvent(new Event('load')); + expect(controlPort.postMessage).toHaveBeenCalledWith({ + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }) + ); + + await expect(rendered).resolves.toBeUndefined(); + expect(frame?.isConnected).toBe(true); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('rejects accepted ADM settlement after the owner iframe navigation changes', async () => { + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.( + new MessageEvent('message', { + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort as unknown as MessagePort], + }) + ); + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
intended creative
', + width: 300, + height: 250, + }, + }, + ports: [], + }) + ); + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + if (!frame) throw new Error('Expected owner iframe'); + frame.srcdoc = '
replaced creative
'; + frame.dispatchEvent(new Event('load')); + expect(controlPort.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ message: 'TS ADM Loaded' }) + ); + + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }) + ); + + await expect(rendered).rejects.toThrow('TS render owner control refused'); + expect(frame.isConnected).toBe(false); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('settles and closes the owner channel when every terminal DOM cleanup hook throws', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + const hostileOwnerWindow = Object.create(window) as Window; + const clearTimeout = vi.fn(() => { + throw new Error('clear timeout failed'); + }); + Object.defineProperties(hostileOwnerWindow, { + clearTimeout: { configurable: true, value: clearTimeout }, + document: { configurable: true, value: document }, + setTimeout: { configurable: true, value: window.setTimeout.bind(window) }, + }); + let registrationCallback: ((event: unknown) => void) | undefined; + const stopListening = vi.fn(); + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return stopListening; + } + ); + let controlListener: ((event: unknown) => void) | undefined; + let throwOnHandlerClear = false; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + if (listener === null && throwOnHandlerClear) throw new Error('message clear failed'); + controlListener = listener ?? undefined; + }, + set onmessageerror(listener: ((event: unknown) => void) | null) { + if (listener === null && throwOnHandlerClear) { + throw new Error('messageerror clear failed'); + } + }, + }; + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + hostileOwnerWindow + ); + const observed = rendered.then( + () => 'resolved', + () => 'rejected' + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
cleanup test
', + width: 300, + height: 250, + }, + }, + ports: [], + }); + const frame = document.body.querySelector('iframe'); + if (!frame) throw new Error('Expected the owner frame'); + const loadHandler = frame.onload; + const errorHandler = frame.onerror; + Object.defineProperties(frame, { + onerror: { + configurable: true, + get: () => errorHandler, + set: (value: unknown) => { + if (value === null) throw new Error('frame error-handler clear failed'); + }, + }, + onload: { + configurable: true, + get: () => loadHandler, + set: (value: unknown) => { + if (value === null) throw new Error('frame load-handler clear failed'); + }, + }, + remove: { + configurable: true, + value: vi.fn(() => { + throw new Error('frame removal failed'); + }), + }, + }); + throwOnHandlerClear = true; + + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'failed', + reason: 'adm_document_no_load', + }, + ports: [], + }); + + await Promise.resolve(); + expect(await Promise.race([observed, Promise.resolve('pending')])).toBe('rejected'); + expect(clearTimeout).toHaveBeenCalled(); + expect(stopListening).toHaveBeenCalledOnce(); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('accepts the optional APS creative id and preserves no-referrer on the owner iframe', async () => { + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const documentPort = createPort(); + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + creativeId: 'creative-1', + }, + }, + }, + ports: [documentPort], + }); + + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + expect(frame?.getAttribute('referrerpolicy')).toBe('no-referrer'); + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }); + await expect(rendered).resolves.toBeUndefined(); + } finally { + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('keeps APS ownership alive after a local frame error until the kernel settles failure', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const documentPort = createPort(); + let rendered: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + }, + }, + }, + ports: [documentPort], + }); + + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + frame?.dispatchEvent(new Event('error')); + const immediate = rendered.then( + () => 'resolved', + () => 'rejected' + ); + await Promise.resolve(); + expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('pending'); + expect(document.body.querySelector('iframe')).toBeNull(); + expect(documentPort.close).toHaveBeenCalledOnce(); + expect(controlPort.close).not.toHaveBeenCalled(); + + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'failed', + reason: 'runner_no_load', + }, + ports: [], + }); + await expect(rendered).rejects.toThrow('runner_no_load'); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + await vi.runAllTimersAsync(); + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('fails closed immediately when the PUC helper does not return its disposer', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let rendered: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage: vi.fn(() => undefined) }, + window + ); + const immediate = rendered.then( + () => 'resolved', + () => 'rejected' + ); + + await Promise.resolve(); + expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('rejected'); + } finally { + await vi.runAllTimersAsync(); + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('rejects registration at exactly three seconds, disposes the helper, and closes a late port', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + const stopListening = vi.fn(); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return stopListening; + } + ); + let rendered: Promise | undefined; + let settlement = 'pending'; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + void rendered.then( + () => { + settlement = 'resolved'; + }, + () => { + settlement = 'rejected'; + } + ); + + await vi.advanceTimersByTimeAsync(2_999); + expect(settlement).toBe('pending'); + expect(stopListening).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(settlement).toBe('rejected'); + expect(stopListening).toHaveBeenCalledOnce(); + + const latePort = createPort(); + registrationCallback?.({ data: '{}', ports: [latePort] }); + expect(latePort.close).toHaveBeenCalledOnce(); + expect(stopListening).toHaveBeenCalledOnce(); + } finally { + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('removes uncommitted owner DOM at the exact twenty-second watchdog boundary', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + let rendered: Promise | undefined; + let settlement = 'pending'; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + void rendered.then( + () => { + settlement = 'resolved'; + }, + () => { + settlement = 'rejected'; + } + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
uncommitted creative
', + width: 300, + height: 250, + }, + }, + ports: [], + }); + const frame = document.body.querySelector('iframe'); + expect(frame?.isConnected).toBe(true); + + await vi.advanceTimersByTimeAsync(19_999); + expect(settlement).toBe('pending'); + expect(frame?.isConnected).toBe(true); + expect(controlPort.close).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(settlement).toBe('rejected'); + expect(frame?.isConnected).toBe(false); + expect(controlPort.close).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it.each([ + { + caseName: 'cross-origin renderer route', + ownerKind: 'aps', + rendererOverrides: {}, + rendererUrl: 'https://attacker.example/integrations/aps/renderer/v1', + }, + { + caseName: 'semantically invalid renderer descriptor', + ownerKind: 'aps', + rendererOverrides: { tagType: 'native' }, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + }, + { + caseName: 'mismatched declared owner kind', + ownerKind: 'adm', + rendererOverrides: {}, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + }, + ])( + 'refuses an APS owner start with a $caseName', + async ({ ownerKind, rendererOverrides, rendererUrl }) => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const documentPort = createPort(); + let rendered: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: ownerKind, + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl, + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + ...rendererOverrides, + }, + }, + }, + ports: [documentPort], + }); + const immediate = rendered.then( + () => 'resolved', + () => 'rejected' + ); + + await Promise.resolve(); + expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('rejected'); + expect(document.body.querySelector('iframe')).toBeNull(); + expect(documentPort.close).toHaveBeenCalledOnce(); + } finally { + await vi.runAllTimersAsync(); + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + } + ); + + it('installs one capture listener synchronously and removes only that listener on disposal', () => { + const harness = createHarness(() => ({ recognized: false })); + + expect(harness.target.addEventListener).toHaveBeenCalledOnce(); + expect(harness.target.addEventListener.mock.calls[0]?.[0]).toBe('message'); + expect(harness.target.addEventListener.mock.calls[0]?.[2]).toBe(true); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 0, + disposed: false, + liveTickets: 0, + pendingClaims: 0, + ticketTombstones: 0, + }); + + harness.bridge.dispose(); + harness.bridge.dispose(); + expect(harness.target.removeEventListener).toHaveBeenCalledOnce(); + expect(harness.target.removeEventListener.mock.calls[0]?.[0]).toBe('message'); + expect(harness.target.removeEventListener.mock.calls[0]?.[2]).toBe(true); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 0, + disposed: true, + liveTickets: 0, + pendingClaims: 0, + ticketTombstones: 0, + }); + }); + + it('leaves native Prebid identifiers untouched before port or source inspection', () => { + const recognize = vi.fn((): ReservationRecognition => ({ recognized: false })); + const harness = createHarness(recognize); + const stopImmediatePropagation = vi.fn(); + const ports = vi.fn(() => { + throw new Error('native ports must not be read'); + }); + const source = vi.fn(() => { + throw new Error('native source must not be read'); + }); + + harness.dispatch({ + data: exactRequest('native-prebid-id'), + stopImmediatePropagation, + get ports() { + return ports(); + }, + get source() { + return source(); + }, + }); + + expect(recognize).toHaveBeenCalledWith('native-prebid-id'); + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(ports).not.toHaveBeenCalled(); + expect(source).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + }); + + it.each([ + ['extended object', { message: 'Prebid Request', adId: RESERVATION_ID, extra: true }], + [ + 'extended JSON', + JSON.stringify({ message: 'Prebid Request', adId: RESERVATION_ID, extra: true }), + ], + ])('suppresses and generically refuses a recognized %s before exact parsing', (_label, data) => { + const order: string[] = []; + const harness = createHarness((reservationId) => { + order.push(`lookup:${String(reservationId)}`); + return { recognized: true, state: 'renderable', expiresAt: 1_000 }; + }); + const port = createPort(); + + harness.dispatch({ + data, + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(() => order.push('stop')), + }); + + expect(order).toEqual([`lookup:${RESERVATION_ID}`, 'stop']); + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '3', + tsOwner: { version: 1, status: 'refused' }, + }); + expect(port.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(port.close).toHaveBeenCalledOnce(); + }); + + it('suppresses recognized requests with the wrong port count, refuses on the first, and closes every port', () => { + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 1_000, + })); + const first = createPort(); + const second = createPort(); + const third = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: exactRequest(), + ports: [first, second, third], + source: Object.freeze({}), + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(first.postMessage).toHaveBeenCalledOnce(); + expect(JSON.parse(String(first.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '3', + tsOwner: { version: 1, status: 'refused' }, + }); + expect(first.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(second.postMessage).not.toHaveBeenCalled(); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + expect(third.close).toHaveBeenCalledOnce(); + + const malformed = { close: vi.fn() }; + const laterUsable = createPort(); + harness.dispatch({ + data: exactRequest(), + ports: [malformed, laterUsable], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect(malformed.close).toHaveBeenCalledOnce(); + expect(laterUsable.postMessage).toHaveBeenCalledOnce(); + expect(laterUsable.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + }); + + it('buffers only the first exact live claim and generically refuses a duplicate', () => { + const gam = createGamAttempt('aps'); + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 1_000, + })); + const first = createPort(); + const duplicate = createPort(); + const source = Object.freeze({ frame: 'authoritative' }); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + harness.dispatch({ + data: exactRequest(), + ports: [first], + source, + stopImmediatePropagation: vi.fn(), + }); + + expect(first.postMessage).not.toHaveBeenCalled(); + expect(first.close).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(1); + + harness.dispatch({ + data: exactRequest(), + ports: [duplicate], + source: Object.freeze({ frame: 'duplicate' }), + stopImmediatePropagation: vi.fn(), + }); + + expect(duplicate.postMessage).toHaveBeenCalledOnce(); + expect(duplicate.close).toHaveBeenCalledOnce(); + expect(first.postMessage).not.toHaveBeenCalled(); + expect(first.close).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(1); + + harness.bridge.dispose(); + expect(first.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + }); + + it.each(['caller_aborted', 'superseded', 'navigation_disposed'] as const)( + 'contains a claim-first attempt cancelled as %s', + (reason) => { + const gam = createGamAttempt('aps'); + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 10_000, + })); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ frame: 'authoritative' }), + stopImmediatePropagation: vi.fn(), + }); + + expect(gam.attempt.cancel(reason)).toBe(true); + + expect(port.postMessage).not.toHaveBeenCalled(); + expect(port.close).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + pendingClaims: 0, + }); + } + ); + + it.each(['gam_empty', 'gpt_request_timeout', 'gpt_completion_timeout'] as const)( + 'contains a GAM-first attempt failed as %s and clears its claim deadline', + (reason) => { + const clock = createClock(); + const gam = createGamAttempt('aps'); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { now: clock.now, scheduler: clock.scheduler } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + + expect(gam.attempt.fail(reason)).toBe(true); + + expect(clock.scheduler.clear).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + pendingClaims: 0, + }); + } + ); + + it('tombstones a ready ticket when the owning attempt settles before registration', () => { + const clock = createClock(); + const gam = createGamAttempt('adm'); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + issueReadyTicket(harness, gam, Object.freeze({ frame: 'authoritative' })); + + expect(gam.attempt.cancel('superseded')).toBe(true); + + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + ticketTombstones: 1, + }); + }); + + it.each(['consumed', 'disposed', 'awaiting_prebid_selection'] as const)( + 'suppresses and refuses a recognized non-renderable %s reservation', + (state) => { + const harness = createHarness(() => ({ recognized: true, state, expiresAt: 1_000 })); + const port = createPort(); + + harness.dispatch({ + data: exactRequest(), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(port.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + } + ); + + it('joins an early claim with nonempty GAM and exposes only owner kind and ticket', () => { + const gam = createGamAttempt('aps'); + const source = Object.freeze({ frame: 'authoritative' }); + const claim = vi.fn(({ pucSource }: { pucSource: unknown }): ReservationClaimResult => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + })); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + harness.dispatch({ + data: exactRequest(), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }); + expect(claim).not.toHaveBeenCalled(); + expect(port.postMessage).not.toHaveBeenCalled(); + + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + expect(claim).toHaveBeenCalledWith({ + attempt: gam.owner, + navigationGeneration: gam.owner.navigationGeneration, + pucSource: source, + reservationId: RESERVATION_ID, + slot: gam.owner.slot, + }); + expect(gam.attempt.admitClaimedWinner).toHaveBeenCalledOnce(); + expect(gam.attempt.ownerClaimed).toHaveBeenCalledOnce(); + expect(port.postMessage).toHaveBeenCalledOnce(); + const response = JSON.parse(String(port.postMessage.mock.calls[0]?.[0])); + expect( + new TextEncoder().encode(String(port.postMessage.mock.calls[0]?.[0])).byteLength + ).toBeLessThanOrEqual(72 * 1_024); + expect(response).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }); + expect(response).not.toHaveProperty('source'); + expect(response).not.toHaveProperty('renderSource'); + expect(response).not.toHaveProperty('winnerContext'); + expect(port.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(port.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 1, + disposed: false, + liveTickets: 1, + pendingClaims: 0, + ticketTombstones: 0, + }); + + expect(gam.attempt.fail('internal_error')).toBe(true); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 0, + disposed: false, + liveTickets: 0, + pendingClaims: 0, + ticketTombstones: 1, + }); + }); + + it('requests one guarded shell resize only after the current ready response posts', () => { + const gam = createGamAttempt('aps', 81); + const source = Object.freeze({ frame: 'authoritative' }); + const resizeCollapsedShell = vi.fn(() => true); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + resizeCollapsedShell, + } + ); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + + expect(resizeCollapsedShell).toHaveBeenCalledExactlyOnceWith({ + source, + width: 300, + height: 250, + }); + expect(port.postMessage.mock.invocationCallOrder[0]).toBeLessThan( + resizeCollapsedShell.mock.invocationCallOrder[0]! + ); + }); + + it('does not resize after a failed post or a navigation cancellation during the post', () => { + const resizeCollapsedShell = vi.fn(() => true); + for (const cancelDuringPost of [false, true]) { + const gam = createGamAttempt('adm', cancelDuringPost ? 83 : 82); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + resizeCollapsedShell, + } + ); + const port = createPort(); + port.postMessage.mockImplementation(() => { + if (cancelDuringPost) gam.attempt.cancel('navigation_disposed'); + else throw new Error('post failed'); + }); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ frame: cancelDuringPost ? 'cancelled' : 'failed' }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + } + + expect(resizeCollapsedShell).not.toHaveBeenCalled(); + }); + + it('starts the exact three-second claim deadline only after nonempty GAM', () => { + const clock = createClock(); + const gam = createGamAttempt('adm'); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { now: clock.now, scheduler: clock.scheduler } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + expect(clock.scheduler.set).toHaveBeenCalledWith(expect.any(Function), 3_000); + clock.advance(2_999); + expect(gam.attempt.fail).not.toHaveBeenCalled(); + clock.advance(1); + expect(gam.attempt.fail).toHaveBeenCalledWith('bridge_claim_timeout'); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().attempts).toBe(0); + }); + + it('clears a GAM-first claim deadline when the exact request completes the join', () => { + const clock = createClock(); + const gam = createGamAttempt('adm'); + const claim = vi.fn(({ pucSource }: { pucSource: unknown }): ReservationClaimResult => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + })); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + const staleClaimDeadline = clock.scheduler.set.mock.calls[0]?.[0]; + if (typeof staleClaimDeadline !== 'function') { + throw new Error('Expected the GAM-first claim deadline callback'); + } + const port = createPort(); + harness.dispatch({ + data: exactRequest(), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(gam.attempt.renderSource).toMatchObject({ type: 'adm', version: 1 }); + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.kind).toBe('adm'); + expect(clock.scheduler.clear).toHaveBeenCalledOnce(); + staleClaimDeadline(); + expect(gam.attempt.fail).not.toHaveBeenCalledWith('bridge_claim_timeout'); + clock.advance(2_999); + expect(gam.attempt.fail).not.toHaveBeenCalled(); + clock.advance(1); + expect(gam.attempt.fail).toHaveBeenCalledWith('owner_registration_timeout'); + expect(gam.attempt.fail).not.toHaveBeenCalledWith('bridge_claim_timeout'); + }); + + it('checks all eight ticket draws against live and tombstoned entries', () => { + const first = createGamAttempt('aps', 1); + const second = createGamAttempt('aps', 2); + let draws = 0; + const claim = vi.fn(({ pucSource }: { pucSource: unknown }): ReservationClaimResult => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + })); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim, + mintLifecycleTicket: () => { + draws += 1; + return Object.freeze({ ok: true as const, value: LIFECYCLE_TICKET }); + }, + } + ); + for (const gam of [first, second]) { + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + const port = createPort(); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ index: draws }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + if (gam === first) { + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe( + 'ready' + ); + expect(gam.attempt.fail('internal_error')).toBe(true); + } else { + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe( + 'refused' + ); + expect(gam.attempt.fail).toHaveBeenCalledWith('identity_generation_failed'); + } + } + expect(draws).toBe(9); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + }); + + it('retains ticket tombstones through 2,999 ms and prunes them at 3,000 ms', () => { + const clock = createClock(); + const gam = createGamAttempt('aps', 7); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [createPort()], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(gam.attempt.fail('internal_error')).toBe(true); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + + clock.advance(2_999); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + clock.advance(1); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(0); + }); + + it('starts the fixed ticket TTL only after posting the ready outer response', () => { + const clock = createClock(); + const gam = createGamAttempt('aps', 71); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + const port = createPort(); + port.postMessage.mockImplementation(() => clock.advance(1_000)); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + + clock.advance(2_000); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + expect(gam.attempt.fail).not.toHaveBeenCalled(); + clock.advance(999); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + clock.advance(1); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(0); + expect(gam.attempt.fail).toHaveBeenCalledWith('owner_registration_timeout'); + }); + + it('keeps a reused ticket live when a cleared expiry callback from its prior issue arrives late', () => { + let now = 0; + const callbacks: Array<() => void> = []; + const scheduler = { + set: vi.fn((callback: () => void): number => { + callbacks[callbacks.length] = callback; + return callbacks.length; + }), + clear: vi.fn(), + }; + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: () => now, + scheduler, + } + ); + const first = createGamAttempt('aps', 72); + issueReadyTicket(harness, first, Object.freeze({ frame: 'first' })); + const firstExpiry = callbacks[0]; + if (!firstExpiry) throw new Error('Expected the first ticket expiry callback'); + + now = 3_000; + firstExpiry(); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(0); + + const second = createGamAttempt('aps', 73); + issueReadyTicket(harness, second, Object.freeze({ frame: 'second' })); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + + now = 6_000; + firstExpiry(); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + expect(second.attempt.fail).not.toHaveBeenCalled(); + + const secondExpiry = callbacks[1]; + if (!secondExpiry) throw new Error('Expected the reused ticket expiry callback'); + secondExpiry(); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(0); + expect(second.attempt.fail).toHaveBeenCalledWith('owner_registration_timeout'); + }); + + it('fails and tombstones a ticket when the ready outer response cannot be posted', () => { + const gam = createGamAttempt('adm', 8); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + const port = createPort(); + port.postMessage.mockImplementation(() => { + throw new Error('outer response transport failed'); + }); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(port.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + pendingClaims: 0, + ticketTombstones: 1, + }); + }); + + it('shares ticket capacity 320 across live entries without eviction', () => { + const clock = createClock(); + let draw = 0; + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => { + const suffix = draw.toString(36).padStart(22, '0').slice(-22); + draw += 1; + return Object.freeze({ ok: true as const, value: `t1_${suffix}` }); + }, + now: clock.now, + scheduler: clock.scheduler, + } + ); + + for (let index = 0; index < 320; index += 1) { + const gam = createGamAttempt('aps', 100 + index); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + const port = createPort(); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ index }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe('ready'); + } + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 320, + liveTickets: 320, + ticketTombstones: 0, + }); + + const overflow = createGamAttempt('aps', 999); + const overflowPort = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: overflow.artifact, + attempt: overflow.attempt, + owner: overflow.owner, + reservationId: overflow.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(overflow.reservationId), + ports: [overflowPort], + source: Object.freeze({ overflow: true }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: overflow.artifact, + attempt: overflow.attempt, + owner: overflow.owner, + reservationId: overflow.reservationId, + }) + ).toBe(true); + expect(overflow.attempt.fail).toHaveBeenCalledWith('capability_registry_full'); + expect(JSON.parse(String(overflowPort.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe( + 'refused' + ); + expect(draw).toBe(320); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 320, + liveTickets: 320, + ticketTombstones: 0, + }); + harness.bridge.dispose(); + }); + + it('ignores an unknown owner ticket before suppression, source, or port inspection', () => { + const harness = createHarness(() => ({ recognized: false })); + const stopImmediatePropagation = vi.fn(); + const ports = vi.fn(() => { + throw new Error('unknown ticket ports must not be read'); + }); + const source = vi.fn(() => { + throw new Error('unknown ticket source must not be read'); + }); + + harness.dispatch({ + data: exactOwnerRegistration(RESERVATION_ID, 't1_0000000000000000000000'), + stopImmediatePropagation, + get ports() { + return ports(); + }, + get source() { + return source(); + }, + }); + + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(ports).not.toHaveBeenCalled(); + expect(source).not.toHaveBeenCalled(); + }); + + it('suppresses a known owner ticket before failing closed on a regressed clock', () => { + let now = 100; + const gam = createGamAttempt('adm', 1_009); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: () => now, + } + ); + issueReadyTicket(harness, gam, pucSource); + now = 99; + const responsePort = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(responsePort.close).toHaveBeenCalledOnce(); + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + }); + + it('consumes one exact owner registration and retains only the kernel control endpoint', () => { + const gam = createGamAttempt('adm', 1_001); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const retained = createPort(); + const transferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = retained; + readonly port2 = transferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const responsePort = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(gam.attempt.ownerRegistered).toHaveBeenCalledOnce(); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'TS Render Owner Registered', + adId: gam.reservationId, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(responsePort.postMessage.mock.calls[0]?.[1]).toEqual([transferred]); + expect(responsePort.close).toHaveBeenCalledOnce(); + expect(transferred.close).not.toHaveBeenCalled(); + expect(retained.close).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 1, + liveTickets: 0, + ticketTombstones: 1, + }); + + expect(gam.attempt.fail('internal_error')).toBe(true); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).not.toHaveBeenCalled(); + }); + + it('closes both channel endpoints when owner-channel construction settles reentrantly', () => { + const gam = createGamAttempt('adm', 1_010); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const retained = createPort(); + const transferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = retained; + readonly port2 = transferred; + + constructor() { + gam.attempt.fail('internal_error'); + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const responsePort = createPort(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).toHaveBeenCalledOnce(); + expect(responsePort.close).toHaveBeenCalledOnce(); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().attempts).toBe(0); + }); + + it('sends exact ADM start and settles only after owner insertion and intended load', () => { + const gam = createGamAttempt('adm', 1_011); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = controlRetained; + readonly port2 = controlTransferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const responsePort = createPort(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(controlRetained.postMessage).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[0]).toEqual([ + { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
fictional creative
', + width: 300, + height: 250, + }, + }, + [], + ]); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + + dispatchPortMessage(controlRetained, { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(gam.attempt.beginAdm).toHaveBeenCalledWith(gam.artifact); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + + dispatchPortMessage(controlRetained, { + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(gam.attempt.accept).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + expect(controlRetained.postMessage).toHaveBeenCalledTimes(2); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + [], + ]); + expect(controlRetained.close).toHaveBeenCalledOnce(); + }); + + it('fails closed and contains every port when an owner control message transfers one', () => { + const gam = createGamAttempt('adm', 1_014); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = controlRetained; + readonly port2 = controlTransferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + const unexpected = createPort(); + + dispatchPortMessage( + controlRetained, + { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }, + [unexpected] + ); + + expect(unexpected.close).toHaveBeenCalledOnce(); + expect(gam.attempt.beginAdm).not.toHaveBeenCalled(); + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(controlRetained.close).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + }); + + it('sends exact APS start with one document port and accepts exact document completion', () => { + const gam = createGamAttempt('aps', 1_012); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const documentRetained = createPort(); + const documentTransferred = createPort(); + const channels = [ + { port1: controlRetained, port2: controlTransferred }, + { port1: documentRetained, port2: documentTransferred }, + ]; + let channelIndex = 0; + const issue = vi.fn( + (input: { + readonly attempt: PucRenderAttempt; + readonly port: { readonly close: () => void }; + }) => { + expect(input.attempt.onSettled(() => input.port.close())).toBe(true); + return Object.freeze({ ok: true as const, nonce: 'n1_abcdefghijklmnopqrstuv' }); + } + ); + const consume = vi.fn(() => true); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1: unknown; + readonly port2: unknown; + + constructor() { + const channel = channels[channelIndex]; + channelIndex += 1; + if (!channel) throw new Error('Unexpected extra MessageChannel'); + this.port1 = channel.port1; + this.port2 = channel.port2; + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + publisherOrigin: 'https://publisher.example', + rendererNonces: Object.freeze({ issue, consume }), + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + } + ); + issueReadyTicket(harness, gam, pucSource); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(channelIndex).toBe(2); + expect(issue).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[0]).toEqual([ + { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + }, + }, + }, + [documentTransferred], + ]); + + dispatchPortMessage(documentRetained, { + message: 'TS APS Document Accepted', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + expect(consume).not.toHaveBeenCalled(); + expect(gam.attempt.apsDocumentAccepted).not.toHaveBeenCalled(); + + dispatchPortMessage(documentRetained, { + message: 'TS APS Runner Loaded', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Completed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Failed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + reason: 'runner_failed', + }); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + + // Control and document messages travel over different ports, so delivery order + // is not defined even though the owner posts insertion before handing off. + dispatchPortMessage(controlRetained, { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(gam.attempt.beginApsDocument).toHaveBeenCalledWith(gam.artifact); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + expect(consume).toHaveBeenCalledOnce(); + expect(gam.attempt.apsDocumentAccepted).toHaveBeenCalledOnce(); + expect(gam.attempt.accept).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + [], + ]); + expect(controlRetained.close).toHaveBeenCalledOnce(); + expect(documentRetained.close).toHaveBeenCalledOnce(); + expect(controlTransferred.close).not.toHaveBeenCalled(); + expect(documentTransferred.close).not.toHaveBeenCalled(); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + }); + + it('keeps the first buffered APS failure when a later completion arrives', () => { + const gam = createGamAttempt('aps', 1_016); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const documentRetained = createPort(); + const documentTransferred = createPort(); + const channels = [ + { port1: controlRetained, port2: controlTransferred }, + { port1: documentRetained, port2: documentTransferred }, + ]; + let channelIndex = 0; + const issue = vi.fn( + (input: { + readonly attempt: PucRenderAttempt; + readonly port: { readonly close: () => void }; + }) => { + expect(input.attempt.onSettled(() => input.port.close())).toBe(true); + return Object.freeze({ ok: true as const, nonce: 'n1_abcdefghijklmnopqrstuv' }); + } + ); + const consume = vi.fn(() => true); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1: unknown; + readonly port2: unknown; + + constructor() { + const channel = channels[channelIndex]; + channelIndex += 1; + if (!channel) throw new Error('Unexpected extra MessageChannel'); + this.port1 = channel.port1; + this.port2 = channel.port2; + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + publisherOrigin: 'https://publisher.example', + rendererNonces: Object.freeze({ issue, consume }), + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + } + ); + issueReadyTicket(harness, gam, pucSource); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + dispatchPortMessage(documentRetained, { + message: 'TS APS Document Accepted', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Failed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + reason: 'runner_failed', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Completed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(controlRetained, { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + + expect(consume).toHaveBeenCalledOnce(); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + expect(gam.attempt.fail).toHaveBeenCalledWith('runner_failed'); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'failed', + reason: 'runner_failed', + }, + [], + ]); + }); + + it('closes a reentrant APS document channel before issuing nonce authority', () => { + const gam = createGamAttempt('aps', 1_015); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const documentRetained = createPort(); + const documentTransferred = createPort(); + let channelIndex = 0; + const issue = vi.fn(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1: unknown; + readonly port2: unknown; + + constructor() { + channelIndex += 1; + if (channelIndex === 1) { + this.port1 = controlRetained; + this.port2 = controlTransferred; + return; + } + this.port1 = documentRetained; + this.port2 = documentTransferred; + gam.attempt.fail('internal_error'); + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + publisherOrigin: 'https://publisher.example', + rendererNonces: Object.freeze({ issue, consume: vi.fn() }), + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + } + ); + issueReadyTicket(harness, gam, pucSource); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(channelIndex).toBe(2); + expect(issue).not.toHaveBeenCalled(); + expect(documentRetained.close).toHaveBeenCalledOnce(); + expect(documentTransferred.close).toHaveBeenCalledOnce(); + expect(controlRetained.close).toHaveBeenCalledOnce(); + expect(controlTransferred.close).not.toHaveBeenCalled(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().attempts).toBe(0); + }); + + it('suppresses, refuses, and invalidates a live ticket used from the wrong source', () => { + const gam = createGamAttempt('adm', 1_002); + const pucSource = Object.freeze({ frame: 'authoritative' }); + let channels = 0; + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + constructor() { + channels += 1; + } + + readonly port1 = createPort(); + readonly port2 = createPort(); + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const wrongSourcePort = createPort(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [wrongSourcePort], + source: Object.freeze({ frame: 'wrong' }), + stopImmediatePropagation: vi.fn(), + }); + + expect(JSON.parse(String(wrongSourcePort.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + version: 1, + }); + expect(wrongSourcePort.close).toHaveBeenCalledOnce(); + expect(channels).toBe(0); + expect(gam.attempt.fail).toHaveBeenCalledWith('bridge_id_mismatch'); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + ticketTombstones: 1, + }); + + const replayPort = createPort(); + const stopReplay = vi.fn(); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [replayPort], + source: pucSource, + stopImmediatePropagation: stopReplay, + }); + expect(stopReplay).toHaveBeenCalledOnce(); + expect(JSON.parse(String(replayPort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(replayPort.close).toHaveBeenCalledOnce(); + expect(channels).toBe(0); + }); + + it('invalidates a live owner ticket on an extended shape or wrong port count', () => { + const gam = createGamAttempt('aps', 1_003); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const first = createPort(); + const second = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: JSON.stringify({ + message: 'TS Render Owner Register', + adId: gam.reservationId, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + extra: true, + }), + ports: [first, second], + source: pucSource, + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(String(first.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + version: 1, + }); + expect(first.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + expect(gam.attempt.fail).toHaveBeenCalledWith('bridge_id_mismatch'); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + }); + + it('tombstones a posted ticket when its expiry scheduler cannot arm', () => { + let now = 0; + const gam = createGamAttempt('aps', 81); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: () => now, + scheduler: { + clear: vi.fn(), + set: vi.fn(() => undefined), + }, + } + ); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + liveTickets: 0, + ticketTombstones: 1, + }); + + now = 3_000; + const latePort = createPort(); + const stopImmediatePropagation = vi.fn(); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId, LIFECYCLE_TICKET), + ports: [latePort], + source: Object.freeze({}), + stopImmediatePropagation, + }); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(0); + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(String(latePort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(latePort.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts new file mode 100644 index 000000000..496b5cc18 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -0,0 +1,3691 @@ +import { describe, expect, it, vi } from 'vitest'; + +import apsEnvelope from '../fixtures/aps-renderer-v1.json'; +import { createBrowserMessagingAdapter, type MessagingAdapter } from '../../src/adapters/messaging'; +import { prepareAdmIframe } from '../../src/core/render'; +import { resizeCollapsedPucShell } from '../../src/core/puc_shell'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { createRuntimeSession } from '../../src/kernel/sessions'; +import type { RenderAttemptScope, WinnerContext } from '../../src/kernel/sessions'; +import { + APS_RENDERER_SANDBOX, + APS_RENDERER_V1_PATH, + renderDirectApsAttempt, + resolveApsRendererV1Url, +} from '../../src/integrations/aps/render'; +import { + createCommittedArtifactStore, + createRenderAttempt, + createRendererNonceRegistry, + createSlotOperation, + renderDirectAdmAttempt, + type CommittedRenderArtifact, + type DirectAdmIframeConstructor, + type DirectAdmIframeHandle, + type RenderAttempt, + type RenderAttemptDiagnosticsObservation, + type RenderAttemptSnapshot, + type RenderAttemptState, + type SlotOperation, + type SlotOperationOptions, +} from '../../src/services/render'; +import { + createReservationService, + type ReservationClaimResult, + type ReservationRenderSource, + type ReservationService, +} from '../../src/services/reservations'; + +const ATTEMPT_ONE = 'a1_0000000000000000000000'; +const ATTEMPT_TWO = 'a1_0000000000000000000001'; + +function indexedAttemptId(index: number): string { + return `a1_${index.toString().padStart(22, '0')}`; +} + +function indexedRendererNonce(index: number): string { + return `n1_${index.toString().padStart(22, '0')}`; +} + +const ADM_SOURCE = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
fictional creative
', + width: 300, + height: 250, +}); + +const APS_SOURCE = Object.freeze({ + type: 'aps' as const, + version: 1 as const, + accountId: 'fictional-account', + bidId: 'fictional-bid', + tagType: 'iframe' as const, + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'e30=', +}); + +const DIRECT_APS_BID = apsEnvelope.seatbid[0]!.bid[0]!; +const DIRECT_APS_SOURCE = Object.freeze({ + type: 'aps' as const, + version: 1 as const, + accountId: 'fictional-account', + bidId: DIRECT_APS_BID.id, + creativeId: 'fictional-creative', + tagType: DIRECT_APS_BID.ext.tagtype as 'iframe', + creativeUrl: DIRECT_APS_BID.ext.creativeurl, + width: DIRECT_APS_BID.w, + height: DIRECT_APS_BID.h, + aaxResponse: btoa(JSON.stringify(apsEnvelope)), +}); + +const WINNER_CONTEXT = Object.freeze({ selectedCpm: 1 }); + +describe('collapsed PUC shell resize', () => { + function collapsedShell(): { + readonly frame: HTMLIFrameElement; + readonly wrapper: HTMLDivElement; + } { + const wrapper = document.createElement('div'); + const frame = document.createElement('iframe'); + wrapper.style.width = '1px'; + wrapper.style.height = '1px'; + frame.setAttribute('width', '1'); + frame.setAttribute('height', '1'); + frame.style.width = '1px'; + frame.style.height = '1px'; + wrapper.appendChild(frame); + document.body.appendChild(wrapper); + return { frame, wrapper }; + } + + it('resizes only the exact connected source iframe and its collapsed immediate wrapper once', () => { + const selected = collapsedShell(); + const sibling = collapsedShell(); + + try { + expect( + resizeCollapsedPucShell({ + source: selected.frame.contentWindow!, + width: 300, + height: 250, + }) + ).toBe(true); + expect(selected.frame.style.width).toBe('300px'); + expect(selected.frame.style.height).toBe('250px'); + expect(selected.wrapper.style.width).toBe('300px'); + expect(selected.wrapper.style.height).toBe('250px'); + expect(sibling.frame.style.width).toBe('1px'); + expect(sibling.wrapper.style.width).toBe('1px'); + + expect( + resizeCollapsedPucShell({ + source: selected.frame.contentWindow!, + width: 300, + height: 250, + }) + ).toBe(false); + } finally { + selected.wrapper.remove(); + sibling.wrapper.remove(); + } + }); + + it('rejects invalid dimensions and non-ordinary, expanded, detached, or replaced shells atomically', () => { + const cases: Array<(shell: ReturnType) => void> = [ + ({ wrapper }) => { + wrapper.style.width = '2px'; + }, + ({ frame }) => { + frame.style.position = 'fixed'; + }, + ({ wrapper }) => { + wrapper.style.position = 'sticky'; + }, + ({ wrapper }) => { + wrapper.setAttribute('data-anchor-status', 'displayed'); + }, + ({ frame }) => { + frame.remove(); + }, + ]; + + for (const mutate of cases) { + const shell = collapsedShell(); + const source = shell.frame.contentWindow!; + mutate(shell); + try { + expect(resizeCollapsedPucShell({ source, width: 300, height: 250 })).toBe(false); + expect(shell.wrapper.style.height).toBe('1px'); + expect(shell.frame.style.height).toBe('1px'); + } finally { + shell.wrapper.remove(); + } + } + + const invalid = collapsedShell(); + try { + expect( + resizeCollapsedPucShell({ + source: invalid.frame.contentWindow!, + width: Number.NaN, + height: 250, + }) + ).toBe(false); + expect(invalid.frame.style.width).toBe('1px'); + expect(invalid.wrapper.style.width).toBe('1px'); + } finally { + invalid.wrapper.remove(); + } + }); + + it('rejects a collapsed ordinary wrapper nested inside an anchor shell', () => { + const shell = collapsedShell(); + const anchor = document.createElement('a'); + shell.wrapper.replaceWith(anchor); + anchor.appendChild(shell.wrapper); + + try { + expect( + resizeCollapsedPucShell({ + source: shell.frame.contentWindow!, + width: 300, + height: 250, + }) + ).toBe(false); + expect(shell.frame.style.width).toBe('1px'); + expect(shell.wrapper.style.width).toBe('1px'); + } finally { + anchor.remove(); + } + }); +}); + +function prepareRenderSource(candidate: unknown) { + if (candidate === ADM_SOURCE) return ADM_SOURCE; + if (candidate === APS_SOURCE) return APS_SOURCE; + if (candidate === DIRECT_APS_SOURCE) return DIRECT_APS_SOURCE; + return undefined; +} + +const RESERVATION_ID = 'r1_0000000000000000000000'; +const attemptReservations = new WeakMap(); +const matrixClaims = new WeakMap(); + +function reservations(): ReservationService { + return createReservationService({ now: () => 0, prepareRenderSource }); +} + +type TestOwner = RenderAttemptScope & { + admitClaimedContext(context: WinnerContext): void; + disposeFromNavigation(): void; +}; + +function owner( + id = ATTEMPT_ONE, + slot = 'fictional-slot', + navigationGeneration = Object.freeze({}) +): TestOwner { + let current = true; + let disposed = false; + let winnerContext: WinnerContext | undefined; + const callbacks: Array<() => void> = []; + const controller = new AbortController(); + const scope = { + id, + slot, + generation: Object.freeze({}), + navigationGeneration, + interfaces: Object.freeze({}), + get disposed() { + return disposed; + }, + get signal() { + return controller.signal; + }, + get winnerContext() { + return winnerContext; + }, + capture: + (callback: (...arguments_: Arguments) => unknown) => + (...arguments_: Arguments): boolean => { + if (!scope.isCurrent()) return false; + callback(...arguments_); + return true; + }, + isCurrent: () => current && !disposed, + prepareWinnerContext: (context: WinnerContext) => { + if (!scope.isCurrent()) return undefined; + const previous = winnerContext; + if (previous !== undefined && previous !== context) return undefined; + let committed = false; + return Object.freeze({ + commit: () => { + if (committed) return winnerContext === context; + if (!scope.isCurrent() || winnerContext !== previous) return false; + winnerContext = context; + committed = true; + return true; + }, + rollback: () => { + if (committed && previous === undefined && winnerContext === context) { + winnerContext = undefined; + } + committed = false; + return winnerContext === previous; + }, + }); + }, + onDispose: (_kind: string, callback: () => void) => { + callbacks.push(callback); + }, + dispose: () => { + if (disposed) return; + disposed = true; + controller.abort(); + for (let index = callbacks.length - 1; index >= 0; index -= 1) callbacks[index]?.(); + }, + disposeFromNavigation: () => { + current = false; + scope.dispose(); + }, + admitClaimedContext: (context: WinnerContext) => { + winnerContext = context; + }, + } satisfies TestOwner; + return scope; +} + +function artifact( + render: Pick, + kind: CommittedRenderArtifact['kind'] = 'direct_iframe' +): CommittedRenderArtifact & { dispose: ReturnType } { + return Object.freeze({ + kind, + attemptId: render.id, + slot: render.slot, + navigationGeneration: render.navigationGeneration, + dispose: vi.fn(), + }); +} + +function attempt( + scope = owner(), + options: Partial[0]> = {} +): RenderAttempt { + const reservationService = options.reservations ?? reservations(); + const result = createRenderAttempt({ + artifacts: options.artifacts ?? createCommittedArtifactStore(), + owner: scope, + prepareRenderSource: options.prepareRenderSource ?? prepareRenderSource, + reservations: reservationService, + ...(options.parentAttemptId === undefined ? {} : { parentAttemptId: options.parentAttemptId }), + ...(options.publishDiagnostics === undefined + ? {} + : { publishDiagnostics: options.publishDiagnostics }), + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + }); + expect(result).toMatchObject({ ok: true }); + if (!result.ok) throw new Error('should create an attempt'); + attemptReservations.set(result.value, reservationService); + return result.value; +} + +function rendererPort() { + return Object.freeze({ close: vi.fn() }); +} + +function browserMessagePort() { + const listeners = new Set<(event: unknown) => void>(); + const messageErrorListeners = new Set<(event: unknown) => void>(); + return { + addEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + (type === 'messageerror' ? messageErrorListeners : listeners).add(listener); + }), + close: vi.fn(), + emit(data: unknown): void { + for (const listener of listeners) listener({ data }); + }, + emitError(): void { + for (const listener of messageErrorListeners) listener({}); + }, + postMessage: vi.fn(), + removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + (type === 'messageerror' ? messageErrorListeners : listeners).delete(listener); + }), + start: vi.fn(), + }; +} + +describe('renderer nonce registry', () => { + it('admits exactly 256 active bindings and refuses the 257th without drawing', () => { + let draw = 0; + const mintNonce = vi.fn(() => + Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }) + ); + const registry = createRendererNonceRegistry({ mintNonce }); + + for (let index = 0; index < 257; index += 1) { + const render = attempt(owner(indexedAttemptId(index), `slot-${index}`)); + const issued = registry.issue({ + attempt: render, + source: Object.freeze({ index }), + port: rendererPort(), + }); + if (index < 256) { + expect(issued).toEqual({ ok: true, nonce: indexedRendererNonce(index) }); + expect(registry.snapshotForTest()).toMatchObject({ + bindings: index + 1, + liveNonces: index + 1, + }); + } else { + expect(issued).toEqual({ ok: false, reason: 'capability_registry_full' }); + } + } + expect(mintNonce).toHaveBeenCalledTimes(256); + }); + + it('uses eight total collision draws and contains identity-source failure', () => { + const nonce = indexedRendererNonce(7); + const collisionMint = vi.fn(() => Object.freeze({ ok: true as const, value: nonce })); + const registry = createRendererNonceRegistry({ mintNonce: collisionMint }); + const first = attempt(owner(indexedAttemptId(1), 'slot-1')); + const second = attempt(owner(indexedAttemptId(2), 'slot-2')); + expect( + registry.issue({ attempt: first, source: Object.freeze({}), port: rendererPort() }) + ).toEqual({ ok: true, nonce }); + expect( + registry.issue({ attempt: second, source: Object.freeze({}), port: rendererPort() }) + ).toEqual({ ok: false, reason: 'identity_generation_failed' }); + expect(collisionMint).toHaveBeenCalledTimes(9); + + const failedMint = vi.fn(() => + Object.freeze({ ok: false as const, reason: 'identity_generation_failed' as const }) + ); + const failedRegistry = createRendererNonceRegistry({ mintNonce: failedMint }); + expect( + failedRegistry.issue({ + attempt: attempt(owner(indexedAttemptId(3), 'slot-3')), + source: Object.freeze({}), + port: rendererPort(), + }) + ).toEqual({ ok: false, reason: 'identity_generation_failed' }); + expect(failedMint).toHaveBeenCalledOnce(); + }); + + it.each([ + ['undefined', () => undefined], + ['null', () => null], + ['primitive', () => 1], + [ + 'accessor', + () => + Object.freeze( + Object.defineProperties( + {}, + { + ok: { + enumerable: true, + get: () => { + throw new Error('sensitive issuer result'); + }, + }, + value: { enumerable: true, value: indexedRendererNonce(1) }, + } + ) + ), + ], + [ + 'proxy', + () => + new Proxy(Object.freeze({ ok: true, value: indexedRendererNonce(1) }), { + ownKeys: () => { + throw new Error('sensitive issuer proxy'); + }, + }), + ], + [ + 'malformed success', + () => Object.freeze({ ok: true, value: indexedRendererNonce(1), unexpected: true }), + ], + ['malformed failure', () => Object.freeze({ ok: false, reason: 'different_failure' })], + ])('fails closed for a hostile %s issuer result', (_label, hostileResult) => { + const registry = createRendererNonceRegistry({ + mintNonce: hostileResult as never, + }); + let result: unknown; + expect(() => { + result = registry.issue({ + attempt: attempt(owner(indexedAttemptId(9), 'slot-9')), + source: Object.freeze({}), + port: rendererPort(), + }); + }).not.toThrow(); + expect(result).toEqual({ ok: false, reason: 'identity_generation_failed' }); + }); + + it('consumes once only for the exact nonce, source, port, attempt, and generation', () => { + const nonce = indexedRendererNonce(1); + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const render = attempt(owner(indexedAttemptId(1), 'slot-1')); + const other = attempt(owner(indexedAttemptId(2), 'slot-2')); + const source = Object.freeze({}); + const port = rendererPort(); + expect(registry.issue({ attempt: render, source, port })).toEqual({ ok: true, nonce }); + + expect( + registry.consume({ + nonce: indexedRendererNonce(2), + attempt: render, + generation: render.generation, + source, + port, + }) + ).toBe(false); + expect( + registry.consume({ + nonce, + attempt: other, + generation: render.generation, + source, + port, + }) + ).toBe(false); + expect( + registry.consume({ + nonce, + attempt: render, + generation: Object.freeze({}), + source, + port, + }) + ).toBe(false); + expect( + registry.consume({ + nonce, + attempt: render, + generation: render.generation, + source: Object.freeze({}), + port, + }) + ).toBe(false); + expect( + registry.consume({ + nonce, + attempt: render, + generation: render.generation, + source, + port: rendererPort(), + }) + ).toBe(false); + const exact = { nonce, attempt: render, generation: render.generation, source, port }; + expect(registry.consume(exact)).toBe(true); + expect(registry.consume(exact)).toBe(false); + expect(registry.snapshotForTest()).toMatchObject({ bindings: 1, liveNonces: 0 }); + expect(port.close).not.toHaveBeenCalled(); + }); + + it('issues before insertion and binds exactly one later renderer source before consumption', () => { + const nonce = indexedRendererNonce(1); + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const render = attempt(owner(indexedAttemptId(1), 'slot-1')); + const other = attempt(owner(indexedAttemptId(2), 'slot-2')); + const port = rendererPort(); + const source = Object.freeze({ window: true }); + const wrongSource = Object.freeze({ window: false }); + expect(registry.issue({ attempt: render, port })).toEqual({ ok: true, nonce }); + const exact = Object.freeze({ + nonce, + attempt: render, + generation: render.generation, + source, + port, + }); + + expect(registry.consume(exact)).toBe(false); + expect(registry.bindSource(Object.freeze({ ...exact, nonce: indexedRendererNonce(2) }))).toBe( + false + ); + expect(registry.bindSource(Object.freeze({ ...exact, attempt: other }))).toBe(false); + expect(registry.bindSource(Object.freeze({ ...exact, generation: Object.freeze({}) }))).toBe( + false + ); + expect(registry.bindSource(Object.freeze({ ...exact, source: wrongSource }))).toBe(true); + expect(registry.bindSource(exact)).toBe(false); + expect(registry.consume(exact)).toBe(false); + expect(registry.consume(Object.freeze({ ...exact, source: wrongSource }))).toBe(true); + expect(registry.consume(Object.freeze({ ...exact, source: wrongSource }))).toBe(false); + }); + + it('cannot bind a deferred renderer source after attempt or registry disposal', () => { + let draw = 0; + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + const settled = attempt(owner(indexedAttemptId(1), 'slot-1')); + const settledPort = rendererPort(); + const settledIssue = registry.issue({ attempt: settled, port: settledPort }); + if (!settledIssue.ok) throw new Error('Expected deferred binding'); + expect(settled.fail('internal_error')).toBe(true); + expect( + registry.bindSource( + Object.freeze({ + nonce: settledIssue.nonce, + attempt: settled, + generation: settled.generation, + source: Object.freeze({}), + port: settledPort, + }) + ) + ).toBe(false); + expect(settledPort.close).toHaveBeenCalledOnce(); + + const disposed = attempt(owner(indexedAttemptId(2), 'slot-2')); + const disposedPort = rendererPort(); + const disposedIssue = registry.issue({ attempt: disposed, port: disposedPort }); + if (!disposedIssue.ok) throw new Error('Expected deferred binding'); + registry.dispose(); + expect( + registry.bindSource( + Object.freeze({ + nonce: disposedIssue.nonce, + attempt: disposed, + generation: disposed.generation, + source: Object.freeze({}), + port: disposedPort, + }) + ) + ).toBe(false); + expect(disposedPort.close).toHaveBeenCalledOnce(); + }); + + it('lets exactly one nested deferred source bind win before a hostile outer replay', () => { + const nonce = indexedRendererNonce(1); + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const render = attempt(); + const port = rendererPort(); + const nestedSource = Object.freeze({ nested: true }); + const outerSource = Object.freeze({ outer: true }); + expect(registry.issue({ attempt: render, port })).toEqual({ ok: true, nonce }); + const nestedExpectation = Object.freeze({ + nonce, + attempt: render, + generation: render.generation, + source: nestedSource, + port, + }); + const outerExpectation = Object.freeze({ + nonce, + attempt: render, + generation: render.generation, + source: outerSource, + port, + }); + let nested: boolean | undefined; + let reentered = false; + const replay = new Proxy(outerExpectation, { + ownKeys: (target) => { + if (!reentered) { + reentered = true; + nested = registry.bindSource(nestedExpectation); + } + return Reflect.ownKeys(target); + }, + }); + + expect(registry.bindSource(replay)).toBe(false); + expect(nested).toBe(true); + expect(registry.consume(outerExpectation)).toBe(false); + expect(registry.consume(nestedExpectation)).toBe(true); + }); + + it('rejects cross-attempt retained-port reuse without taking failed-issue ownership', () => { + let draw = 0; + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + const port = rendererPort(); + const first = attempt(owner(indexedAttemptId(1), 'slot-1')); + const second = attempt(owner(indexedAttemptId(2), 'slot-2')); + expect(registry.issue({ attempt: first, source: Object.freeze({}), port })).toMatchObject({ + ok: true, + }); + expect(registry.issue({ attempt: second, source: Object.freeze({}), port })).toEqual({ + ok: false, + reason: 'invalid_attempt', + }); + expect(port.close).not.toHaveBeenCalled(); + expect(second.fail('internal_error')).toBe(true); + expect(port.close).not.toHaveBeenCalled(); + expect(first.fail('internal_error')).toBe(true); + expect(port.close).toHaveBeenCalledOnce(); + expect( + registry.issue({ + attempt: attempt(owner(indexedAttemptId(3), 'slot-3')), + source: Object.freeze({}), + port, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + registry.dispose(); + expect(port.close).toHaveBeenCalledOnce(); + }); + + it('retires a transferred port before close can reenter issuance', () => { + let draw = 0; + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + const first = attempt(owner(indexedAttemptId(1), 'slot-1')); + const second = attempt(owner(indexedAttemptId(2), 'slot-2')); + let nested: unknown; + const port = Object.freeze({ + close: vi.fn(() => { + nested = registry.issue({ attempt: second, source: Object.freeze({}), port }); + }), + }); + expect(registry.issue({ attempt: first, source: Object.freeze({}), port })).toMatchObject({ + ok: true, + }); + expect(first.fail('internal_error')).toBe(true); + expect(nested).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(port.close).toHaveBeenCalledOnce(); + }); + + it('makes branded settlement registration and revalidation intrinsic under prototype mutation', () => { + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + const render = attempt(owner(indexedAttemptId(1), 'slot-1')); + let closes = 0; + const port = Object.freeze({ + close: () => { + closes += 1; + }, + }); + const nativePush = Array.prototype.push; + const nativeSlice = Array.prototype.slice; + let poisonCalls = 0; + const push = vi.spyOn(Array.prototype, 'push').mockImplementation(function ( + this: unknown[], + ...values + ) { + poisonCalls += 1; + Reflect.apply(nativePush, this, values); + throw new Error('hostile observer registration'); + }); + let sliceCalls = 0; + const slice = vi.spyOn(Array.prototype, 'slice').mockImplementation(function ( + this: unknown[], + start?: number, + end?: number + ) { + sliceCalls += 1; + const result = Reflect.apply(nativeSlice, this, [start, end]); + if (sliceCalls >= 4) throw new Error('hostile post-registration snapshot'); + return result; + }); + let issued: unknown; + try { + issued = registry.issue({ attempt: render, source: Object.freeze({}), port }); + } finally { + slice.mockRestore(); + push.mockRestore(); + } + expect(poisonCalls).toBe(0); + expect(sliceCalls).toBe(0); + expect(issued).toEqual({ ok: true, nonce: indexedRendererNonce(1) }); + expect(closes).toBe(0); + expect(render.fail('internal_error')).toBe(true); + expect(closes).toBe(1); + }); + + it('drains terminal observers intrinsically before prototype splice can throw', () => { + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + const render = attempt(owner(indexedAttemptId(1), 'slot-1')); + let closes = 0; + const port = Object.freeze({ + close: () => { + closes += 1; + }, + }); + expect(registry.issue({ attempt: render, source: Object.freeze({}), port })).toMatchObject({ + ok: true, + }); + const nativeSplice = Array.prototype.splice; + let spliceCalls = 0; + const splice = vi.spyOn(Array.prototype, 'splice').mockImplementation(function ( + this: unknown[], + start: number, + deleteCount?: number + ) { + spliceCalls += 1; + Reflect.apply(nativeSplice, this, [start, deleteCount]); + throw new Error('hostile terminal observer drain'); + }); + let iteratorCalls = 0; + const iterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value: () => { + iteratorCalls += 1; + throw new Error('hostile terminal observer iteration'); + }, + }); + let settled: boolean | undefined; + let thrown: unknown; + try { + settled = render.fail('internal_error'); + } catch (error) { + thrown = error; + } finally { + if (iterator) Object.defineProperty(Array.prototype, Symbol.iterator, iterator); + splice.mockRestore(); + } + expect(thrown).toBeUndefined(); + expect(settled).toBe(true); + expect(spliceCalls).toBe(0); + expect(iteratorCalls).toBe(0); + expect(closes).toBe(1); + }); + + it('binds pending and live issuance to the exact issued attempt generation', () => { + let draw = 0; + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + const sharedOwner = owner(indexedAttemptId(1), 'slot-1'); + const first = attempt(sharedOwner); + const second = attempt(sharedOwner); + const secondPort = rendererPort(); + expect( + registry.issue({ attempt: first, source: Object.freeze({}), port: rendererPort() }) + ).toMatchObject({ ok: true }); + expect( + registry.issue({ attempt: second, source: Object.freeze({}), port: secondPort }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(secondPort.close).not.toHaveBeenCalled(); + + const nestedOwner = owner(indexedAttemptId(2), 'slot-2'); + const outer = attempt(nestedOwner); + const inner = attempt(nestedOwner); + const innerPort = rendererPort(); + let nested: unknown; + let recurse = true; + const reentrantRegistry = createRendererNonceRegistry({ + mintNonce: () => { + if (recurse) { + recurse = false; + nested = reentrantRegistry.issue({ + attempt: inner, + source: Object.freeze({}), + port: innerPort, + }); + } + return Object.freeze({ ok: true as const, value: indexedRendererNonce(9) }); + }, + }); + expect( + reentrantRegistry.issue({ + attempt: outer, + source: Object.freeze({}), + port: rendererPort(), + }) + ).toMatchObject({ ok: true }); + expect(nested).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(innerPort.close).not.toHaveBeenCalled(); + }); + + it('cannot publish after the issuer reentrantly disposes the registry', () => { + const port = rendererPort(); + const registry = createRendererNonceRegistry({ + mintNonce: () => { + registry.dispose(); + return Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }); + }, + }); + const issued = registry.issue({ + attempt: attempt(owner(indexedAttemptId(1), 'slot-1')), + source: Object.freeze({}), + port, + }); + expect(issued).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(issued).not.toHaveProperty('nonce'); + expect(port.close).not.toHaveBeenCalled(); + expect(registry.snapshotForTest()).toEqual({ + bindings: 0, + disposed: true, + liveNonces: 0, + }); + }); + + it('reserves attempt and capacity before invoking a reentrant issuer', () => { + let draw = 0; + let reenter: (() => void) | undefined; + const registry = createRendererNonceRegistry({ + mintNonce: () => { + const callback = reenter; + reenter = undefined; + callback?.(); + return Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }); + }, + }); + + for (let index = 0; index < 255; index += 1) { + expect( + registry.issue({ + attempt: attempt(owner(indexedAttemptId(index), `slot-${index}`)), + source: Object.freeze({}), + port: rendererPort(), + }) + ).toMatchObject({ ok: true }); + } + const outerAttempt = attempt(owner(indexedAttemptId(255), 'slot-255')); + const innerAttempt = attempt(owner(indexedAttemptId(256), 'slot-256')); + let nestedCapacity: unknown; + reenter = () => { + nestedCapacity = registry.issue({ + attempt: innerAttempt, + source: Object.freeze({}), + port: rendererPort(), + }); + }; + expect( + registry.issue({ + attempt: outerAttempt, + source: Object.freeze({}), + port: rendererPort(), + }) + ).toMatchObject({ ok: true }); + expect(nestedCapacity).toEqual({ ok: false, reason: 'capability_registry_full' }); + expect(registry.snapshotForTest()).toMatchObject({ bindings: 256, liveNonces: 256 }); + + const sameAttempt = attempt(owner(indexedAttemptId(999), 'slot-999')); + const sameInput = { + attempt: sameAttempt, + source: Object.freeze({}), + port: rendererPort(), + }; + let nestedSameAttempt: unknown; + let recurse = true; + const sameAttemptRegistry = createRendererNonceRegistry({ + mintNonce: () => { + if (recurse) { + recurse = false; + nestedSameAttempt = sameAttemptRegistry.issue(sameInput); + } + return Object.freeze({ ok: true as const, value: indexedRendererNonce(998) }); + }, + }); + expect(sameAttemptRegistry.issue(sameInput)).toMatchObject({ ok: true }); + expect(nestedSameAttempt).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(sameAttemptRegistry.snapshotForTest()).toMatchObject({ bindings: 1, liveNonces: 1 }); + }); + + it('lets exactly one nested exact consume win before a hostile outer replay', () => { + const nonce = indexedRendererNonce(1); + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const render = attempt(owner(indexedAttemptId(1), 'slot-1')); + const source = Object.freeze({}); + const port = rendererPort(); + expect(registry.issue({ attempt: render, source, port })).toEqual({ ok: true, nonce }); + const exact = Object.freeze({ + nonce, + attempt: render, + generation: render.generation, + source, + port, + }); + let nested: boolean | undefined; + let reentered = false; + const replay = new Proxy(exact, { + ownKeys: (target) => { + if (!reentered) { + reentered = true; + nested = registry.consume(exact); + } + return Reflect.ownKeys(target); + }, + }); + + expect(registry.consume(replay)).toBe(false); + expect(nested).toBe(true); + expect(registry.consume(exact)).toBe(false); + }); + + it('closes and removes attempt-owned bindings on settlement with no nonce history', () => { + const nonce = indexedRendererNonce(1); + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const first = attempt(owner(indexedAttemptId(1), 'slot-1')); + const firstPort = rendererPort(); + const firstSource = Object.freeze({}); + expect(registry.issue({ attempt: first, source: firstSource, port: firstPort })).toEqual({ + ok: true, + nonce, + }); + expect( + registry.consume({ + nonce, + attempt: first, + generation: first.generation, + source: firstSource, + port: firstPort, + }) + ).toBe(true); + expect( + registry.issue({ attempt: first, source: Object.freeze({}), port: rendererPort() }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(first.fail('internal_error')).toBe(true); + expect(first.fail('internal_error')).toBe(false); + expect(firstPort.close).toHaveBeenCalledOnce(); + expect(registry.snapshotForTest()).toEqual({ + bindings: 0, + disposed: false, + liveNonces: 0, + }); + + const second = attempt(owner(indexedAttemptId(2), 'slot-2')); + expect( + registry.issue({ attempt: second, source: Object.freeze({}), port: rendererPort() }) + ).toEqual({ ok: true, nonce }); + expect( + registry.issue({ attempt: second, source: Object.freeze({}), port: rendererPort() }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + }); + + it('disposes live and consumed runtime bindings exactly once and remains terminal', () => { + let draw = 0; + const registry = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + const live = attempt(owner(indexedAttemptId(1), 'slot-1')); + const consumed = attempt(owner(indexedAttemptId(2), 'slot-2')); + const liveSource = Object.freeze({ live: true }); + const consumedSource = Object.freeze({ consumed: true }); + let liveCloses = 0; + let consumedCloses = 0; + const livePort = Object.freeze({ close: () => (liveCloses += 1) }); + const consumedPort = Object.freeze({ close: () => (consumedCloses += 1) }); + const liveIssue = registry.issue({ attempt: live, source: liveSource, port: livePort }); + const consumedIssue = registry.issue({ + attempt: consumed, + source: consumedSource, + port: consumedPort, + }); + if (!liveIssue.ok || !consumedIssue.ok) throw new Error('Expected nonce bindings'); + const consumedExpectation = Object.freeze({ + nonce: consumedIssue.nonce, + attempt: consumed, + generation: consumed.generation, + source: consumedSource, + port: consumedPort, + }); + expect(registry.consume(consumedExpectation)).toBe(true); + + const iterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + let iteratorCalls = 0; + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value: () => { + iteratorCalls += 1; + throw new Error('hostile registry disposal iteration'); + }, + }); + let disposeError: unknown; + try { + registry.dispose(); + } catch (error) { + disposeError = error; + } finally { + if (iterator) Object.defineProperty(Array.prototype, Symbol.iterator, iterator); + } + expect(disposeError).toBeUndefined(); + expect(iteratorCalls).toBe(0); + expect(liveCloses).toBe(1); + expect(consumedCloses).toBe(1); + expect(registry.snapshotForTest()).toEqual({ + bindings: 0, + disposed: true, + liveNonces: 0, + }); + registry.dispose(); + expect(live.fail('internal_error')).toBe(true); + expect(consumed.fail('internal_error')).toBe(true); + expect(liveCloses).toBe(1); + expect(consumedCloses).toBe(1); + expect(registry.consume(consumedExpectation)).toBe(false); + + const rejectedPort = rendererPort(); + expect( + registry.issue({ + attempt: attempt(owner(indexedAttemptId(3), 'slot-3')), + source: Object.freeze({}), + port: rejectedPort, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(rejectedPort.close).not.toHaveBeenCalled(); + }); +}); + +describe('direct APS attempt rendering', () => { + it('accepts no document-port traffic before the native load handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const nonce = indexedRendererNonce(1); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(transferredRaw.postMessage).not.toHaveBeenCalled(); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_document', + }); + + const frame = document.querySelector('#fictional-slot iframe')!; + frame.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('uses captured native creation instead of a connected iframe returned by a hostile factory', () => { + document.body.innerHTML = + '
'; + const publisherContainer = document.getElementById('publisher-owned')!; + const publisherFrame = publisherContainer.querySelector('iframe')!; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const createElement = vi + .spyOn(document, 'createElement') + .mockReturnValueOnce(publisherFrame as HTMLIFrameElement); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry(); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(createElement).not.toHaveBeenCalled(); + expect(publisherFrame.parentNode).toBe(publisherContainer); + expect(publisherFrame.title).toBe('publisher frame'); + expect(document.querySelector('#fictional-slot iframe')).not.toBe(publisherFrame); + expect(render.cancel('caller_aborted')).toBe(true); + expect(publisherFrame.parentNode).toBe(publisherContainer); + } finally { + createElement.mockRestore(); + nonces.dispose(); + document.body.innerHTML = ''; + } + }); + + it('ignores a detached poisoned iframe and keeps native source/removal authority', () => { + document.body.innerHTML = + '
'; + const unrelated = document.getElementById('unrelated-publisher-dom')!; + const poisoned = document.createElement('iframe'); + poisoned.title = 'publisher detached frame'; + const forgedSource = Object.freeze({ postMessage: vi.fn() }); + Object.defineProperty(poisoned, 'contentWindow', { + configurable: true, + get: () => forgedSource, + }); + Object.defineProperty(poisoned, 'src', { + configurable: true, + get: () => 'https://publisher.example/lie', + set: vi.fn(), + }); + poisoned.getAttribute = vi.fn(() => 'https://publisher.example/lie'); + poisoned.addEventListener = vi.fn(() => { + throw new Error('publisher listener'); + }); + poisoned.remove = vi.fn(() => unrelated.remove()); + const createElement = vi.spyOn(document, 'createElement').mockReturnValueOnce(poisoned); + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(createElement).not.toHaveBeenCalled(); + expect(poisoned.parentNode).toBeNull(); + expect(poisoned.title).toBe('publisher detached frame'); + const exactFrame = document.querySelector('#fictional-slot iframe')!; + const exactSource = exactFrame.contentWindow!; + const exactPost = vi.spyOn(exactSource, 'postMessage'); + exactFrame.dispatchEvent(new Event('load')); + expect(exactPost).toHaveBeenCalledOnce(); + expect(forgedSource.postMessage).not.toHaveBeenCalled(); + expect(poisoned.remove).not.toHaveBeenCalled(); + expect(unrelated.isConnected).toBe(true); + expect(render.cancel('caller_aborted')).toBe(true); + expect(unrelated.isConnected).toBe(true); + } finally { + createElement.mockRestore(); + nonces.dispose(); + document.body.innerHTML = ''; + } + }); + + it('disposes detached setup resources when listener installation throws before staging', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retained = Object.freeze({ + close: vi.fn(), + listen: vi.fn(() => { + throw new Error('hostile retained listener'); + }), + post: vi.fn(), + }); + const transferred = Object.freeze({ + close: vi.fn(), + listen: vi.fn(), + post: vi.fn(), + }); + const messaging = Object.freeze({ + createChannel: () => Object.freeze({ retained, transferred }), + postWindow: vi.fn(), + installCaptureListener: vi.fn(), + parseProtocolMessage: vi.fn(), + extractTransferredPorts: vi.fn(), + }) as unknown as MessagingAdapter; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).toHaveBeenCalledOnce(); + expect(document.querySelector('iframe')).toBeNull(); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('does not insert after a pre-append cancellation returns through setup', () => { + document.body.innerHTML = '
'; + const container = document.getElementById('fictional-slot')!; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retained = Object.freeze({ + close: vi.fn(), + listen: vi.fn(() => { + render.cancel('caller_aborted'); + return () => undefined; + }), + post: vi.fn(), + }); + const transferred = Object.freeze({ + close: vi.fn(), + listen: vi.fn(), + post: vi.fn(), + }); + const messaging = Object.freeze({ + createChannel: () => Object.freeze({ retained, transferred }), + postWindow: vi.fn(), + installCaptureListener: vi.fn(), + parseProtocolMessage: vi.fn(), + extractTransferredPorts: vi.fn(), + }) as unknown as MessagingAdapter; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + expect(observer.takeRecords()).toHaveLength(0); + expect(container.children).toHaveLength(0); + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).toHaveBeenCalledOnce(); + observer.disconnect(); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('binds the inserted renderer window and accepts only exact document-port completion', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
placeholder
'; + const artifacts = createCommittedArtifactStore(); + const render = attempt(owner(), { artifacts }); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const container = document.getElementById('fictional-slot')!; + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const iframe = container.querySelector('iframe'); + expect(iframe).not.toBeNull(); + expect(iframe?.src).toBe( + `${new URL(APS_RENDERER_V1_PATH, window.location.origin).href}#tsaps=${nonce}` + ); + expect(iframe?.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); + expect(iframe?.width).toBe(String(DIRECT_APS_SOURCE.width)); + expect(iframe?.height).toBe(String(DIRECT_APS_SOURCE.height)); + expect(iframe?.style.width).toBe(`${DIRECT_APS_SOURCE.width}px`); + expect(iframe?.style.height).toBe(`${DIRECT_APS_SOURCE.height}px`); + expect(render.snapshot().state).toBe('waiting_for_document'); + + const target = iframe?.contentWindow; + if (!iframe || !target) throw new Error('Expected renderer window'); + const postMessage = vi.spyOn(target, 'postMessage'); + iframe.dispatchEvent(new Event('load')); + expect(postMessage).toHaveBeenCalledWith( + { + version: 1, + nonce, + publisherOrigin: window.location.origin, + renderer: DIRECT_APS_SOURCE, + }, + '*', + [transferredRaw] + ); + + retainedRaw.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: indexedRendererNonce(2), + }); + expect(render.snapshot().state).toBe('waiting_for_document'); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + expect(render.snapshot().state).toBe('waiting_for_aps_completion'); + retainedRaw.emit({ message: 'TS APS Runner Loaded', version: 1, nonce }); + expect(render.snapshot().outcome).toBeUndefined(); + expect(container.querySelector('span')).not.toBeNull(); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(container.querySelector('span')).toBeNull(); + retainedRaw.emit({ + message: 'TS APS Render Failed', + version: 1, + nonce, + reason: 'runner_failed', + }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(iframe.isConnected).toBe(true); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + } finally { + artifacts.dispose(); + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('maps document and APS completion deadlines through the attempt-owned timers', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const makeRender = (id: string, slot: string) => { + const render = attempt(owner(id, slot)); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + return { messaging, render, retainedRaw }; + }; + const first = makeRender(indexedAttemptId(1), 'document-slot'); + const second = makeRender(indexedAttemptId(2), 'runner-slot'); + let draw = 1; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: first.render, + container: document.getElementById('document-slot')!, + messaging: first.messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + vi.advanceTimersByTime(3_000); + expect(first.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(document.querySelector('#document-slot iframe')).toBeNull(); + + expect( + renderDirectApsAttempt({ + attempt: second.render, + container: document.getElementById('runner-slot')!, + messaging: second.messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const runnerFrame = document.querySelector('#runner-slot iframe')!; + runnerFrame.dispatchEvent(new Event('load')); + second.retainedRaw.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: indexedRendererNonce(2), + }); + second.retainedRaw.emit({ + message: 'TS APS Runner Loaded', + version: 1, + nonce: indexedRendererNonce(2), + }); + vi.advanceTimersByTime(10_000); + expect(second.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'runner_failed', + }); + expect(runnerFrame.isConnected).toBe(false); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it.each([ + ['descriptor_invalid', 'winner_not_renderable'], + ['runner_no_load', 'runner_no_load'], + ['runner_failed', 'runner_failed'], + ] as const)('maps static renderer %s to %s', (rendererReason, attemptReason) => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + document.querySelector('iframe')?.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ + message: 'TS APS Render Failed', + version: 1, + nonce, + reason: rendererReason, + }); + expect(render.snapshot().outcome).toEqual({ outcome: 'failed', reason: attemptReason }); + expect(document.querySelector('iframe')).toBeNull(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('removes and retires the pending frame and channel when caller cancellation wins', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = document.querySelector('iframe')!; + expect(render.cancel('caller_aborted')).toBe(true); + expect(frame.isConnected).toBe(false); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + frame.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('cannot accept a renderer frame removed before its load handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = document.querySelector('iframe')!; + frame.remove(); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('cannot accept a renderer whose container ancestor is removed before handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = + '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const container = document.getElementById('fictional-slot')!; + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe')!; + const target = frame.contentWindow!; + const postMessage = vi.spyOn(target, 'postMessage'); + document.getElementById('publisher-region')!.remove(); + expect(frame.parentNode).toBe(container); + expect(frame.isConnected).toBe(false); + frame.dispatchEvent(new Event('load')); + expect(postMessage).not.toHaveBeenCalled(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('rejects a same-node src navigation before handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = ''; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + + const navigationRender = attempt(owner(indexedAttemptId(1), 'navigation-slot')); + expect(navigationRender.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const navigationRetained = browserMessagePort(); + const navigationTransferred = browserMessagePort(); + const navigationMessaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = navigationRetained; + readonly port2 = navigationTransferred; + }, + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: navigationRender, + container: document.getElementById('navigation-slot')!, + messaging: navigationMessaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const navigationFrame = document.querySelector('#navigation-slot iframe')!; + const originalSource = navigationFrame.contentWindow!; + const postMessage = vi.spyOn(originalSource, 'postMessage'); + navigationFrame.src = 'https://attacker.example/replacement'; + navigationFrame.dispatchEvent(new Event('load')); + expect(postMessage).not.toHaveBeenCalled(); + expect(navigationRender.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('does not remove DOM installed reentrantly by accepted-settlement observers', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
placeholder
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const container = document.getElementById('fictional-slot')!; + expect( + render.onSettled((outcome) => { + if (outcome.outcome !== 'accepted') return; + const successor = document.createElement('div'); + successor.id = 'reentrant-successor'; + container.appendChild(successor); + }) + ).toBe(true); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + container.querySelector('iframe')?.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + const duringRenderSuccessor = document.createElement('div'); + duringRenderSuccessor.id = 'during-render-successor'; + container.appendChild(duringRenderSuccessor); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(container.querySelector('span')).toBeNull(); + expect(container.querySelector('#during-render-successor')).not.toBeNull(); + expect(container.querySelector('#reentrant-successor')).not.toBeNull(); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('anchors a synchronous document deadline after insertion and removes the exact frame', () => { + document.body.innerHTML = '
'; + const render = attempt(owner(), { + scheduler: Object.freeze({ + clear: vi.fn(), + set: (callback: () => void) => { + callback(); + return Object.freeze({}); + }, + }), + }); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + const container = document.getElementById('fictional-slot')!; + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + const mutations = observer.takeRecords(); + expect(mutations.some((mutation) => mutation.addedNodes.length === 1)).toBe(true); + expect(mutations.some((mutation) => mutation.removedNodes.length === 1)).toBe(true); + observer.disconnect(); + expect(container.querySelector('iframe')).toBeNull(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('contains a hostile nonce-issuer result and closes both unowned channel endpoints', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const realNonces = createRendererNonceRegistry(); + const nonces = Object.freeze({ + ...realNonces, + issue: () => + Object.freeze( + Object.defineProperty({}, 'ok', { + enumerable: true, + get: () => { + throw new Error('hostile nonce result'); + }, + }) + ), + }) as unknown as typeof realNonces; + let result: boolean | undefined; + let thrown: unknown; + try { + result = renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeUndefined(); + expect(result).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'identity_generation_failed', + }); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + expect(document.querySelector('iframe')).toBeNull(); + realNonces.dispose(); + document.body.innerHTML = ''; + }); + + it('rejects an invalid APS descriptor before creating a channel or mutating the DOM', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const channelConstructor = vi.fn(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: channelConstructor as never, + }); + const nonces = createRendererNonceRegistry(); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(channelConstructor).not.toHaveBeenCalled(); + expect(container.children).toHaveLength(0); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('rejects a publisher origin that is not the exact container document origin', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const channelConstructor = vi.fn(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: channelConstructor as never, + }); + const nonces = createRendererNonceRegistry(); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: 'https://foreign-publisher.example', + }) + ).toBe(false); + expect(channelConstructor).not.toHaveBeenCalled(); + expect(container.children).toHaveLength(0); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('allows HTTPS and loopback HTTP renderer origins but rejects production HTTP', () => { + expect(resolveApsRendererV1Url('https://publisher.example')).toBe( + 'https://publisher.example/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://localhost:8080')).toBe( + 'http://localhost:8080/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://127.0.0.1:8080')).toBe( + 'http://127.0.0.1:8080/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://[::1]:8080')).toBe( + 'http://[::1]:8080/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://publisher.example')).toBeUndefined(); + }); +}); + +function claimed( + render: RenderAttempt, + scope: TestOwner, + source: ReservationRenderSource +): Extract { + const service = attemptReservations.get(render); + if (!service) throw new Error('should own a reservation service'); + const registered = service.registerRender({ + reservationId: RESERVATION_ID, + slot: scope.slot, + navigation: { + generation: scope.navigationGeneration, + isCurrent: scope.isCurrent, + onDispose: scope.onDispose, + }, + attemptId: scope.id, + renderSource: source, + winnerContext: WINNER_CONTEXT, + }); + if (!registered.ok) throw new Error('should register a render reservation'); + const result = service.claim({ + reservationId: RESERVATION_ID, + slot: scope.slot, + navigationGeneration: scope.navigationGeneration, + attempt: scope, + pucSource: Object.freeze({}), + }); + if (!result.recognized || !result.claimed) throw new Error('should claim a reservation'); + return result; +} + +function slotOperation(options: SlotOperationOptions): SlotOperation { + const result = createSlotOperation(options); + expect(result).toMatchObject({ ok: true }); + if (!result.ok) throw new Error('should create a slot operation'); + return result.value; +} + +describe('direct ADM attempt rendering', () => { + it('accepts the exact intended srcdoc and promotes its iframe artifact', () => { + document.body.innerHTML = '
placeholder
'; + const artifacts = createCommittedArtifactStore(); + const render = attempt(owner(), { artifacts }); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'waiting_for_adm' }); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + expect(frame?.srcdoc).toContain('fictional creative'); + expect(frame?.hasAttribute('src')).toBe(false); + expect(container.querySelector('span')).not.toBeNull(); + + frame?.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(container.querySelector('span')).toBeNull(); + expect(container.querySelector('iframe')).toBe(frame); + expect(artifacts.current('fictional-slot')).toMatchObject({ + attemptId: render.id, + kind: 'direct_iframe', + }); + + artifacts.dispose(); + expect(frame?.isConnected).toBe(false); + document.body.innerHTML = ''; + }); + + it('commits predecessors despite settlement-time iterator poisoning', () => { + document.body.innerHTML = '
placeholder
'; + const container = document.getElementById('fictional-slot')!; + const predecessor = container.querySelector('span'); + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const nativeIterator = Array.prototype[Symbol.iterator]; + let ownedIteratorCalls = 0; + expect(iteratorDescriptor).toBeDefined(); + expect( + render.onSettled(() => { + Object.defineProperty(Array.prototype, Symbol.iterator, { + ...iteratorDescriptor, + value: function (this: unknown[]) { + const first = this[0]; + const isAttributeTuple = + this.length === 2 && + typeof first === 'string' && + (first === 'sandbox' || + first === 'referrerpolicy' || + first === 'width' || + first === 'height' || + first === 'scrolling' || + first === 'frameborder' || + first === 'marginwidth' || + first === 'marginheight' || + first === 'title' || + first === 'aria-label' || + first === 'style'); + const isAttributeList = + this.length === 11 && Array.isArray(first) && first[0] === 'sandbox'; + const isPredecessorSnapshot = this.length === 1 && first === predecessor; + if (isAttributeTuple || isAttributeList || isPredecessorSnapshot) { + ownedIteratorCalls += 1; + throw new Error('hostile owned-array iterator'); + } + return Reflect.apply(nativeIterator, this, []); + }, + }); + }) + ).toBe(true); + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + + try { + frame?.dispatchEvent(new Event('load')); + } finally { + if (iteratorDescriptor) { + Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); + } + } + + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(ownedIteratorCalls).toBe(0); + expect(predecessor?.isConnected).toBe(false); + expect(frame?.isConnected).toBe(true); + document.body.innerHTML = ''; + }); + + it.each(['property', 'append', 'current', 'activate'] as const)( + 'contains a throwing ADM handle %s phase and disposes its exact frame', + (phase) => { + document.body.innerHTML = '
'; + const container = document.getElementById('fictional-slot')!; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + let underlying: DirectAdmIframeHandle | undefined; + const prepareIframe: DirectAdmIframeConstructor = (options) => { + underlying = prepareAdmIframe(options); + if (!underlying) return undefined; + if (phase === 'property') { + return new Proxy(underlying, { + get(target, property, receiver) { + if (property === 'append') throw new Error('hostile append property'); + return Reflect.get(target, property, receiver); + }, + }); + } + return Object.freeze({ + frame: underlying.frame, + append: () => { + const appended = underlying?.append() === true; + if (phase === 'append') throw new Error('hostile append'); + return appended; + }, + activate: () => { + const activated = underlying?.activate() === true; + if (phase === 'activate') throw new Error('hostile activate'); + return activated; + }, + commit: () => underlying?.commit() === true, + current: () => { + if (phase === 'current') throw new Error('hostile current'); + return underlying?.current() === true; + }, + dispose: () => underlying?.dispose(), + }); + }; + + expect(() => + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe, + publisherOrigin: window.location.origin, + }) + ).not.toThrow(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + expect(container.querySelector('iframe')).toBeNull(); + expect(underlying?.append()).toBe(false); + document.body.innerHTML = ''; + } + ); + + it('rejects a non-publisher creative origin before inserting a frame', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: 'https://not-the-publisher.example', + }) + ).toBe(false); + expect(container.querySelector('iframe')).toBeNull(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + document.body.innerHTML = ''; + }); + + it('anchors the five-second deadline after inserting a complete srcdoc frame', () => { + document.body.innerHTML = '
'; + const render = attempt(owner(), { + scheduler: Object.freeze({ + clear: vi.fn(), + set: (callback: () => void) => { + callback(); + return Object.freeze({}); + }, + }), + }); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + const mutations = observer.takeRecords(); + const inserted = mutations + .flatMap((mutation) => [...mutation.addedNodes]) + .find((node): node is HTMLIFrameElement => node instanceof HTMLIFrameElement); + expect(inserted?.srcdoc).toContain('fictional creative'); + expect(inserted?.hasAttribute('src')).toBe(false); + expect(mutations.some((mutation) => mutation.removedNodes.length === 1)).toBe(true); + expect(container.querySelector('iframe')).toBeNull(); + observer.disconnect(); + document.body.innerHTML = ''; + }); + + it.each(['error', 'removed', 'replaced-srcdoc'] as const)( + 'fails and removes an unaccepted frame when it is %s', + (failure) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + if (!frame) throw new Error('should insert an ADM frame'); + + if (failure === 'error') frame.dispatchEvent(new Event('error')); + if (failure === 'removed') { + frame.remove(); + frame.dispatchEvent(new Event('load')); + } + if (failure === 'replaced-srcdoc') { + frame.srcdoc = 'publisher replacement'; + frame.dispatchEvent(new Event('load')); + } + + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + expect(frame.isConnected).toBe(false); + document.body.innerHTML = ''; + } + ); + + it.each([ + ['sandbox', (frame: HTMLIFrameElement) => frame.setAttribute('sandbox', 'allow-scripts')], + [ + 'referrer policy', + (frame: HTMLIFrameElement) => frame.setAttribute('referrerpolicy', 'unsafe-url'), + ], + ['dimensions', (frame: HTMLIFrameElement) => frame.setAttribute('width', '301')], + ['layout style', (frame: HTMLIFrameElement) => frame.style.setProperty('width', '301px')], + ] as const)( + 'refuses acceptance after publisher mutation of the exact %s contract', + (_field, mutate) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + if (!frame) throw new Error('should insert an ADM frame'); + + mutate(frame); + frame.dispatchEvent(new Event('load')); + + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + expect(frame.isConnected).toBe(false); + document.body.innerHTML = ''; + } + ); + + it('removes on cancellation and makes every late frame event inert', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + + expect(render.cancel('caller_aborted')).toBe(true); + expect(frame?.isConnected).toBe(false); + frame?.dispatchEvent(new Event('load')); + frame?.dispatchEvent(new Event('error')); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + document.body.innerHTML = ''; + }); + + it('rejects an admitted but malformed frozen ADM source before DOM mutation', () => { + const malformed = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '', + width: 0, + height: 250, + }); + document.body.innerHTML = '
'; + const render = attempt(owner(), { + prepareRenderSource: (candidate) => (candidate === malformed ? malformed : undefined), + }); + expect(render.admitDirectWinner(malformed, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(container.querySelector('iframe')).toBeNull(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + document.body.innerHTML = ''; + }); +}); + +describe('RenderAttempt state machine', () => { + it('implements the exact PUC APS state table and makes invalid/replay transitions inert', () => { + const scope = owner(); + const candidate = artifact(scope, 'puc'); + const render = attempt(scope); + const observed: RenderAttemptState[] = []; + + expect(render.beginGamClaim()).toBe(true); + expect(render.beginDirect()).toBe(false); + expect(render.admitClaimedWinner(claimed(render, scope, APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + expect(render.beginApsDocument(candidate)).toBe(true); + expect(render.beginAdm(candidate)).toBe(false); + expect(render.apsDocumentAccepted()).toBe(true); + expect(render.accept()).toBe(true); + expect(render.accept()).toBe(false); + expect(render.fail('runner_failed')).toBe(false); + expect(candidate.dispose).not.toHaveBeenCalled(); + + for (const state of render.snapshot().history) observed.push(state); + expect(observed).toEqual([ + 'created', + 'waiting_for_gam_and_claim', + 'waiting_for_owner', + 'waiting_for_insertion', + 'waiting_for_document', + 'waiting_for_aps_completion', + 'accepted', + ]); + expect(render.snapshot()).toMatchObject({ + state: 'accepted', + outcome: { outcome: 'accepted' }, + }); + expect(scope.disposed).toBe(true); + }); + + it('implements direct and owner ADM paths without permitting APS-only transitions', () => { + const directOwner = owner(); + const directArtifact = artifact(directOwner); + const direct = attempt(directOwner); + expect(direct.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(direct.beginDirect()).toBe(true); + expect(direct.beginAdm(directArtifact)).toBe(true); + expect(direct.apsDocumentAccepted()).toBe(false); + expect(direct.accept()).toBe(true); + + const pucOwner = owner(ATTEMPT_TWO); + const pucArtifact = artifact(pucOwner, 'puc'); + const puc = attempt(pucOwner); + expect(puc.beginGamClaim()).toBe(true); + expect(puc.admitClaimedWinner(claimed(puc, pucOwner, ADM_SOURCE))).toBe(true); + expect(puc.ownerClaimed()).toBe(true); + expect(puc.ownerRegistered()).toBe(true); + expect(puc.beginAdm(pucArtifact)).toBe(true); + expect(puc.accept()).toBe(true); + expect(puc.snapshot().history).toEqual([ + 'created', + 'waiting_for_gam_and_claim', + 'waiting_for_owner', + 'waiting_for_insertion', + 'waiting_for_adm', + 'accepted', + ]); + }); + + it('rejects source and artifact combinations from a different render path', () => { + const directApsOwner = owner(); + const directAps = attempt(directApsOwner); + expect(directAps.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(directAps.beginDirect()).toBe(true); + expect(directAps.beginAdm(artifact(directApsOwner))).toBe(false); + expect(directAps.beginApsDocument(artifact(directApsOwner, 'puc'))).toBe(false); + expect(directAps.beginApsDocument(artifact(directApsOwner))).toBe(true); + + const directAdmOwner = owner(ATTEMPT_TWO); + const directAdm = attempt(directAdmOwner); + expect(directAdm.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(directAdm.beginDirect()).toBe(true); + expect(directAdm.beginApsDocument(artifact(directAdmOwner))).toBe(false); + expect(directAdm.beginAdm(artifact(directAdmOwner, 'puc'))).toBe(false); + expect(directAdm.beginAdm(artifact(directAdmOwner))).toBe(true); + + const pucApsOwner = owner('a1_0000000000000000000002'); + const pucAps = attempt(pucApsOwner); + expect(pucAps.beginGamClaim()).toBe(true); + expect(pucAps.admitClaimedWinner(claimed(pucAps, pucApsOwner, APS_SOURCE))).toBe(true); + expect(pucAps.ownerClaimed()).toBe(true); + expect(pucAps.ownerRegistered()).toBe(true); + expect(pucAps.beginAdm(artifact(pucApsOwner, 'puc'))).toBe(false); + expect(pucAps.beginApsDocument(artifact(pucApsOwner))).toBe(false); + expect(pucAps.beginApsDocument(artifact(pucApsOwner, 'puc'))).toBe(true); + }); + + it('admits a claimed winner only through the exact one-shot source/context claim', () => { + const scope = owner(); + const render = attempt(scope); + expect(render.beginGamClaim()).toBe(true); + const exactClaim = claimed(render, scope, APS_SOURCE); + const exactContext = scope.winnerContext; + if (!exactContext) throw new Error('should admit the exact reservation context'); + + const mismatchedOwner = owner(ATTEMPT_TWO); + const mismatched = attempt(mismatchedOwner); + expect(mismatched.beginGamClaim()).toBe(true); + mismatchedOwner.admitClaimedContext(exactContext); + expect(mismatched.admitClaimedWinner(exactClaim)).toBe(false); + expect(mismatched.renderSource).toBeUndefined(); + mismatched.cancel('caller_aborted'); + + expect(render.admitClaimedWinner(Object.freeze({}))).toBe(false); + expect(render.admitClaimedWinner(exactClaim)).toBe(true); + expect(render.renderSource).toEqual(APS_SOURCE); + expect(render.winnerContext).toBe(exactContext); + expect(render.admitClaimedWinner(exactClaim)).toBe(false); + }); + + it('enforces every valid, invalid, and replay transition in the state table', () => { + type Transition = + | 'admit_direct' + | 'admit_claimed' + | 'begin_gam_claim' + | 'owner_claimed' + | 'owner_registered' + | 'begin_direct' + | 'begin_aps_document' + | 'begin_adm' + | 'aps_document_accepted' + | 'accept' + | 'no_bid' + | 'gam_empty' + | 'fail' + | 'cancel'; + type ScenarioName = + | 'created' + | 'created_direct' + | 'waiting_for_gam_and_claim' + | 'waiting_for_gam_and_claim_admitted' + | 'waiting_for_owner' + | 'waiting_for_insertion_aps' + | 'waiting_for_insertion_adm' + | 'rendering_direct_aps' + | 'rendering_direct_adm' + | 'waiting_for_document' + | 'waiting_for_aps_completion' + | 'waiting_for_adm' + | 'accepted' + | 'no_bid' + | 'failed' + | 'cancelled'; + + const transitions: readonly Transition[] = [ + 'admit_direct', + 'admit_claimed', + 'begin_gam_claim', + 'owner_claimed', + 'owner_registered', + 'begin_direct', + 'begin_aps_document', + 'begin_adm', + 'aps_document_accepted', + 'accept', + 'no_bid', + 'gam_empty', + 'fail', + 'cancel', + ]; + const valid = new Map>([ + ['created', new Set(['admit_direct', 'begin_gam_claim', 'no_bid', 'fail', 'cancel'])], + ['created_direct', new Set(['begin_direct', 'fail', 'cancel'])], + ['waiting_for_gam_and_claim', new Set(['admit_claimed', 'gam_empty', 'fail', 'cancel'])], + [ + 'waiting_for_gam_and_claim_admitted', + new Set(['owner_claimed', 'gam_empty', 'fail', 'cancel']), + ], + ['waiting_for_owner', new Set(['owner_registered', 'fail', 'cancel'])], + ['waiting_for_insertion_aps', new Set(['begin_aps_document', 'fail', 'cancel'])], + ['waiting_for_insertion_adm', new Set(['begin_adm', 'fail', 'cancel'])], + ['rendering_direct_aps', new Set(['begin_aps_document', 'fail', 'cancel'])], + ['rendering_direct_adm', new Set(['begin_adm', 'fail', 'cancel'])], + ['waiting_for_document', new Set(['aps_document_accepted', 'fail', 'cancel'])], + ['waiting_for_aps_completion', new Set(['accept', 'fail', 'cancel'])], + ['waiting_for_adm', new Set(['accept', 'fail', 'cancel'])], + ['accepted', new Set()], + ['no_bid', new Set()], + ['failed', new Set()], + ['cancelled', new Set()], + ]); + + const build = (name: ScenarioName): RenderAttempt => { + const scope = owner(); + const render = attempt(scope); + const claim = (source: typeof APS_SOURCE | typeof ADM_SOURCE): void => { + render.beginGamClaim(); + render.admitClaimedWinner(claimed(render, scope, source)); + }; + switch (name) { + case 'created': + break; + case 'created_direct': + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + break; + case 'waiting_for_gam_and_claim': + render.beginGamClaim(); + matrixClaims.set(render, claimed(render, scope, APS_SOURCE)); + break; + case 'waiting_for_gam_and_claim_admitted': + claim(APS_SOURCE); + break; + case 'waiting_for_owner': + claim(APS_SOURCE); + render.ownerClaimed(); + break; + case 'waiting_for_insertion_aps': + claim(APS_SOURCE); + render.ownerClaimed(); + render.ownerRegistered(); + break; + case 'waiting_for_insertion_adm': + claim(ADM_SOURCE); + render.ownerClaimed(); + render.ownerRegistered(); + break; + case 'rendering_direct_aps': + render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + break; + case 'rendering_direct_adm': + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + break; + case 'waiting_for_document': + render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginApsDocument(artifact(scope)); + break; + case 'waiting_for_aps_completion': + render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginApsDocument(artifact(scope)); + render.apsDocumentAccepted(); + break; + case 'waiting_for_adm': + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginAdm(artifact(scope)); + break; + case 'accepted': + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginAdm(artifact(scope)); + render.accept(); + break; + case 'no_bid': + render.noBid(); + break; + case 'failed': + render.fail('internal_error'); + break; + case 'cancelled': + render.cancel('caller_aborted'); + break; + } + return render; + }; + + const invoke = (render: RenderAttempt, transition: Transition): boolean => { + const kind = render.snapshot().state === 'waiting_for_insertion' ? 'puc' : 'direct_iframe'; + switch (transition) { + case 'admit_direct': + return render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + case 'admit_claimed': + return render.admitClaimedWinner(matrixClaims.get(render) ?? Object.freeze({})); + case 'begin_gam_claim': + return render.beginGamClaim(); + case 'owner_claimed': + return render.ownerClaimed(); + case 'owner_registered': + return render.ownerRegistered(); + case 'begin_direct': + return render.beginDirect(); + case 'begin_aps_document': + return render.beginApsDocument(artifact(render, kind)); + case 'begin_adm': + return render.beginAdm(artifact(render, kind)); + case 'aps_document_accepted': + return render.apsDocumentAccepted(); + case 'accept': + return render.accept(); + case 'no_bid': + return render.noBid(); + case 'gam_empty': + return render.fail('gam_empty'); + case 'fail': + return render.fail('internal_error'); + case 'cancel': + return render.cancel('caller_aborted'); + } + }; + + for (const [scenario, expectedTransitions] of valid) { + for (const transition of transitions) { + const render = build(scenario); + const expected = expectedTransitions.has(transition); + expect(invoke(render, transition), `${scenario} -> ${transition}`).toBe(expected); + if (expected) { + expect(invoke(render, transition), `${scenario} -> ${transition} replay`).toBe(false); + } + if (!render.snapshot().outcome) render.cancel('caller_aborted'); + } + } + }); + + it('owns the exact admitted source and winner context for a direct APS path', () => { + const scope = owner(); + const render = attempt(scope); + expect(render.beginDirect()).toBe(false); + expect(render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(render.renderSource).toBe(APS_SOURCE); + expect(render.winnerContext).toBe(WINNER_CONTEXT); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(false); + + const candidate = artifact(scope); + expect(render.beginDirect()).toBe(true); + expect(render.beginApsDocument(candidate)).toBe(true); + expect(render.apsDocumentAccepted()).toBe(true); + expect(render.accept()).toBe(true); + expect(render.renderSource).toBeUndefined(); + expect(render.winnerContext).toBeUndefined(); + }); + + it('allows no_bid only for the exact parsed decision before rendering starts', () => { + const noBid = attempt(); + expect(noBid.noBid()).toBe(true); + expect(noBid.snapshot()).toMatchObject({ state: 'no_bid', outcome: { outcome: 'no_bid' } }); + expect(noBid.beginDirect()).toBe(false); + + const rendering = attempt(owner(ATTEMPT_TWO)); + expect(rendering.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(rendering.beginDirect()).toBe(true); + expect(rendering.noBid()).toBe(false); + expect(rendering.fail('invalid_response')).toBe(true); + }); + + it('races state-owned timeout, success, failure, abort, and navigation disposal through one latch', () => { + vi.useFakeTimers(); + try { + const timedOwner = owner(); + const timedArtifact = artifact(timedOwner); + const timed = attempt(timedOwner, { + owner: timedOwner, + artifacts: createCommittedArtifactStore(), + }); + expect(timed.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(timed.beginDirect()).toBe(true); + expect(timed.beginAdm(timedArtifact)).toBe(true); + vi.advanceTimersByTime(5_000); + expect(timed.snapshot()).toMatchObject({ + outcome: { outcome: 'failed', reason: 'adm_document_no_load' }, + }); + expect(timedArtifact.dispose).toHaveBeenCalledOnce(); + expect(timed.accept()).toBe(false); + expect(timed.cancel('caller_aborted')).toBe(false); + + const aborted = attempt(owner(ATTEMPT_TWO)); + expect(aborted.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(aborted.beginDirect()).toBe(true); + expect(aborted.cancel('caller_aborted')).toBe(true); + expect(aborted.fail('internal_error')).toBe(false); + + const navigationOwner = owner('a1_0000000000000000000002'); + const navigationAttempt = attempt(navigationOwner); + expect(navigationAttempt.beginGamClaim()).toBe(true); + navigationOwner.disposeFromNavigation(); + expect(navigationAttempt.snapshot()).toMatchObject({ + outcome: { outcome: 'cancelled', reason: 'navigation_disposed' }, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('uses fixed transition-owned deadline timings and failure mappings', () => { + vi.useFakeTimers(); + try { + const registrationOwner = owner(); + const registration = attempt(registrationOwner); + registration.beginGamClaim(); + registration.admitClaimedWinner(claimed(registration, registrationOwner, APS_SOURCE)); + registration.ownerClaimed(); + vi.advanceTimersByTime(2_999); + expect(registration.snapshot().state).toBe('waiting_for_owner'); + vi.advanceTimersByTime(1); + expect(registration.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'owner_registration_timeout', + }); + + const insertionOwner = owner(ATTEMPT_TWO); + const insertion = attempt(insertionOwner); + insertion.beginGamClaim(); + insertion.admitClaimedWinner(claimed(insertion, insertionOwner, APS_SOURCE)); + insertion.ownerClaimed(); + insertion.ownerRegistered(); + vi.advanceTimersByTime(1_000); + expect(insertion.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'owner_insertion_timeout', + }); + + const documentOwner = owner('a1_0000000000000000000002'); + const documentAttempt = attempt(documentOwner); + documentAttempt.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + documentAttempt.beginDirect(); + documentAttempt.beginApsDocument(artifact(documentOwner)); + vi.advanceTimersByTime(3_000); + expect(documentAttempt.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + + const completionOwner = owner('a1_0000000000000000000003'); + const completion = attempt(completionOwner); + completion.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + completion.beginDirect(); + completion.beginApsDocument(artifact(completionOwner)); + completion.apsDocumentAccepted(); + vi.advanceTimersByTime(10_000); + expect(completion.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'runner_failed', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('reserves transition and terminal latches before hostile scheduler and artifact cleanup', () => { + const transitionReference: { current?: RenderAttempt } = {}; + let clearReenters = false; + const scheduler = { + set: vi.fn(() => Object.freeze({})), + clear: vi.fn(() => { + if (clearReenters) transitionReference.current?.cancel('caller_aborted'); + }), + }; + const transitionOwner = owner(); + const transitionAttempt = attempt(transitionOwner, { scheduler }); + transitionReference.current = transitionAttempt; + transitionAttempt.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); + transitionAttempt.beginDirect(); + transitionAttempt.beginApsDocument(artifact(transitionOwner)); + clearReenters = true; + + expect(transitionAttempt.apsDocumentAccepted()).toBe(true); + expect(transitionAttempt.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + expect(transitionAttempt.snapshot().history.slice(-2)).toEqual([ + 'waiting_for_aps_completion', + 'cancelled', + ]); + + const disposalReference: { current?: RenderAttempt } = {}; + const disposalOwner = owner(ATTEMPT_TWO); + const hostileArtifact = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: disposalOwner.id, + slot: disposalOwner.slot, + navigationGeneration: disposalOwner.navigationGeneration, + dispose: vi.fn(() => disposalReference.current?.cancel('superseded')), + }); + const disposalAttempt = attempt(disposalOwner); + disposalReference.current = disposalAttempt; + disposalAttempt.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + disposalAttempt.beginDirect(); + disposalAttempt.beginAdm(hostileArtifact); + + expect(disposalAttempt.fail('internal_error')).toBe(true); + expect(disposalAttempt.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'internal_error', + }); + expect(disposalAttempt.snapshot().history.filter((state) => state === 'failed')).toHaveLength( + 1 + ); + expect(disposalAttempt.snapshot().history).not.toContain('cancelled'); + }); + + it('does not promote after deadline cleanup reentrantly settles the attempt', () => { + const artifacts = createCommittedArtifactStore(); + const reference: { current?: RenderAttempt } = {}; + let cancelOnClear = false; + const scheduler = { + set: vi.fn(() => Object.freeze({})), + clear: vi.fn(() => { + if (cancelOnClear) reference.current?.cancel('caller_aborted'); + }), + }; + const scope = owner(); + const candidate = artifact(scope); + const render = attempt(scope, { artifacts, scheduler }); + reference.current = render; + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginAdm(candidate); + cancelOnClear = true; + + expect(render.accept()).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + expect(candidate.dispose).toHaveBeenCalledOnce(); + expect(artifacts.current(scope.slot)).toBeUndefined(); + }); + + it('rejects malformed or stale attempt ownership before registering work', () => { + const malformed = owner('bad-attempt'); + expect( + createRenderAttempt({ + owner: malformed, + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + + const stale = owner(); + stale.disposeFromNavigation(); + expect( + createRenderAttempt({ + owner: stale, + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + }); + + it('transactionally disposes owners when lifecycle registration cannot commit', () => { + for (const mode of ['throw', 'callback', 'identity'] as const) { + const scope = owner(); + const originalDispose = scope.dispose; + const dispose = vi.fn(() => originalDispose()); + Object.defineProperty(scope, 'dispose', { configurable: true, value: dispose }); + Object.defineProperty(scope, 'onDispose', { + configurable: true, + value: (_kind: string, callback: () => void) => { + if (mode === 'callback') callback(); + if (mode === 'identity') { + Object.defineProperty(scope, 'id', { configurable: true, value: ATTEMPT_TWO }); + } + if (mode === 'throw') throw new Error('registration failed'); + }, + }); + + expect( + createRenderAttempt({ + owner: scope, + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(dispose, mode).toHaveBeenCalledOnce(); + } + }); + + it('releases real session indexes after every post-issuance construction rejection', () => { + let issuedByte = 0; + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(issuedByte); + issuedByte += 1; + return target; + }, + }), + }); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('should start a navigation'); + const batch = navigation.value.createAuctionBatch('batch-render-construction'); + if (!batch) throw new Error('should create an auction batch'); + const slot = 'fictional-slot'; + + const unbrandedOwner = batch.createRenderAttempt(slot); + if (!unbrandedOwner.ok) throw new Error('should issue the first owner'); + expect( + createRenderAttempt({ + owner: unbrandedOwner.value, + artifacts: { ...createCommittedArtifactStore() }, + reservations: reservations(), + prepareRenderSource, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + + const invalidSchedulerOwner = batch.createRenderAttempt(slot); + expect(invalidSchedulerOwner).toMatchObject({ ok: true }); + if (!invalidSchedulerOwner.ok) throw new Error('should retry after provenance rejection'); + expect( + createRenderAttempt({ + owner: invalidSchedulerOwner.value, + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + scheduler: { set: undefined as never, clear: () => undefined }, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + + const unbrandedReservationsOwner = batch.createRenderAttempt(slot); + expect(unbrandedReservationsOwner).toMatchObject({ ok: true }); + if (!unbrandedReservationsOwner.ok) { + throw new Error('should retry after scheduler rejection'); + } + expect( + createRenderAttempt({ + owner: unbrandedReservationsOwner.value, + artifacts: createCommittedArtifactStore(), + prepareRenderSource, + reservations: { + ...reservations(), + consumeClaim: () => + Object.freeze({ + renderSource: ADM_SOURCE, + winnerContext: WINNER_CONTEXT, + }), + }, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + + expect(batch.createRenderAttempt(slot)).toMatchObject({ ok: true }); + runtime.dispose(); + }); + + it('runtime-rejects invalid terminal reasons instead of publishing malformed outcomes', () => { + const render = attempt(); + expect(render.fail('invented_failure' as never)).toBe(false); + expect(render.cancel('invented_cancellation' as never)).toBe(false); + expect(render.snapshot()).toMatchObject({ state: 'created', outcome: undefined }); + expect(render.fail('internal_error')).toBe(true); + }); +}); + +describe('committed artifact ownership', () => { + it('promotes before attempt disposal, preserves accepted DOM, and disposes the prior artifact before replacement', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const firstOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const firstArtifact = artifact(firstOwner); + const first = attempt(firstOwner, { owner: firstOwner, artifacts: store }); + first.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + first.beginDirect(); + first.beginAdm(firstArtifact); + expect(first.accept()).toBe(true); + expect(firstOwner.disposed).toBe(true); + expect(firstArtifact.dispose).not.toHaveBeenCalled(); + expect(store.current('fictional-slot')).toBe(firstArtifact); + + const secondOwner = owner(ATTEMPT_TWO, 'fictional-slot', generation); + const secondArtifact = artifact(secondOwner); + const second = attempt(secondOwner, { owner: secondOwner, artifacts: store }); + second.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + second.beginDirect(); + second.beginAdm(secondArtifact); + expect(second.accept()).toBe(true); + expect(firstArtifact.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBe(secondArtifact); + expect(secondArtifact.dispose).not.toHaveBeenCalled(); + + store.disposeNavigation(generation); + expect(secondArtifact.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + }); + + it('disposes only uncommitted artifacts on failure or cancellation', () => { + for (const [index, settle] of (['failed', 'cancelled'] as const).entries()) { + const scope = owner(`a1_000000000000000000000${index}`); + const candidate = artifact(scope); + const render = attempt(scope); + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + render.beginAdm(candidate); + if (settle === 'failed') expect(render.fail('adm_document_no_load')).toBe(true); + else expect(render.cancel('superseded')).toBe(true); + expect(candidate.dispose).toHaveBeenCalledOnce(); + } + }); + + it('does not publish a replacement when prior-artifact disposal reentrantly cancels it', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const secondOwner = owner(ATTEMPT_TWO, 'fictional-slot', generation); + const firstOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const firstArtifact = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: firstOwner.id, + slot: 'fictional-slot', + navigationGeneration: generation, + dispose: vi.fn(() => secondOwner.disposeFromNavigation()), + }); + const first = attempt(firstOwner, { owner: firstOwner, artifacts: store }); + first.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + first.beginDirect(); + first.beginAdm(firstArtifact); + first.accept(); + + const secondArtifact = artifact(secondOwner); + const second = attempt(secondOwner, { owner: secondOwner, artifacts: store }); + second.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + second.beginDirect(); + second.beginAdm(secondArtifact); + + expect(second.accept()).toBe(false); + expect(second.snapshot()).toMatchObject({ + outcome: { outcome: 'cancelled', reason: 'navigation_disposed' }, + }); + expect(firstArtifact.dispose).toHaveBeenCalledOnce(); + expect(secondArtifact.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + }); + + it('requires an immutable exact-attempt artifact without invoking accessors', () => { + const scope = owner(); + const render = attempt(scope); + render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + render.beginDirect(); + const wrongAttempt = Object.freeze({ + ...artifact(scope), + attemptId: ATTEMPT_TWO, + }); + expect(render.beginAdm(wrongAttempt)).toBe(false); + + const getter = vi.fn(() => 'direct_iframe'); + const hostile = Object.freeze( + Object.defineProperties( + {}, + { + attemptId: { enumerable: true, value: scope.id }, + dispose: { enumerable: true, value: vi.fn() }, + kind: { enumerable: true, get: getter }, + navigationGeneration: { enumerable: true, value: scope.navigationGeneration }, + slot: { enumerable: true, value: scope.slot }, + } + ) + ); + expect(render.beginAdm(hostile as CommittedRenderArtifact)).toBe(false); + expect(getter).not.toHaveBeenCalled(); + }); + + it('defers reentrant navigation disposal and never publishes into a disposed generation', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const firstOwner = owner(ATTEMPT_ONE, 'slot-one', generation); + const secondOwner = owner(ATTEMPT_TWO, 'slot-two', generation); + const replacementOwner = owner('a1_0000000000000000000002', 'slot-one', generation); + const first = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: firstOwner.id, + slot: firstOwner.slot, + navigationGeneration: generation, + dispose: vi.fn(() => store.disposeNavigation(generation)), + }); + const second = artifact(secondOwner); + const replacement = artifact(replacementOwner); + expect(store.promote(first)).toBe(true); + expect(store.promote(second)).toBe(true); + + expect(store.promote(replacement)).toBe(false); + expect(first.dispose).toHaveBeenCalledOnce(); + expect(second.dispose).toHaveBeenCalledOnce(); + expect(store.current('slot-one')).toBeUndefined(); + expect(store.current('slot-two')).toBeUndefined(); + }); + + it('never retries a throwing artifact disposer', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const firstOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const replacementOwner = owner(ATTEMPT_TWO, 'fictional-slot', generation); + const dispose = vi.fn(() => { + throw new Error('partial artifact disposal'); + }); + const first = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: firstOwner.id, + slot: firstOwner.slot, + navigationGeneration: generation, + dispose, + }); + expect(store.promote(first)).toBe(true); + expect(store.promote(artifact(replacementOwner))).toBe(false); + expect(store.current('fictional-slot')).toBe(first); + + store.disposeNavigation(generation); + expect(dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + }); + + it('fails closed and contains an asynchronous artifact disposer', async () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const firstOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const replacementOwner = owner(ATTEMPT_TWO, 'fictional-slot', generation); + const dispose = vi.fn(async () => { + throw new Error('asynchronous artifact disposal is unsupported'); + }); + const first = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: firstOwner.id, + slot: firstOwner.slot, + navigationGeneration: generation, + dispose, + }); + expect(store.promote(first)).toBe(true); + + expect(store.promote(artifact(replacementOwner))).toBe(false); + await Promise.resolve(); + + expect(dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBe(first); + }); + + it.each(['fulfilled_promise', 'fulfilling_thenable'] as const)( + 'contains an asynchronous %s disposer without publishing a replacement', + async (mode) => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const firstOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const replacementOwner = owner(ATTEMPT_TWO, 'fictional-slot', generation); + const dispose = vi.fn(() => + mode === 'fulfilled_promise' + ? Promise.resolve() + : { + then: (fulfilled: () => void) => { + queueMicrotask(() => fulfilled()); + }, + } + ); + const first = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: firstOwner.id, + slot: firstOwner.slot, + navigationGeneration: generation, + dispose, + }); + expect(store.promote(first)).toBe(true); + + expect(store.promote(artifact(replacementOwner))).toBe(false); + await Promise.resolve(); + await Promise.resolve(); + + expect(dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBe(first); + } + ); + + it('never republishes an artifact after its disposal has started', () => { + const store = createCommittedArtifactStore(); + const candidate = artifact(owner()); + expect(store.promote(candidate)).toBe(true); + expect(store.release(candidate)).toBe(true); + expect(candidate.dispose).toHaveBeenCalledOnce(); + + expect(store.promote(candidate)).toBe(false); + expect(store.current(candidate.slot)).toBeUndefined(); + expect(candidate.dispose).toHaveBeenCalledOnce(); + }); + + it('preserves the prior artifact when promotion currentness is already false', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const current = artifact(owner(ATTEMPT_ONE, 'fictional-slot', generation)); + const candidate = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation)); + expect(store.promote(current)).toBe(true); + + expect(store.promote(candidate, () => false)).toBe(false); + expect(current.dispose).not.toHaveBeenCalled(); + expect(candidate.dispose).not.toHaveBeenCalled(); + expect(store.current('fictional-slot')).toBe(current); + }); + + it('never publishes after its navigation generation or whole store is disposed', () => { + const generation = Object.freeze({}); + const navigationStore = createCommittedArtifactStore(); + const navigationArtifact = artifact(owner(ATTEMPT_ONE, 'fictional-slot', generation)); + navigationStore.disposeNavigation(generation); + expect(navigationStore.promote(navigationArtifact)).toBe(false); + expect(navigationStore.current('fictional-slot')).toBeUndefined(); + + const runtimeStore = createCommittedArtifactStore(); + const runtimeArtifact = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation)); + expect( + runtimeStore.promote(runtimeArtifact, () => { + runtimeStore.dispose(); + return true; + }) + ).toBe(false); + expect(runtimeStore.current('fictional-slot')).toBeUndefined(); + }); + + it('contains collection prototype tampering at every artifact-store boundary', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const first = artifact(owner(ATTEMPT_ONE, 'fictional-slot', generation)); + const replacement = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation)); + const originalMapGet = Map.prototype.get; + const originalSetAdd = Set.prototype.add; + const originalWeakMapHas = WeakMap.prototype.has; + + let promoted: boolean | undefined; + Map.prototype.get = () => { + throw new Error('tampered Map.get'); + }; + try { + promoted = store.promote(first); + } finally { + Map.prototype.get = originalMapGet; + } + expect(promoted).toBe(true); + + let released: boolean | undefined; + WeakMap.prototype.has = () => { + throw new Error('tampered WeakMap.has'); + }; + try { + released = store.release(first); + } finally { + WeakMap.prototype.has = originalWeakMapHas; + } + expect(released).toBe(true); + expect(first.dispose).toHaveBeenCalledOnce(); + + expect(store.promote(replacement)).toBe(true); + Set.prototype.add = () => { + throw new Error('tampered Set.add'); + }; + try { + store.disposeNavigation(generation); + } finally { + Set.prototype.add = originalSetAdd; + } + expect(replacement.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + }); + + it('keeps store bookkeeping valid when a disposer tampers with collection prototypes', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + const originalMapGet = Map.prototype.get; + const dispose = vi.fn(() => { + Map.prototype.get = () => { + throw new Error('tampered Map.get'); + }; + }); + const current = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: ATTEMPT_ONE, + slot: 'fictional-slot', + navigationGeneration: generation, + dispose, + }); + const replacement = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation)); + expect(store.promote(current)).toBe(true); + let promoted: boolean | undefined; + try { + promoted = store.promote(replacement); + } finally { + Map.prototype.get = originalMapGet; + } + + expect(promoted).toBe(true); + expect(dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBe(replacement); + }); +}); + +describe('RenderAttempt diagnostics producer', () => { + it('publishes one frozen terminal observation only after accepted artifact state commits', () => { + const artifacts = createCommittedArtifactStore(); + const attemptReference: { current?: RenderAttempt } = {}; + const snapshots: RenderAttemptSnapshot[] = []; + const publishDiagnostics = vi.fn((observation: RenderAttemptDiagnosticsObservation) => { + expect(Object.isFrozen(observation)).toBe(true); + expect(Object.isFrozen(observation.outcome)).toBe(true); + snapshots.push(attemptReference.current!.snapshot()); + throw new Error('fictional diagnostics failure'); + }); + const renderAttempt = attempt(owner(), { artifacts, publishDiagnostics }); + attemptReference.current = renderAttempt; + expect(renderAttempt.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(renderAttempt.beginDirect()).toBe(true); + const committed = artifact(renderAttempt); + expect(renderAttempt.beginAdm(committed)).toBe(true); + + expect(renderAttempt.accept()).toBe(true); + + expect(artifacts.current(renderAttempt.slot)).toBe(committed); + expect(snapshots).toEqual([ + expect.objectContaining({ state: 'accepted', outcome: { outcome: 'accepted' } }), + ]); + expect(publishDiagnostics).toHaveBeenCalledOnce(); + expect(publishDiagnostics).toHaveBeenCalledWith({ + kind: 'render_attempt', + attemptId: renderAttempt.id, + slotId: renderAttempt.slot, + path: 'auction', + rendered: true, + injected: true, + servedFrom: 'inline', + state: 'accepted', + outcome: { outcome: 'accepted' }, + }); + }); + + it('publishes source-owned APS trace identity without exposing the creative payload', () => { + const publishDiagnostics = vi.fn(); + const renderAttempt = attempt(owner(), { publishDiagnostics }); + expect(renderAttempt.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(renderAttempt.beginDirect()).toBe(true); + const committed = artifact(renderAttempt); + expect(renderAttempt.beginApsDocument(committed)).toBe(true); + expect(renderAttempt.apsDocumentAccepted()).toBe(true); + + expect(renderAttempt.accept()).toBe(true); + + expect(publishDiagnostics).toHaveBeenCalledWith( + expect.objectContaining({ + bidId: DIRECT_APS_SOURCE.bidId, + creativeId: DIRECT_APS_SOURCE.creativeId, + injected: true, + rendered: true, + }) + ); + const observation = publishDiagnostics.mock.calls[0]?.[0] as Record; + expect(observation).not.toHaveProperty('aaxResponse'); + expect(observation).not.toHaveProperty('creativeUrl'); + }); + + it('publishes terminal failure after the lifecycle state commit and never republishes', () => { + const attemptReference: { current?: RenderAttempt } = {}; + const observedStates: RenderAttemptState[] = []; + const publishDiagnostics = vi.fn(() => { + observedStates.push(attemptReference.current!.snapshot().state); + return false; + }); + const renderAttempt = attempt(owner(), { publishDiagnostics }); + attemptReference.current = renderAttempt; + + expect(renderAttempt.fail('runner_failed')).toBe(true); + expect(renderAttempt.cancel('superseded')).toBe(false); + + expect(observedStates).toEqual(['failed']); + expect(publishDiagnostics).toHaveBeenCalledOnce(); + }); +}); + +describe('SlotOperation result isolation', () => { + it('rejects an unbranded structural primary before observing or starting fallback', () => { + const createFallback = vi.fn(); + const forged = { + id: ATTEMPT_ONE, + slot: 'fictional-slot', + navigationGeneration: Object.freeze({}), + onSettled: vi.fn(), + snapshot: vi.fn(), + } as unknown as RenderAttempt; + + expect(createSlotOperation({ primary: forged, createFallback })).toEqual({ + ok: false, + reason: 'invalid_attempt', + }); + expect(forged.onSettled).not.toHaveBeenCalled(); + expect(createFallback).not.toHaveBeenCalled(); + }); + + it('retains immutable primary gam_empty and settles from one distinct fallback child', () => { + const primary = attempt(); + let fallback: RenderAttempt | undefined; + const operation = slotOperation({ + primary, + createFallback: (parentAttemptId) => { + const childOwner = owner(ATTEMPT_TWO, primary.slot, primary.navigationGeneration); + const result = createRenderAttempt({ + owner: childOwner, + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + parentAttemptId, + }); + if (result.ok) fallback = result.value; + return result; + }, + }); + + primary.beginGamClaim(); + expect(primary.fail('gam_empty')).toBe(true); + expect(fallback).toBeDefined(); + expect(fallback?.parentAttemptId).toBe(primary.id); + expect(fallback?.id).not.toBe(primary.id); + expect(fallback?.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + fallback?.beginDirect(); + const fallbackArtifact = artifact(fallback!); + fallback?.beginAdm(fallbackArtifact); + expect(fallback?.accept()).toBe(true); + + expect(operation.snapshot()).toEqual({ + settled: true, + result: { + path: 'fallback', + outcome: { outcome: 'accepted' }, + primaryAttemptId: ATTEMPT_ONE, + primary: { outcome: 'failed', reason: 'gam_empty' }, + fallbackAttemptId: ATTEMPT_TWO, + fallback: { outcome: 'accepted' }, + }, + }); + expect(Object.isFrozen(operation.snapshot().result)).toBe(true); + }); + + it('does not start fallback for ineligible primary results or settle twice', () => { + const primary = attempt(); + const createFallback = vi.fn(); + const operation = slotOperation({ primary, createFallback }); + primary.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); + primary.beginDirect(); + primary.fail('runner_failed'); + + expect(createFallback).not.toHaveBeenCalled(); + expect(operation.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'primary', + outcome: { outcome: 'failed', reason: 'runner_failed' }, + }, + }); + expect(primary.cancel('superseded')).toBe(false); + }); + + it('cannot forge fallback with gam_empty outside an attributable GAM state', () => { + const primary = attempt(); + const createFallback = vi.fn(); + const operation = slotOperation({ primary, createFallback }); + + expect(primary.fail('gam_empty')).toBe(false); + expect(createFallback).not.toHaveBeenCalled(); + expect(operation.snapshot()).toEqual({ settled: false }); + expect(primary.cancel('caller_aborted')).toBe(true); + }); + + it('rejects a fallback child from another navigation generation', () => { + const primary = attempt(); + let child: RenderAttempt | undefined; + const operation = slotOperation({ + primary, + createFallback: (parentAttemptId) => { + const result = createRenderAttempt({ + owner: owner(ATTEMPT_TWO), + artifacts: createCommittedArtifactStore(), + reservations: reservations(), + prepareRenderSource, + parentAttemptId, + }); + if (result.ok) child = result.value; + return result; + }, + }); + primary.beginGamClaim(); + primary.fail('gam_empty'); + + expect(child?.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'superseded', + }); + expect(operation.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'fallback', + outcome: { outcome: 'failed', reason: 'internal_error' }, + }, + }); + }); + + it('fails closed when fallback identity issuance fails', () => { + const primary = attempt(); + const operation = slotOperation({ + primary, + createFallback: () => Object.freeze({ ok: false, reason: 'identity_generation_failed' }), + }); + primary.beginGamClaim(); + primary.fail('gam_empty'); + + expect(operation.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'fallback', + primary: { outcome: 'failed', reason: 'gam_empty' }, + outcome: { outcome: 'failed', reason: 'identity_generation_failed' }, + }, + }); + }); + + it('contains hostile fallback result getters and child subscription failures', () => { + const getterPrimary = attempt(); + const getterOperation = slotOperation({ + primary: getterPrimary, + createFallback: () => + Object.defineProperty({}, 'ok', { + get: () => { + throw new Error('hostile result getter'); + }, + }) as never, + }); + getterPrimary.beginGamClaim(); + getterPrimary.fail('gam_empty'); + expect(getterOperation.snapshot()).toMatchObject({ + settled: true, + result: { outcome: { outcome: 'failed', reason: 'internal_error' } }, + }); + + const subscriptionPrimary = attempt(owner(ATTEMPT_ONE, 'fictional-slot', Object.freeze({}))); + const hostileChild = { + id: ATTEMPT_TWO, + slot: subscriptionPrimary.slot, + parentAttemptId: subscriptionPrimary.id, + navigationGeneration: subscriptionPrimary.navigationGeneration, + cancel: vi.fn(() => true), + onSettled: () => { + throw new Error('hostile child subscription'); + }, + } as unknown as RenderAttempt; + const subscriptionOperation = slotOperation({ + primary: subscriptionPrimary, + createFallback: () => Object.freeze({ ok: true, value: hostileChild }), + }); + subscriptionPrimary.beginGamClaim(); + subscriptionPrimary.fail('gam_empty'); + expect(subscriptionOperation.snapshot()).toMatchObject({ + settled: true, + result: { outcome: { outcome: 'failed', reason: 'internal_error' } }, + }); + expect(hostileChild.cancel).toHaveBeenCalledOnce(); + }); + + it('rejects a fallback result accessor without rereading or cancelling another value', () => { + const primary = attempt(); + const first = { cancel: vi.fn() }; + const second = { cancel: vi.fn() }; + let reads = 0; + const result = Object.freeze( + Object.defineProperties( + {}, + { + ok: { enumerable: true, value: true }, + value: { + enumerable: true, + get: () => { + reads += 1; + return reads === 1 ? first : second; + }, + }, + } + ) + ); + const operation = slotOperation({ primary, createFallback: () => result as never }); + primary.beginGamClaim(); + primary.fail('gam_empty'); + + expect(operation.snapshot()).toMatchObject({ + settled: true, + result: { outcome: { outcome: 'failed', reason: 'internal_error' } }, + }); + expect(reads).toBe(0); + expect(first.cancel).not.toHaveBeenCalled(); + expect(second.cancel).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts new file mode 100644 index 000000000..67c5d37ac --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -0,0 +1,2239 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { parseBidRenderSourceV1 } from '../../src/core/contracts/auction_projection'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { + createRuntimeSession, + type NavigationSession, + type RenderAttemptScope, + type WinnerContext, +} from '../../src/kernel/sessions'; +import { + PREBID_ADMISSION_LEASE_MS, + RENDER_RESERVATION_LIFETIME_MS, + createReservationService, + isRendererReservationId, + type ReservationOwner, + type ReservationRenderSource, +} from '../../src/services/reservations'; + +function reservationId(index = 0): string { + return `r1_${index.toString(36).padStart(22, '0')}`; +} + +function runtimeNavigation(): { + readonly navigation: NavigationSession; + readonly runtime: ReturnType; +} { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + }); + const navigation = runtime.startInitialNavigation(); + if (!navigation.ok) throw new Error('Expected a navigation'); + return { navigation: navigation.value, runtime }; +} + +function renderAttempt(navigation: NavigationSession, slot = 'fictional-slot'): RenderAttemptScope { + const batch = navigation.createAuctionBatch(`batch-${slot}`); + if (!batch) throw new Error('Expected an auction batch'); + const attempt = batch.createRenderAttempt(slot); + if (!attempt.ok) throw new Error('Expected a render attempt'); + return attempt.value; +} + +function admSource(markup = '
fictional creative
') { + return { type: 'adm', version: 1, adm: markup, width: 300, height: 250 } as const; +} + +function apsSource() { + const creativeUrl = 'https://creative.example/render'; + const envelope = { + seatbid: [ + { + bid: [ + { + id: 'upstream-bid', + w: 300, + h: 250, + price: 1.25, + ext: { creativeurl: creativeUrl, tagtype: 'iframe' }, + }, + ], + }, + ], + }; + return { + type: 'aps', + version: 1, + accountId: 'fictional-account', + bidId: 'upstream-bid', + creativeId: 'fictional-creative', + tagType: 'iframe', + creativeUrl, + aaxResponse: btoa(JSON.stringify(envelope)), + width: 300, + height: 250, + } as const; +} + +function serviceAt(readNow: () => number) { + return createReservationService({ + now: readNow, + prepareRenderSource: (candidate) => { + const source = parseBidRenderSourceV1(candidate); + return source?.type === 'pbs_cache' ? undefined : source; + }, + }); +} + +function registerRender( + service: ReturnType, + navigation: NavigationSession, + attempt: RenderAttemptScope, + id = reservationId(), + renderSource: unknown = admSource(), + selectedCpm = 1.25 +) { + return service.registerRender({ + reservationId: id, + slot: attempt.slot, + navigation, + attemptId: attempt.id, + renderSource, + winnerContext: { selectedCpm }, + }); +} + +function claim( + service: ReturnType, + navigation: NavigationSession, + attempt: RenderAttemptScope, + id = reservationId(), + pucSource: object = Object.freeze({}) +) { + return service.claim({ + reservationId: id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt, + pucSource, + }); +} + +function tombstone( + service: ReturnType, + navigation: NavigationSession, + attempt: RenderAttemptScope, + id: string, + state: 'disposed' | 'stale' +) { + return service.tombstone( + { + reservationId: id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + attemptId: attempt.id, + }, + state + ); +} + +describe('renderer reservation identity and registration', () => { + it.each([ + [reservationId(), true], + [`r1_${'A'.repeat(22)}`, true], + [`r1_${'_'.repeat(22)}`, true], + [`r1_${'-'.repeat(22)}`, true], + [`r1_${'a'.repeat(21)}`, false], + [`r1_${'a'.repeat(23)}`, false], + [`r2_${'a'.repeat(22)}`, false], + [`r1_${'a'.repeat(21)}=`, false], + [`r1_${'a'.repeat(21)}+`, false], + ['', false], + [undefined, false], + ])('validates the exact server-minted identity %j', (candidate, expected) => { + expect(isRendererReservationId(candidate)).toBe(expected); + }); + + it('copies and freezes one exact APS or ADM source without retaining projection input', () => { + const { navigation } = runtimeNavigation(); + const sources = [apsSource(), admSource()]; + + for (const [index, source] of sources.entries()) { + const service = serviceAt(() => 5); + const attempt = renderAttempt(navigation, `slot-${index}`); + const mutable = structuredClone(source) as Record; + expect(registerRender(service, navigation, attempt, reservationId(index), mutable).ok).toBe( + true + ); + mutable.width = 1; + + const result = claim(service, navigation, attempt, reservationId(index)); + expect(result).toMatchObject({ recognized: true, claimed: true }); + if (!result.recognized || !result.claimed) throw new Error('Expected a claim'); + const context = attempt.winnerContext; + if (!context) throw new Error('Expected an admitted winner context'); + const admission = service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }); + expect(admission?.renderSource).toEqual(source); + expect(admission?.renderSource).not.toBe(mutable); + expect(Object.isFrozen(admission?.renderSource)).toBe(true); + expect(Object.isFrozen(admission?.winnerContext)).toBe(true); + } + }); + + it('binds one consumed claim object to its exact attempt source and winner context', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 5); + const attempt = renderAttempt(navigation); + expect(registerRender(service, navigation, attempt)).toMatchObject({ ok: true }); + const result = claim(service, navigation, attempt); + if (!result.recognized || !result.claimed) throw new Error('Expected a claim'); + expect(Object.getOwnPropertyNames(result).sort()).toEqual([ + 'claimed', + 'expiresAt', + 'pucSource', + 'recognized', + ]); + expect(result).not.toHaveProperty('renderSource'); + expect(result).not.toHaveProperty('winnerContext'); + const context = attempt.winnerContext; + if (!context) throw new Error('Expected an admitted winner context'); + + expect( + Reflect.apply(service.consumeClaim, service, [ + result, + { + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }, + ]) + ).toBeUndefined(); + const replayedAttempt = Object.freeze({ ...attempt }); + expect( + service.consumeClaim(result, { + attempt: replayedAttempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }) + ).toBeUndefined(); + expect( + service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: Object.freeze({}), + winnerContext: context, + }) + ).toBeUndefined(); + expect( + service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: Object.freeze({ selectedCpm: context.selectedCpm }), + }) + ).toBeUndefined(); + expect( + service.consumeClaim(Object.freeze({ ...result }), { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }) + ).toBeUndefined(); + + const admission = service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }); + expect(admission).toEqual({ + renderSource: admSource(), + winnerContext: context, + }); + expect(admission?.winnerContext).toBe(context); + expect(Object.isFrozen(admission)).toBe(true); + expect( + service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }) + ).toBeUndefined(); + }); + + it.each(['navigation_disposed', 'service_disposed', 'expired'] as const)( + 'invalidates a consumed claim when its authority is %s', + (mode) => { + let now = 5; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const attempt = renderAttempt(navigation); + expect(registerRender(service, navigation, attempt)).toMatchObject({ ok: true }); + const result = claim(service, navigation, attempt); + if (!result.recognized || !result.claimed) throw new Error('Expected a claim'); + const context = attempt.winnerContext; + if (!context) throw new Error('Expected an admitted winner context'); + + if (mode === 'navigation_disposed') navigation.dispose(); + else if (mode === 'service_disposed') service.dispose(); + else now = result.expiresAt; + + expect( + service.consumeClaim(result, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext: context, + }) + ).toBeUndefined(); + } + ); + + it('rejects duplicate identity against live and tombstoned entries without overwriting either', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + const first = renderAttempt(navigation, 'first'); + const second = renderAttempt(navigation, 'second'); + + expect(registerRender(service, navigation, first)).toMatchObject({ ok: true }); + expect(registerRender(service, navigation, second)).toEqual({ + ok: false, + reason: 'reservation_collision', + }); + expect(claim(service, navigation, first)).toMatchObject({ claimed: true }); + expect(registerRender(service, navigation, second)).toEqual({ + ok: false, + reason: 'reservation_collision', + }); + }); + + it('rejects nonfinite, negative, accessor, and extra-field winner contexts before publication', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + for (const winnerContext of [ + { selectedCpm: Number.NaN }, + { selectedCpm: Number.POSITIVE_INFINITY }, + { selectedCpm: -0.01 }, + { selectedCpm: 1, extra: true }, + Object.defineProperty({}, 'selectedCpm', { enumerable: true, get: () => 1 }), + ]) { + const service = serviceAt(() => 0); + expect( + service.registerRender({ + reservationId: reservationId(), + slot: attempt.slot, + navigation, + attemptId: attempt.id, + renderSource: admSource(), + winnerContext, + }) + ).toEqual({ ok: false, reason: 'invalid_winner_context' }); + expect(service.snapshotInventoryForTest().size).toBe(0); + } + }); + + it('contains hostile sources, owners, and prototype poisoning without partial live publication', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const hostileSource = Object.defineProperty({}, 'type', { + enumerable: true, + get() { + throw new Error('hostile getter'); + }, + }); + + expect(() => + registerRender(service, navigation, attempt, reservationId(), hostileSource) + ).not.toThrow(); + expect(registerRender(service, navigation, attempt, reservationId(), hostileSource)).toEqual({ + ok: false, + reason: 'invalid_render_source', + }); + + const originalGet = Map.prototype.get; + const originalSet = Map.prototype.set; + const originalDelete = Map.prototype.delete; + Map.prototype.get = function poisonedGet() { + throw new Error('poisoned get'); + }; + Map.prototype.set = function poisonedSet() { + throw new Error('poisoned set'); + }; + Map.prototype.delete = function poisonedDelete() { + throw new Error('poisoned delete'); + }; + try { + expect( + service.registerRender({ + reservationId: reservationId(), + slot: attempt.slot, + navigation: { + generation: navigation.generation, + isCurrent: () => true, + onDispose: vi.fn(), + }, + attemptId: attempt.id, + renderSource: admSource(), + winnerContext: { selectedCpm: 1.25 }, + }) + ).toMatchObject({ ok: true }); + let adopted: WinnerContext | undefined; + expect( + service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: { + id: attempt.id, + slot: attempt.slot, + get winnerContext() { + return adopted; + }, + isCurrent: () => true, + prepareWinnerContext: (context) => { + return { + commit: () => { + adopted = context; + return true; + }, + rollback: () => { + if (adopted === context) adopted = undefined; + return true; + }, + }; + }, + }, + pucSource: Object.freeze({}), + }) + ).toMatchObject({ claimed: true }); + } finally { + Map.prototype.get = originalGet; + Map.prototype.set = originalSet; + Map.prototype.delete = originalDelete; + } + }); + + it('uses captured identity and UTF-8 validators after their prototypes are poisoned', () => { + const generation = Object.freeze({}); + const renderSource = admSource() as ReservationRenderSource; + const service = createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + const owner: ReservationOwner = { + generation, + isCurrent: () => true, + onDispose: () => undefined, + }; + const originalRegExpTest = RegExp.prototype.test; + const originalTextEncoderEncode = TextEncoder.prototype.encode; + let validIdentity: boolean | undefined; + let invalidIdentity: boolean | undefined; + let invalidSlot: ReturnType | undefined; + let validRegistration: ReturnType | undefined; + let thrown: unknown; + + RegExp.prototype.test = function poisonedRegExpTest() { + throw new Error('poisoned RegExp.test'); + }; + TextEncoder.prototype.encode = function poisonedTextEncoderEncode() { + throw new Error('poisoned TextEncoder.encode'); + }; + try { + validIdentity = isRendererReservationId(reservationId()); + invalidIdentity = isRendererReservationId('not-a-reservation'); + invalidSlot = service.registerRender({ + reservationId: reservationId(), + slot: 'x'.repeat(257), + navigation: owner, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + validRegistration = service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: owner, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + } catch (error) { + thrown = error; + } finally { + RegExp.prototype.test = originalRegExpTest; + TextEncoder.prototype.encode = originalTextEncoderEncode; + } + + expect(thrown).toBeUndefined(); + expect(validIdentity).toBe(true); + expect(invalidIdentity).toBe(false); + expect(invalidSlot).toEqual({ ok: false, reason: 'invalid_slot' }); + expect(validRegistration).toMatchObject({ ok: true }); + }); + + it('uses captured code-unit validation when String.charCodeAt returns benign data', () => { + const generation = Object.freeze({}); + const renderSource = admSource() as ReservationRenderSource; + const service = createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + const originalCharCodeAt = String.prototype.charCodeAt; + const results: ReturnType[] = []; + String.prototype.charCodeAt = () => 0x61; + try { + for (const [index, slot] of ['control\u0000slot', 'lone-surrogate\ud800'].entries()) { + results[results.length] = service.registerRender({ + reservationId: reservationId(index), + slot, + navigation: { generation, isCurrent: () => true, onDispose: () => undefined }, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + } + } finally { + String.prototype.charCodeAt = originalCharCodeAt; + } + + expect(results).toEqual([ + { ok: false, reason: 'invalid_slot' }, + { ok: false, reason: 'invalid_slot' }, + ]); + expect(service.snapshotInventoryForTest().size).toBe(0); + }); + + it('contains throwing String.charCodeAt poisoning without publishing', () => { + const generation = Object.freeze({}); + const renderSource = admSource() as ReservationRenderSource; + const service = createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + const originalCharCodeAt = String.prototype.charCodeAt; + let result: ReturnType | undefined; + let thrown: unknown; + String.prototype.charCodeAt = () => { + throw new Error('poisoned String.charCodeAt'); + }; + try { + result = service.registerRender({ + reservationId: reservationId(), + slot: 'control\u0000slot', + navigation: { generation, isCurrent: () => true, onDispose: () => undefined }, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + } catch (error) { + thrown = error; + } finally { + String.prototype.charCodeAt = originalCharCodeAt; + } + + expect(thrown).toBeUndefined(); + expect(result).toEqual({ ok: false, reason: 'invalid_slot' }); + expect(service.snapshotInventoryForTest().size).toBe(0); + }); + + it.each([ + ['throws before applying', false], + ['throws after applying', true], + ] as const)('contains a captured Map.set that %s', async (_name, applyFirst) => { + vi.resetModules(); + const originalSet = Map.prototype.set; + Map.prototype.set = function poisonedReservationSet(key, value) { + if (typeof key !== 'string' || !key.startsWith('r1_')) { + return Reflect.apply(originalSet, this, [key, value]) as Map; + } + if (applyFirst) Reflect.apply(originalSet, this, [key, value]); + throw new Error('captured reservation Map.set failure'); + }; + let isolated: typeof import('../../src/services/reservations'); + try { + isolated = await import('../../src/services/reservations'); + } finally { + Map.prototype.set = originalSet; + } + const generation = Object.freeze({}); + let cleanup: (() => void) | undefined; + const renderSource = admSource() as ReservationRenderSource; + const service = isolated.createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + let result: ReturnType | undefined; + let thrown: unknown; + try { + result = service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeUndefined(); + if (applyFirst) { + expect(result).toMatchObject({ ok: true }); + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + cleanup?.(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + } else { + expect(result).toEqual({ ok: false, reason: 'service_disposed' }); + expect(service.recognize(reservationId())).toEqual({ recognized: false }); + expect(cleanup).toBeTypeOf('function'); + expect(() => cleanup?.()).not.toThrow(); + } + }); + + it.each([ + ['throws before applying', false], + ['throws after applying', true], + ] as const)('contains a captured WeakMap.set that %s', async (_name, applyFirst) => { + vi.resetModules(); + const originalSet = WeakMap.prototype.set; + WeakMap.prototype.set = function poisonedOwnerSet(key, value) { + const record = value as Record | undefined; + if (!record || !('identity' in record) || !('ready' in record)) { + return Reflect.apply(originalSet, this, [key, value]) as WeakMap; + } + if (applyFirst) Reflect.apply(originalSet, this, [key, value]); + throw new Error('captured owner WeakMap.set failure'); + }; + let isolated: typeof import('../../src/services/reservations'); + try { + isolated = await import('../../src/services/reservations'); + } finally { + WeakMap.prototype.set = originalSet; + } + const generation = Object.freeze({}); + let cleanup: (() => void) | undefined; + const renderSource = admSource() as ReservationRenderSource; + const service = isolated.createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + let result: ReturnType | undefined; + let thrown: unknown; + try { + result = service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeUndefined(); + if (applyFirst) { + expect(result).toMatchObject({ ok: true }); + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + cleanup?.(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + } else { + expect(result).toEqual({ ok: false, reason: 'stale_owner' }); + expect(cleanup).toBeUndefined(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + } + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it('contains a captured WeakMap.get failure in a navigation callback', async () => { + vi.resetModules(); + const originalGet = WeakMap.prototype.get; + let poisoned = false; + WeakMap.prototype.get = function poisonedOwnerGet(key) { + if (poisoned) throw new Error('captured owner WeakMap.get failure'); + return Reflect.apply(originalGet, this, [key]) as unknown; + }; + let isolated: typeof import('../../src/services/reservations'); + try { + isolated = await import('../../src/services/reservations'); + } finally { + WeakMap.prototype.get = originalGet; + } + const generation = Object.freeze({}); + let cleanup: (() => void) | undefined; + const renderSource = admSource() as ReservationRenderSource; + const service = isolated.createReservationService({ + now: () => 0, + prepareRenderSource: (candidate) => (candidate === renderSource ? renderSource : undefined), + }); + expect( + service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }, + attemptId: 'a1_0000000000000000000000', + renderSource, + winnerContext: { selectedCpm: 1 }, + }) + ).toMatchObject({ ok: true }); + poisoned = true; + + expect(() => cleanup?.()).not.toThrow(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it('checks publication identity after the final reentrant owner call', () => { + const service = serviceAt(() => 0); + const generation = Object.freeze({}); + let cleanup: (() => void) | undefined; + let currentChecks = 0; + const owner: ReservationOwner = { + generation, + isCurrent: () => { + currentChecks += 1; + if (currentChecks === 3) cleanup?.(); + return true; + }, + onDispose: (_kind, callback) => { + cleanup = callback; + }, + }; + + expect( + service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: owner, + attemptId: 'a1_0000000000000000000000', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + }); + + it('tombstones a registration if owner generation changes during disposal publication', () => { + const service = serviceAt(() => 0); + const initialGeneration = Object.freeze({}); + let generation = initialGeneration; + const owner: ReservationOwner = { + get generation() { + return generation; + }, + isCurrent: () => true, + onDispose: () => { + generation = Object.freeze({}); + }, + }; + + expect( + service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: owner, + attemptId: 'a1_0000000000000000000000', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(service.recognize(reservationId())).toMatchObject({ + recognized: true, + state: 'disposed', + }); + }); + + it('preserves the established callback when another identity reuses its live generation', () => { + const service = serviceAt(() => 0); + const generation = Object.freeze({}); + let establishedCleanup: (() => void) | undefined; + const firstOwner: ReservationOwner = { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + establishedCleanup = callback; + }, + }; + expect( + service.registerRender({ + reservationId: reservationId(), + slot: 'first-slot', + navigation: firstOwner, + attemptId: 'a1_0000000000000000000000', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + }) + ).toMatchObject({ ok: true }); + const replacementOnDispose = vi.fn(() => { + throw new Error('replacement callback publication failed'); + }); + + expect( + service.registerRender({ + reservationId: reservationId(1), + slot: 'second-slot', + navigation: { + generation, + isCurrent: () => true, + onDispose: replacementOnDispose, + }, + attemptId: 'a1_0000000000000000000001', + renderSource: admSource(), + winnerContext: { selectedCpm: 2 }, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(replacementOnDispose).not.toHaveBeenCalled(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + expect(service.recognize(reservationId(1))).toMatchObject({ state: 'disposed' }); + + establishedCleanup?.(); + + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 2, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it('requires a fresh generation when a different owner identity arrives after expiry', () => { + let now = 0; + const service = serviceAt(() => now); + const generation = Object.freeze({}); + let oldCleanup: (() => void) | undefined; + const oldOwner: ReservationOwner = { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + oldCleanup = callback; + }, + }; + const input = { + reservationId: reservationId(), + slot: 'fictional-slot', + attemptId: 'a1_0000000000000000000000', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + }; + expect(service.registerRender({ ...input, navigation: oldOwner })).toMatchObject({ ok: true }); + now = RENDER_RESERVATION_LIFETIME_MS; + expect(service.recognize(reservationId())).toEqual({ recognized: false }); + + const newOwner: ReservationOwner = { + generation, + isCurrent: () => true, + onDispose: vi.fn(), + }; + expect(service.registerRender({ ...input, navigation: newOwner })).toEqual({ + ok: false, + reason: 'stale_owner', + }); + expect(newOwner.onDispose).not.toHaveBeenCalled(); + oldCleanup?.(); + + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); +}); + +describe('fixed expiry, capacity, and tombstones', () => { + it('is live exactly before the 15-minute boundary and prunes at and after expiry', () => { + for (const offset of [-1, 0, 1]) { + let now = 100; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + const registration = registerRender(service, navigation, attempt); + expect(registration).toEqual({ ok: true, expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS }); + + now = 100 + RENDER_RESERVATION_LIFETIME_MS + offset; + expect(service.recognize(reservationId()).recognized).toBe(offset < 0); + } + }); + + it.each([ + [ + 'throwing', + (): number => { + throw new Error('clock failed'); + }, + ], + ['nonfinite', (): number => Number.NaN], + ['backward', (): number => 99], + ] as const)('retains and suppresses every known id after a %s clock fault', (_name, fault) => { + let readNow = (): number => 100; + const { navigation } = runtimeNavigation(); + const liveAttempt = renderAttempt(navigation, 'live-slot'); + const tombstonedAttempt = renderAttempt(navigation, 'tombstoned-slot'); + const nextAttempt = renderAttempt(navigation, 'next-slot'); + const service = serviceAt(() => readNow()); + expect(registerRender(service, navigation, liveAttempt, reservationId())).toMatchObject({ + ok: true, + }); + expect(registerRender(service, navigation, tombstonedAttempt, reservationId(1))).toMatchObject({ + ok: true, + }); + expect(tombstone(service, navigation, tombstonedAttempt, reservationId(1), 'disposed')).toBe( + true + ); + + readNow = fault; + + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + expect(service.recognize(reservationId(1))).toMatchObject({ state: 'disposed' }); + expect(claim(service, navigation, liveAttempt)).toEqual({ + recognized: true, + claimed: false, + state: 'renderable', + }); + expect(claim(service, navigation, tombstonedAttempt, reservationId(1))).toEqual({ + recognized: true, + claimed: false, + state: 'disposed', + }); + expect(registerRender(service, navigation, nextAttempt, reservationId(2))).toEqual({ + ok: false, + reason: 'service_disposed', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + clockFaulted: true, + disposed: false, + size: 2, + live: 1, + tombstones: 1, + }); + }); + + it('releases live render and lease payloads when navigation disposes after a clock fault', () => { + let now = 100; + const { navigation, runtime } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + expect(registerRender(service, navigation, attempt, reservationId())).toEqual({ + ok: true, + expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS, + }); + expect( + service.registerPrebidLease({ + reservationId: reservationId(1), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }) + ).toEqual({ ok: true, expiresAt: 100 + PREBID_ADMISSION_LEASE_MS }); + now = Number.NaN; + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + + runtime.replaceNavigation(); + + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'disposed', + expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS, + }); + expect(service.recognize(reservationId(1))).toEqual({ + recognized: true, + state: 'aborted', + expiresAt: 100 + PREBID_ADMISSION_LEASE_MS, + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + clockFaulted: true, + live: 0, + tombstones: 2, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + entriesWithPucSource: 0, + }); + }); + + it('allows exact explicit terminal tombstones after a clock fault', () => { + let now = 100; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + registerRender(service, navigation, attempt, reservationId()); + const bid = Object.freeze({ cpm: 1 }); + const registerLease = (id: string, auctionId: string) => + service.registerPrebidLease({ + reservationId: id, + slot: 'fictional-slot', + navigation, + auctionId, + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + registerLease(reservationId(1), 'single-auction'); + registerLease(reservationId(2), 'group-auction'); + registerLease(reservationId(3), 'group-auction'); + now = Number.NaN; + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + + expect(tombstone(service, navigation, attempt, reservationId(), 'stale')).toBe(true); + expect( + service.tombstonePrebidLease( + { + reservationId: reservationId(1), + auctionId: 'single-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + 'prebid_admission_failed' + ) + ).toBe(true); + expect( + service.tombstonePrebidGroup( + { + auctionId: 'group-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + 'prebid_selection_timeout' + ) + ).toBe(2); + + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'stale', + expiresAt: 100 + RENDER_RESERVATION_LIFETIME_MS, + }); + for (const [index, state] of [ + [1, 'prebid_admission_failed'], + [2, 'prebid_selection_timeout'], + [3, 'prebid_selection_timeout'], + ] as const) { + expect(service.recognize(reservationId(index))).toEqual({ + recognized: true, + state, + expiresAt: 100 + PREBID_ADMISSION_LEASE_MS, + }); + } + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 4, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + entriesWithPucSource: 0, + }); + }); + + it.each([ + ['negative', (): number => -1], + ['nonfinite', (): number => Number.NaN], + [ + 'throwing', + (): number => { + throw new Error('clock failed'); + }, + ], + ['overflowing deadline', (): number => Number.MAX_VALUE], + ] as const)('fails closed without publication for a %s monotonic clock', (_name, now) => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(now); + + expect(registerRender(service, navigation, attempt)).toEqual({ + ok: false, + reason: 'service_disposed', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + disposed: true, + size: 0, + live: 0, + tombstones: 0, + }); + }); + + it('prunes safely while Array push and iteration prototypes are poisoned', () => { + let now = 0; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + registerRender(service, navigation, attempt); + now = RENDER_RESERVATION_LIFETIME_MS; + const originalPush = Array.prototype.push; + const originalIterator = Array.prototype[Symbol.iterator]; + let recognition: ReturnType | undefined; + Array.prototype.push = function poisonedPush() { + throw new Error('poisoned push'); + }; + Array.prototype[Symbol.iterator] = function poisonedIterator() { + throw new Error('poisoned iterator'); + }; + try { + recognition = service.recognize(reservationId()); + } finally { + Array.prototype.push = originalPush; + Array.prototype[Symbol.iterator] = originalIterator; + } + expect(recognition).toEqual({ recognized: false }); + }); + + it('uses captured Map iterator operations after their prototypes are poisoned', () => { + let now = 0; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + registerRender(service, navigation, attempt); + const originalValues = Map.prototype.values; + const originalEntries = Map.prototype.entries; + const iteratorPrototype = Object.getPrototypeOf(new Map().values()) as { + next: () => IteratorResult; + }; + const originalNext = iteratorPrototype.next; + let recognition: ReturnType | undefined; + Map.prototype.values = function poisonedValues() { + throw new Error('poisoned values'); + }; + Map.prototype.entries = function poisonedEntries() { + throw new Error('poisoned entries'); + }; + iteratorPrototype.next = function poisonedNext() { + throw new Error('poisoned next'); + }; + now = RENDER_RESERVATION_LIFETIME_MS; + try { + recognition = service.recognize(reservationId()); + } finally { + Map.prototype.values = originalValues; + Map.prototype.entries = originalEntries; + iteratorPrototype.next = originalNext; + } + expect(recognition).toEqual({ recognized: false }); + }); + + it('consumption never extends expiry and leaves only minimum suppression metadata', () => { + let now = 200; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + registerRender(service, navigation, attempt); + now = 400; + + expect(claim(service, navigation, attempt)).toMatchObject({ claimed: true }); + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'consumed', + expiresAt: 200 + RENDER_RESERVATION_LIFETIME_MS, + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + entriesWithPucSource: 0, + }); + }); + + it.each(['stale', 'disposed'] as const)( + 'retains an exact %s tombstone through the original expiry', + (state) => { + let now = 0; + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => now); + registerRender(service, navigation, attempt); + now = 50; + + expect(tombstone(service, navigation, attempt, reservationId(), state)).toBe(true); + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state, + expiresAt: RENDER_RESERVATION_LIFETIME_MS, + }); + expect(service.snapshotInventoryForTest().entriesWithRenderSource).toBe(0); + now = RENDER_RESERVATION_LIFETIME_MS; + expect(service.recognize(reservationId())).toEqual({ recognized: false }); + } + ); + + it('allows only the exact slot, generation, and attempt owner to tombstone a live entry', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + const exact = { + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attemptId: attempt.id, + }; + + expect(service.tombstone({ ...exact, slot: 'other-slot' }, 'stale')).toBe(false); + expect(service.tombstone({ ...exact, navigationGeneration: Object.freeze({}) }, 'stale')).toBe( + false + ); + expect(service.tombstone({ ...exact, attemptId: `${attempt.id}-other` }, 'stale')).toBe(false); + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + expect(service.tombstone(exact, 'stale')).toBe(true); + }); + + it('rejects invalid runtime tombstone states without changing live entries', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const bid = Object.freeze({ cpm: 1 }); + registerRender(service, navigation, attempt, reservationId()); + for (const index of [1, 2]) { + service.registerPrebidLease({ + reservationId: reservationId(index), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + } + const hostileState = Object.defineProperty({}, Symbol.toPrimitive, { + value() { + throw new Error('state must not be coerced'); + }, + }); + + expect( + service.tombstone( + { + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attemptId: attempt.id, + }, + hostileState as never + ) + ).toBe(false); + expect( + service.tombstonePrebidLease( + { + reservationId: reservationId(1), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + 'consumed' as never + ) + ).toBe(false); + expect( + service.tombstonePrebidGroup( + { + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + 'renderable' as never + ) + ).toBe(0); + expect(service.recognize(reservationId())).toMatchObject({ state: 'renderable' }); + expect(service.recognize(reservationId(1))).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + expect(service.recognize(reservationId(2))).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ live: 3, tombstones: 0 }); + }); + + it('shares capacity 320 across live and tombstones, never evicts, and still serves oldest', () => { + let now = 0; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const attempts: RenderAttemptScope[] = []; + for (let index = 0; index < 320; index += 1) { + const attempt = renderAttempt(navigation, `slot-${index}`); + attempts.push(attempt); + expect(registerRender(service, navigation, attempt, reservationId(index))).toMatchObject({ + ok: true, + }); + if (index % 2 === 0) { + tombstone(service, navigation, attempt, reservationId(index), 'disposed'); + } + } + const overflow = renderAttempt(navigation, 'overflow'); + + expect(registerRender(service, navigation, overflow, reservationId(320))).toEqual({ + ok: false, + reason: 'registry_full', + }); + expect(claim(service, navigation, attempts[1]!, reservationId(1))).toMatchObject({ + claimed: true, + }); + expect(service.snapshotInventoryForTest().size).toBe(320); + + now = RENDER_RESERVATION_LIFETIME_MS; + expect(registerRender(service, navigation, overflow, reservationId(320))).toMatchObject({ + ok: true, + }); + }); + + it('automatically tombstones navigation-owned live entries and retains no source/context', () => { + const { navigation, runtime } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + + runtime.replaceNavigation(); + + expect(service.recognize(reservationId())).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it('installs one owner disposer across sequential expired leases', () => { + let now = 0; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const bid = Object.freeze({ cpm: 1 }); + + for (let index = 0; index < 1_000; index += 1) { + expect( + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: `auction-${index}`, + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }) + ).toMatchObject({ ok: true }); + now += PREBID_ADMISSION_LEASE_MS; + expect(service.recognize(reservationId())).toEqual({ recognized: false }); + } + + expect(navigation.snapshotInventoryForTest().activeDisposers).toBe(1); + expect(service.snapshotInventoryForTest().size).toBe(0); + }); + + it('one owner callback tombstones every live state for its exact generation', () => { + const { navigation, runtime } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt, reservationId()); + service.registerPrebidLease({ + reservationId: reservationId(1), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }); + + expect(navigation.snapshotInventoryForTest().activeDisposers).toBe(1); + runtime.replaceNavigation(); + + expect(service.recognize(reservationId())).toMatchObject({ state: 'disposed' }); + expect(service.recognize(reservationId(1))).toMatchObject({ state: 'aborted' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ live: 0, tombstones: 2 }); + }); +}); + +describe('Prebid admission leases and selection', () => { + it('marks a navigation-disposed Prebid lease aborted through its original short expiry', () => { + const { navigation, runtime } = runtimeNavigation(); + const service = serviceAt(() => 10); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }); + + runtime.replaceNavigation(); + + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'aborted', + expiresAt: 10 + PREBID_ADMISSION_LEASE_MS, + }); + }); + + it('does not adopt context when a clock jump makes promotion stale', () => { + let now = 0; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const bid = Object.freeze({ cpm: 1 }); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + const attempt = renderAttempt(navigation); + now = Number.MAX_VALUE; + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }) + ).toEqual({ ok: false, reason: 'reservation_not_live' }); + expect(attempt.winnerContext).toBeUndefined(); + expect(service.snapshotInventoryForTest().live).toBe(0); + }); + + it('requires a frozen bid with exact CPM equality and does not retain native Prebid identity', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + const base = { + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1.25 }, + }; + + expect(service.registerPrebidLease({ ...base, prebidBid: { cpm: 1.25 } })).toEqual({ + ok: false, + reason: 'prebid_cpm_mismatch', + }); + expect( + service.registerPrebidLease({ ...base, prebidBid: Object.freeze({ cpm: 2, adId: 'native' }) }) + ).toEqual({ ok: false, reason: 'prebid_cpm_mismatch' }); + expect( + service.registerPrebidLease({ ...base, prebidBid: Object.freeze({ cpm: 1.25 }) }) + ).toEqual({ ok: true, expiresAt: PREBID_ADMISSION_LEASE_MS }); + expect(service.recognize('native')).toEqual({ recognized: false }); + }); + + it('promotes one selected ADM lease from ten seconds to 15 minutes', () => { + let now = 10; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const bid = Object.freeze({ cpm: 1.25 }); + const base = { + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1.25 }, + prebidBid: bid, + }; + expect(service.registerPrebidLease({ ...base, reservationId: reservationId(1) })).toEqual({ + ok: true, + expiresAt: 10 + PREBID_ADMISSION_LEASE_MS, + }); + expect(service.registerPrebidLease({ ...base, reservationId: reservationId(2) })).toMatchObject( + { + ok: true, + } + ); + const attempt = renderAttempt(navigation); + now = 1_000; + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(1), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }) + ).toEqual({ ok: true, expiresAt: 1_000 + RENDER_RESERVATION_LIFETIME_MS }); + expect(attempt.winnerContext).toEqual({ selectedCpm: 1.25 }); + expect(service.recognize(reservationId(1))).toMatchObject({ + recognized: true, + state: 'renderable', + expiresAt: 1_000 + RENDER_RESERVATION_LIFETIME_MS, + }); + expect(service.recognize(reservationId(2))).toEqual({ + recognized: true, + state: 'unselected', + expiresAt: 10 + PREBID_ADMISSION_LEASE_MS, + }); + const selected = claim(service, navigation, attempt, reservationId(1)); + const winnerContext = attempt.winnerContext; + if (!selected.recognized || !selected.claimed || !winnerContext) { + throw new Error('Expected the promoted ADM lease to remain claimable'); + } + expect( + service.consumeClaim(selected, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext, + }) + ).toEqual({ renderSource: admSource(), winnerContext }); + }); + + it('promotes only before the admission boundary and prunes at and after ten seconds', () => { + for (const offset of [-1, 0, 1]) { + let now = 100; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + const bid = Object.freeze({ cpm: 1 }); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + const attempt = renderAttempt(navigation); + now = 100 + PREBID_ADMISSION_LEASE_MS + offset; + + const result = service.promotePrebidSelection({ + reservationId: reservationId(), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }); + expect(result.ok).toBe(offset < 0); + expect(service.recognize(reservationId()).recognized).toBe(offset < 0); + } + }); + + it('tombstones losers only in the selected exact auction and ad unit', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + const bid = Object.freeze({ cpm: 1 }); + const register = (id: string, auctionId: string, adUnitCode: string) => + service.registerPrebidLease({ + reservationId: id, + slot: adUnitCode, + navigation, + auctionId, + adUnitCode, + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + register(reservationId(1), 'selected-auction', 'selected-slot'); + register(reservationId(2), 'selected-auction', 'selected-slot'); + register(reservationId(3), 'other-auction', 'selected-slot'); + register(reservationId(4), 'selected-auction', 'other-slot'); + const attempt = renderAttempt(navigation, 'selected-slot'); + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(1), + auctionId: 'selected-auction', + adUnitCode: 'selected-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }) + ).toMatchObject({ ok: true }); + expect(service.recognize(reservationId(2))).toMatchObject({ state: 'unselected' }); + expect(service.recognize(reservationId(3))).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + expect(service.recognize(reservationId(4))).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + }); + + it('does not tombstone a same-string loser owned by another navigation generation', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + const bid = Object.freeze({ cpm: 1 }); + const selected = renderAttempt(navigation, 'fictional-slot'); + service.registerPrebidLease({ + reservationId: reservationId(1), + slot: 'fictional-slot', + navigation, + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + const otherGeneration = Object.freeze({}); + service.registerPrebidLease({ + reservationId: reservationId(2), + slot: 'fictional-slot', + navigation: { + generation: otherGeneration, + isCurrent: () => true, + onDispose: vi.fn(), + }, + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(1), + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt: selected, + prebidBid: bid, + }) + ).toMatchObject({ ok: true }); + expect(service.recognize(reservationId(2))).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + }); + + it('promotes and tombstones losers atomically under poisoned Array prototypes', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + const bid = Object.freeze({ cpm: 1 }); + for (const id of [reservationId(1), reservationId(2)]) { + service.registerPrebidLease({ + reservationId: id, + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + } + const attempt = renderAttempt(navigation); + const originalPush = Array.prototype.push; + const originalIterator = Array.prototype[Symbol.iterator]; + let result: ReturnType | undefined; + Array.prototype.push = function poisonedPush() { + throw new Error('poisoned push'); + }; + Array.prototype[Symbol.iterator] = function poisonedIterator() { + throw new Error('poisoned iterator'); + }; + try { + result = service.promotePrebidSelection({ + reservationId: reservationId(1), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }); + } finally { + Array.prototype.push = originalPush; + Array.prototype[Symbol.iterator] = originalIterator; + } + expect(result).toMatchObject({ ok: true }); + expect(service.recognize(reservationId(2))).toMatchObject({ state: 'unselected' }); + }); + + it.each(['aborted', 'prebid_selection_timeout', 'unselected'] as const)( + 'tombstones %s leases only through their original admission expiry', + (reason) => { + let now = 25; + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => now); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 0 }, + prebidBid: Object.freeze({ cpm: 0 }), + }); + now = 50; + + expect( + service.tombstonePrebidGroup( + { + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + reason + ) + ).toBe(1); + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: reason, + expiresAt: 25 + PREBID_ADMISSION_LEASE_MS, + }); + } + ); + + it('makes a stale navigation Prebid group tombstone callback inert', () => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }); + + expect( + service.tombstonePrebidGroup( + { + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: Object.freeze({}), + }, + 'aborted' + ) + ).toBe(0); + expect(service.recognize(reservationId())).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + expect( + service.tombstonePrebidGroup( + { + auctionId: 'reused-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }, + 'aborted' + ) + ).toBe(1); + }); + + it('suppresses and contract-failure tombstones a PUC claim against a preselection lease', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: attempt.slot, + navigation, + auctionId: 'fictional-auction', + adUnitCode: attempt.slot, + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }); + + expect(claim(service, navigation, attempt)).toEqual({ + recognized: true, + claimed: false, + state: 'prebid_contract_violation', + }); + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state: 'prebid_contract_violation', + expiresAt: PREBID_ADMISSION_LEASE_MS, + }); + expect(attempt.winnerContext).toBeUndefined(); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it.each(['prebid_admission_failed', 'prebid_contract_violation'] as const)( + 'tombstones exact-owner %s admission failure through the original lease', + (state) => { + const { navigation } = runtimeNavigation(); + const service = serviceAt(() => 0); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation, + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: Object.freeze({ cpm: 1 }), + }); + const exact = { + reservationId: reservationId(), + auctionId: 'fictional-auction', + adUnitCode: 'fictional-slot', + navigationGeneration: navigation.generation, + }; + + expect( + service.tombstonePrebidLease({ ...exact, navigationGeneration: Object.freeze({}) }, state) + ).toBe(false); + expect(service.tombstonePrebidLease(exact, state)).toBe(true); + expect(service.recognize(reservationId())).toEqual({ + recognized: true, + state, + expiresAt: PREBID_ADMISSION_LEASE_MS, + }); + } + ); +}); + +describe('atomic claims and disposal', () => { + it('does not acquire, transfer, or consume for a mismatched slot, generation, attempt, or stale owner', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const source = Object.freeze({}); + const cases = [ + { slot: 'other-slot', generation: navigation.generation, attempted: attempt }, + { slot: attempt.slot, generation: Object.freeze({}), attempted: attempt }, + { + slot: attempt.slot, + generation: navigation.generation, + attempted: { ...attempt, id: `${attempt.id}-other` }, + }, + { + slot: attempt.slot, + generation: navigation.generation, + attempted: { ...attempt, isCurrent: () => false }, + }, + ]; + + for (const [index, candidate] of cases.entries()) { + const id = reservationId(index + 10); + registerRender(service, navigation, attempt, id); + expect( + service.claim({ + reservationId: id, + slot: candidate.slot, + navigationGeneration: candidate.generation, + attempt: candidate.attempted, + pucSource: source, + }) + ).toEqual({ recognized: true, claimed: false, state: 'renderable' }); + expect(service.recognize(id)).toMatchObject({ state: 'renderable' }); + } + expect(attempt.winnerContext).toBeUndefined(); + expect(service.snapshotInventoryForTest().entriesWithPucSource).toBe(0); + }); + it('preserves one ADM source and immutable context after registration input mutation', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const source = admSource(); + const context = { selectedCpm: 7.5 }; + service.registerRender({ + reservationId: reservationId(), + slot: attempt.slot, + navigation, + attemptId: attempt.id, + renderSource: source, + winnerContext: context, + }); + context.selectedCpm = 99; + const observedStates: string[] = []; + const sink = { + id: attempt.id, + slot: attempt.slot, + get winnerContext(): WinnerContext | undefined { + return attempt.winnerContext; + }, + isCurrent: () => attempt.isCurrent(), + prepareWinnerContext(winnerContext: WinnerContext) { + const recognition = service.recognize(reservationId()); + if (recognition.recognized) observedStates.push(recognition.state); + return attempt.prepareWinnerContext(winnerContext); + }, + }; + + const result = service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: sink, + pucSource: Object.freeze({}), + }); + + expect(observedStates).toEqual(['renderable']); + expect(result).toMatchObject({ recognized: true, claimed: true }); + expect(attempt.winnerContext).toEqual({ selectedCpm: 7.5 }); + expect(Object.isFrozen(attempt.winnerContext)).toBe(true); + expect(service.recognize(reservationId())).toMatchObject({ state: 'consumed' }); + const winnerContext = attempt.winnerContext; + if (!result.recognized || !result.claimed || !winnerContext) { + throw new Error('Expected one claimed ADM winner'); + } + expect( + service.consumeClaim(result, { + attempt: sink, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext, + }) + ).toEqual({ renderSource: source, winnerContext }); + }); + + it('allows exactly one of two simultaneous/reentrant claims and never replaces its PUC source', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + const firstSource = Object.freeze({ name: 'first' }); + const secondSource = Object.freeze({ name: 'second' }); + let nested: ReturnType | undefined; + let acceptedContext: WinnerContext | undefined; + const sink = { + id: attempt.id, + slot: attempt.slot, + get winnerContext(): WinnerContext | undefined { + return acceptedContext; + }, + isCurrent: () => true, + prepareWinnerContext(context: WinnerContext) { + return { + commit(): boolean { + nested = service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: sink, + pucSource: secondSource, + }); + acceptedContext = context; + return true; + }, + rollback(): boolean { + if (acceptedContext === context) acceptedContext = undefined; + return true; + }, + }; + }, + }; + + const first = service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: sink, + pucSource: firstSource, + }); + + expect(first).toMatchObject({ recognized: true, claimed: true, pucSource: firstSource }); + expect(nested).toEqual({ recognized: true, claimed: false, state: 'renderable' }); + expect(claim(service, navigation, attempt, reservationId(), secondSource)).toEqual({ + recognized: true, + claimed: false, + state: 'consumed', + }); + }); + + it('terminally suppresses a throwing context preparation without retaining PUC source', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + const throwingSink = { + id: attempt.id, + slot: attempt.slot, + winnerContext: undefined, + isCurrent: () => true, + prepareWinnerContext() { + throw new Error('partial transfer failed'); + }, + }; + + expect( + service.claim({ + reservationId: reservationId(), + slot: attempt.slot, + navigationGeneration: navigation.generation, + attempt: throwingSink, + pucSource: Object.freeze({}), + }) + ).toEqual({ recognized: true, claimed: false, state: 'stale' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithPucSource: 0, + }); + }); + + it('terminally suppresses a claim when winner admission mutates, reenters, and throws', () => { + const { navigation } = runtimeNavigation(); + const realAttempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, realAttempt); + const firstSource = Object.freeze({ name: 'first' }); + const secondSource = Object.freeze({ name: 'second' }); + let accepted: WinnerContext | undefined; + let nested: ReturnType | undefined; + const attempt = { + id: realAttempt.id, + slot: realAttempt.slot, + get winnerContext(): WinnerContext | undefined { + return accepted; + }, + isCurrent: () => true, + prepareWinnerContext(context: WinnerContext) { + return { + commit(): boolean { + accepted = context; + nested = service.claim({ + reservationId: reservationId(), + slot: realAttempt.slot, + navigationGeneration: navigation.generation, + attempt, + pucSource: secondSource, + }); + throw new Error('commit failed after mutation'); + }, + rollback(): boolean { + if (accepted === context) accepted = undefined; + return true; + }, + }; + }, + }; + + expect( + service.claim({ + reservationId: reservationId(), + slot: realAttempt.slot, + navigationGeneration: navigation.generation, + attempt, + pucSource: firstSource, + }) + ).toEqual({ recognized: true, claimed: false, state: 'stale' }); + expect(nested).toEqual({ recognized: true, claimed: false, state: 'renderable' }); + expect(accepted).toBeUndefined(); + expect(claim(service, navigation, realAttempt, reservationId(), secondSource)).toEqual({ + recognized: true, + claimed: false, + state: 'stale', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithPucSource: 0, + }); + }); + + it('terminally suppresses a Prebid promotion when winner admission has unknown postcondition', () => { + const { navigation } = runtimeNavigation(); + const realAttempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + const bid = Object.freeze({ cpm: 1 }); + service.registerPrebidLease({ + reservationId: reservationId(), + slot: realAttempt.slot, + navigation, + auctionId: 'fictional-auction', + adUnitCode: realAttempt.slot, + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + prebidBid: bid, + }); + let accepted: WinnerContext | undefined; + const attempt = { + id: realAttempt.id, + slot: realAttempt.slot, + get winnerContext(): WinnerContext | undefined { + throw new Error('winner context postcondition unavailable'); + }, + isCurrent: () => true, + prepareWinnerContext(context: WinnerContext) { + return { + commit(): boolean { + accepted = context; + return true; + }, + rollback(): boolean { + if (accepted === context) accepted = undefined; + return true; + }, + }; + }, + }; + + expect( + service.promotePrebidSelection({ + reservationId: reservationId(), + auctionId: 'fictional-auction', + adUnitCode: realAttempt.slot, + navigationGeneration: navigation.generation, + attempt, + prebidBid: bid, + }) + ).toEqual({ ok: false, reason: 'invalid_attempt' }); + expect(accepted).toBeUndefined(); + expect(service.recognize(reservationId())).toMatchObject({ state: 'stale' }); + expect( + service.promotePrebidSelection({ + reservationId: reservationId(), + auctionId: 'fictional-auction', + adUnitCode: realAttempt.slot, + navigationGeneration: navigation.generation, + attempt: realAttempt, + prebidBid: bid, + }) + ).toEqual({ ok: false, reason: 'reservation_not_live' }); + }); + + it('uses captured freezing during a claim without retaining busy claim state', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + const pucSource = Object.freeze({}); + const originalFreeze = Object.freeze; + let result: ReturnType | undefined; + let thrown: unknown; + + Object.freeze = function poisonedFreeze() { + throw new Error('poisoned Object.freeze'); + }; + try { + result = claim(service, navigation, attempt, reservationId(), pucSource); + } catch (error) { + thrown = error; + } finally { + Object.freeze = originalFreeze; + } + + expect(thrown).toBeUndefined(); + expect(result).toMatchObject({ recognized: true, claimed: true }); + expect(service.recognize(reservationId())).toMatchObject({ state: 'consumed' }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithPucSource: 0, + }); + }); + + it('retains only a disposed suppression tombstone when owner publication rolls back', () => { + const service = serviceAt(() => 0); + const generation = Object.freeze({}); + const owner: ReservationOwner = { + generation, + isCurrent: () => true, + onDispose: (_kind, callback) => { + callback(); + throw new Error('publication failed after disposal'); + }, + }; + + expect( + service.registerRender({ + reservationId: reservationId(), + slot: 'fictional-slot', + navigation: owner, + attemptId: 'a1_0000000000000000000000', + renderSource: admSource(), + winnerContext: { selectedCpm: 1 }, + }) + ).toEqual({ ok: false, reason: 'stale_owner' }); + expect(service.recognize(reservationId())).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(service.snapshotInventoryForTest()).toMatchObject({ + live: 0, + tombstones: 1, + entriesWithRenderSource: 0, + entriesWithWinnerContext: 0, + }); + }); + + it('disposes the whole runtime store without making old identities reusable in that service', () => { + const { navigation } = runtimeNavigation(); + const attempt = renderAttempt(navigation); + const service = serviceAt(() => 0); + registerRender(service, navigation, attempt); + + service.dispose(); + + expect(service.snapshotInventoryForTest()).toMatchObject({ disposed: true, size: 0 }); + expect(registerRender(service, navigation, attempt)).toEqual({ + ok: false, + reason: 'service_disposed', + }); + expect(service.recognize(reservationId())).toEqual({ recognized: false }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts new file mode 100644 index 000000000..3a94ca5a6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -0,0 +1,4637 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createBrowserGoogletagAdapter, + GoogletagReplacementError, + type GoogletagAdapter, + type GoogletagFacade, + type GoogletagPublisherCallAdmission, + type GoogletagReplacementCommitAdmission, + type GoogletagReplacementDefinition, + type GptSlotTokenV1, +} from '../../src/adapters/googletag'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { createRuntimeSession, type NavigationSession } from '../../src/kernel/sessions'; +import { + MAX_ACTIVE_SLOT_RECORDS, + createBrowserSlotReconciliationBoundary, + createSlotService, + type GptSlotBinding, + type SlotReconciliationBoundary, + type SlotRegistration, + type SlotService, +} from '../../src/services/slots'; + +function createNavigation(): NavigationSession { + return createRuntimeWithNavigation().navigation; +} + +function createRuntimeWithNavigation() { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(1); + return target; + }, + }), + }); + const result = runtime.startInitialNavigation(); + if (!result.ok) throw new Error('Expected a navigation'); + return { navigation: result.value, runtime }; +} + +function createGptHarness( + options: { + initialLoadDisabled?: boolean; + deferDestroyedResult?: boolean; + missingRefresh?: boolean; + orphanOnReplace?: object; + returnOldOnReplace?: boolean; + synchronousRun?: boolean; + } = {} +) { + const listeners = new Map void>>(); + const slots: object[] = []; + const display = vi.fn(); + const refresh = vi.fn(); + const destroySlots = vi.fn((_slots: readonly object[]) => true); + const defineSlot = vi.fn( + (_path: string, _sizes: unknown, elementId: string): object | undefined => { + const slot = { elementId, replacement: true }; + slots.push(slot); + return slot; + } + ); + const addService = vi.fn(); + const operationDisposals: Array> = []; + const bindingToken = Object.freeze({}); + const traceTokens = new WeakMap(); + let nextTraceToken = 1; + let deferredDestroyedResolved = false; + let resolveDeferredDestroyedPromise!: () => void; + const deferredDestroyedPromise = new Promise((resolve) => { + resolveDeferredDestroyedPromise = resolve; + }); + let deferredDestroyedUsed = false; + const facade: GoogletagFacade = Object.freeze({ + bindingToken: () => bindingToken, + clearTargeting: vi.fn(), + transactionalDefine: () => Object.freeze({ status: 'discarded' as const }), + display, + getTargeting: vi.fn(() => []), + observeTargeting: () => Object.assign(vi.fn(), { isCurrent: () => true }), + refresh: options.missingRefresh + ? (undefined as unknown as GoogletagFacade['refresh']) + : refresh, + serviceState: () => + Object.freeze({ + apiReady: true, + initialLoadDisabled: options.initialLoadDisabled === true, + pubadsReady: true, + }), + setTargeting: vi.fn(), + slotElementId: () => undefined, + slots: () => Object.freeze([...slots]), + subscribe: (eventType: string, listener: (event: unknown) => void) => { + const registered = listeners.get(eventType) ?? new Set(); + registered.add(listener); + listeners.set(eventType, registered); + return () => registered.delete(listener); + }, + transactionalReplace: ( + oldSlot: object, + definition: GoogletagReplacementDefinition | undefined, + isCurrent: () => boolean, + prepareCommit: (replacement: object) => GoogletagReplacementCommitAdmission + ) => { + if (!destroySlots([oldSlot])) throw new Error('gpt_request_failed'); + if (!definition || !isCurrent()) return Object.freeze({ status: 'destroyed' as const }); + const replacement = options.returnOldOnReplace + ? oldSlot + : defineSlot(definition.adUnitPath, definition.sizes, definition.elementId); + if (!replacement) throw new GoogletagReplacementError(undefined, true); + if (replacement === oldSlot) { + if (!destroySlots([replacement])) { + throw new GoogletagReplacementError(replacement, true); + } + throw new GoogletagReplacementError(undefined, true); + } + if (!isCurrent()) { + destroySlots([replacement]); + return Object.freeze({ status: 'destroyed' as const }); + } + addService(replacement); + if (options.orphanOnReplace) { + throw new GoogletagReplacementError(options.orphanOnReplace, true); + } + if (!isCurrent()) { + destroySlots([replacement]); + return Object.freeze({ status: 'destroyed' as const }); + } + const admission = prepareCommit(replacement); + if (!admission.commit()) { + admission.rollback(); + destroySlots([replacement]); + throw new Error('gpt_request_failed'); + } + if (!isCurrent()) { + admission.rollback(); + destroySlots([replacement]); + return Object.freeze({ status: 'destroyed' as const }); + } + return Object.freeze({ status: 'replaced' as const, slot: replacement }); + }, + }); + const adapter: GoogletagAdapter = Object.freeze({ + bindingStatus: () => 'present', + dispose: vi.fn(), + notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), + observePublisherCalls: () => vi.fn(), + traceToken: (slot: object) => { + let token = traceTokens.get(slot); + if (!token) { + token = `gt1_${nextTraceToken.toString(36)}` as GptSlotTokenV1; + nextTraceToken += 1; + traceTokens.set(slot, token); + } + return token; + }, + run: (command: (gpt: Readonly) => T) => { + let disposed = false; + const dispose = vi.fn(() => { + disposed = true; + }); + operationDisposals.push(dispose); + let result: Promise; + if (options.synchronousRun !== false) { + try { + const value = command(facade); + const deferResult = + options.deferDestroyedResult === true && + !deferredDestroyedUsed && + typeof value === 'object' && + value !== null && + 'status' in value && + value.status === 'destroyed'; + if (deferResult) { + deferredDestroyedUsed = true; + result = deferredDestroyedPromise.then(() => value); + } else { + result = Promise.resolve(value); + } + } catch (error) { + result = Promise.reject(error); + } + } else { + result = Promise.resolve().then(() => { + if (disposed) throw new Error('disposed'); + return command(facade); + }); + } + return Object.freeze({ + status: 'present' as const, + result, + dispose, + }); + }, + }); + return { + adapter, + addService, + defineSlot, + destroySlots, + display, + emit: (type: string, event: unknown) => { + for (const listener of listeners.get(type) ?? []) listener(event); + }, + facade, + operationDisposals, + refresh, + resolveDeferredDestroyed: () => { + if (deferredDestroyedResolved) return; + deferredDestroyedResolved = true; + resolveDeferredDestroyedPromise(); + }, + }; +} + +function serverRegistration( + id: string, + overrides: Partial = {} +): SlotRegistration { + return { + registeredSlotId: id, + source: 'server', + ...overrides, + }; +} + +function bindTrustedSlot(service: SlotService, navigation: NavigationSession, id = 'slot') { + const slot = { id }; + expect( + service.register(navigation, [ + serverRegistration(id, { + adUnitCode: `/network/${id}`, + domAliases: [`${id}-div`], + }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: `/network/${id}`, + elementId: `${id}-div`, + sizes: Object.freeze([[300, 250]]), + }, + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + return slot; +} + +function createReconciliationBoundary() { + let listener: (() => void) | undefined; + const connected = new WeakSet(); + const elements = new Map(); + const observe = vi.fn((callback: () => void) => { + listener = callback; + return vi.fn(() => { + if (listener === callback) listener = undefined; + }); + }); + const boundary: SlotReconciliationBoundary = Object.freeze({ + observe, + isConnected: (element: object) => connected.has(element), + resolve: (elementIds: readonly string[]) => { + const matches = new Set(); + let matchedId: string | undefined; + for (const elementId of elementIds) { + for (const element of elements.get(elementId) ?? []) { + if (!connected.has(element)) continue; + matches.add(element); + matchedId = elementId; + } + } + if (matches.size === 0) return Object.freeze({ status: 'unresolved' as const }); + if (matches.size !== 1 || matchedId === undefined) { + return Object.freeze({ status: 'ambiguous' as const }); + } + return Object.freeze({ + status: 'unique' as const, + element: [...matches][0]!, + elementId: matchedId, + }); + }, + }); + const put = (elementId: string, element: object): void => { + connected.add(element); + elements.set(elementId, [element]); + }; + const replace = (elementId: string, element: object): void => { + const previous = elements.get(elementId) ?? []; + for (const candidate of previous) connected.delete(candidate); + put(elementId, element); + listener?.(); + }; + const replaceAmbiguously = (elementId: string, replacements: readonly object[]): void => { + const previous = elements.get(elementId) ?? []; + for (const candidate of previous) connected.delete(candidate); + for (const replacement of replacements) connected.add(replacement); + elements.set(elementId, [...replacements]); + listener?.(); + }; + const disconnect = (elementId: string): void => { + for (const candidate of elements.get(elementId) ?? []) connected.delete(candidate); + elements.delete(elementId); + listener?.(); + }; + return { + boundary, + disconnect, + observe, + put, + replace, + replaceAmbiguously, + trigger: () => listener?.(), + }; +} + +describe('slot registry', () => { + afterEach(() => vi.useRealTimers()); + + it('copies the adapter-owned canonical token into the adopted SlotRecord', () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const physical = {}; + expect(service.register(navigation, [serverRegistration('token-slot')])).toMatchObject({ + ok: true, + }); + + expect( + service.adoptGptSlot(navigation.generation, 'token-slot', { + ownership: 'publisher', + slot: physical, + }) + ).toEqual({ ok: true }); + const record = service.resolveRegisteredSlot('token-slot'); + expect(record?.traceToken).toBe('gt1_1'); + expect(Object.isFrozen(record)).toBe(true); + expect(harness.adapter.traceToken(physical)).toBe(record?.traceToken); + }); + + it('accepts exact nonempty 256-byte ids and rejects empty, 257-byte, and ASCII controls', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const valid = `${'a'.repeat(254)}é`; + + expect(new TextEncoder().encode(valid)).toHaveLength(256); + expect(service.register(navigation, [serverRegistration(valid)])).toMatchObject({ ok: true }); + + for (const invalid of [ + '', + 'a'.repeat(257), + 'nul\0id', + 'line\nid', + `del${String.fromCharCode(0x7f)}id`, + ]) { + expect(service.register(navigation, [serverRegistration(invalid)])).toEqual({ + ok: false, + reason: 'invalid_slot_id', + }); + } + + expect( + service.register(navigation, [serverRegistration(`c1${String.fromCharCode(0x85)}id`)]) + ).toMatchObject({ ok: true }); + }); + + it('reserves the combined 256-record capacity atomically', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const first = Array.from({ length: 255 }, (_, index) => serverRegistration(`server-${index}`)); + + expect(service.register(navigation, first)).toMatchObject({ ok: true }); + expect( + service.register(navigation, [ + { registeredSlotId: 'programmatic-256', source: 'programmatic' }, + ]) + ).toMatchObject({ ok: true }); + expect(service.snapshotForTest().records).toBe(MAX_ACTIVE_SLOT_RECORDS); + expect( + service.register(navigation, [ + { registeredSlotId: 'programmatic-257', source: 'programmatic' }, + ]) + ).toEqual({ ok: false, reason: 'registry_capacity' }); + expect(service.resolveRegisteredSlot('programmatic-257')).toBeUndefined(); + expect(service.snapshotForTest().records).toBe(MAX_ACTIVE_SLOT_RECORDS); + }); + + it('snapshots navigation-local registration order with detached programmatic auction units', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const directAuctionUnit = Object.freeze({ code: 'programmatic' }); + + expect( + service.register(navigation, [ + serverRegistration('server'), + { + directAuctionUnit, + registeredSlotId: 'programmatic', + source: 'programmatic', + }, + ]) + ).toMatchObject({ ok: true }); + expect(service.snapshotRegisteredSlots(navigation)).toEqual([ + expect.objectContaining({ ordinal: 0, registeredSlotId: 'server', source: 'server' }), + expect.objectContaining({ + directAuctionUnit, + ordinal: 1, + registeredSlotId: 'programmatic', + source: 'programmatic', + }), + ]); + expect(Object.isFrozen(service.snapshotRegisteredSlots(navigation))).toBe(true); + + expect( + service.register(navigation, [ + { + directAuctionUnit: { code: 'unfrozen' }, + registeredSlotId: 'unfrozen', + source: 'programmatic', + }, + ]) + ).toEqual({ ok: false, reason: 'invalid_slot_id' }); + + runtime.dispose(); + expect(service.snapshotRegisteredSlots(navigation)).toBeUndefined(); + }); + + it('rejects exact registered-id collisions without partial indexes', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + expect(service.register(navigation, [serverRegistration('existing')])).toMatchObject({ + ok: true, + }); + + expect( + service.register(navigation, [ + serverRegistration('fresh', { domAliases: ['fresh-div'] }), + serverRegistration('existing', { domAliases: ['leaked-div'] }), + ]) + ).toEqual({ ok: false, reason: 'duplicate_slot' }); + expect(service.resolveRegisteredSlot('fresh')).toBeUndefined(); + expect(service.resolveDomAlias('fresh-div')).toBeUndefined(); + }); + + it('resolves only unique ad-unit codes and DOM aliases without normalizing or choosing first', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + expect( + service.register(navigation, [ + serverRegistration('Exact-Slot', { adUnitCode: '/same', domAliases: ['same-div'] }), + serverRegistration('other', { adUnitCode: '/same', domAliases: ['same-div'] }), + ]) + ).toMatchObject({ ok: true }); + + expect(service.resolveRegisteredSlot('Exact-Slot')?.registeredSlotId).toBe('Exact-Slot'); + expect(service.resolveRegisteredSlot('exact-slot')).toBeUndefined(); + expect(service.resolveAdUnitCode('/same')).toBeUndefined(); + expect(service.resolveDomAlias('same-div')).toBeUndefined(); + }); + + it('binds one GPT object identity to at most one record and releases navigation records', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const shared = {}; + expect( + service.register(navigation, [serverRegistration('one'), serverRegistration('two')]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'one', { + ownership: 'publisher', + slot: shared, + }) + ).toEqual({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'two', { + ownership: 'publisher', + slot: shared, + }) + ).toEqual({ ok: false, reason: 'gpt_object_collision' }); + + navigation.dispose(); + expect(service.snapshotForTest().records).toBe(0); + expect(service.resolveRegisteredSlot('one')).toBeUndefined(); + }); + + it('latches a publication request to the exact bound GPT identity', async () => { + const gpt = createGptHarness(); + const service = createSlotService({ googletag: gpt.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + + const stale = service.request({ + expectedSlot: {}, + intentId: 'stale-publication', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(stale.result).resolves.toEqual({ status: 'failed', reason: 'slot_unresolved' }); + expect(gpt.display).not.toHaveBeenCalled(); + + const current = service.request({ + expectedSlot: slot, + intentId: 'current-publication', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await Promise.resolve(); + expect(current.status).toBe('active'); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(slot); + }); + + it('recognizes the exact live GPT binding regardless of who defined the slot', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const trustedSlot = bindTrustedSlot(service, navigation, 'trusted'); + + expect(service.isBoundGptSlot(navigation.generation, 'trusted', trustedSlot)).toBe(true); + expect(service.isBoundGptSlot(navigation.generation, 'other', trustedSlot)).toBe(false); + expect(service.isBoundGptSlot({}, 'trusted', trustedSlot)).toBe(false); + expect(service.isBoundGptSlot(navigation.generation, 'trusted', {})).toBe(false); + + const publisherSlot = {}; + expect(service.register(navigation, [serverRegistration('publisher')])).toMatchObject({ + ok: true, + }); + expect( + service.adoptGptSlot(navigation.generation, 'publisher', { + ownership: 'publisher', + slot: publisherSlot, + }) + ).toEqual({ ok: true }); + expect(service.isBoundGptSlot(navigation.generation, 'publisher', publisherSlot)).toBe(true); + + runtime.dispose(); + expect(service.isBoundGptSlot(navigation.generation, 'trusted', trustedSlot)).toBe(false); + }); + + it('hands an exact late publisher definition the TS slot and consumes only duplicate requests', async () => { + const gpt = createGptHarness({ initialLoadDisabled: true }); + const warnPublisherHandoffMismatch = vi.fn(() => { + throw new Error('fictional local logger failure'); + }); + const service = createSlotService({ + googletag: gpt.adapter, + warnPublisherHandoffMismatch, + }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const slot = bindTrustedSlot(service, navigation); + + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/publisher/mismatch', + elementId: 'slot-div', + initialLoadDisabled: true, + sizes: Object.freeze([[728, 90]]), + }) + ).toEqual({ action: 'handoff', slot }); + expect(warnPublisherHandoffMismatch).toHaveBeenCalledExactlyOnceWith( + 'GPT publisher handoff metadata mismatch', + Object.freeze({ formatsMismatch: true, pathMismatch: true }) + ); + expect(JSON.stringify(warnPublisherHandoffMismatch.mock.calls[0]).length).toBeLessThanOrEqual( + 128 + ); + expect( + service.preparePublisherDisplay({ initialLoadDisabled: true, target: 'slot-div' }) + ).toEqual({ action: 'suppress' }); + expect( + service.preparePublisherDisplay({ initialLoadDisabled: true, target: 'slot-div' }) + ).toEqual({ action: 'forward' }); + + const unrelated = {}; + expect( + service.preparePublisherRefresh({ + requestedSlots: undefined, + slots: Object.freeze([slot, unrelated]), + }) + ).toEqual({ action: 'replace', slots: [unrelated] }); + const forwardedRefresh = service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }); + expect(forwardedRefresh.action).toBe('forward'); + if (forwardedRefresh.action === 'forward') { + expect(forwardedRefresh.admission).toBeDefined(); + forwardedRefresh.admission?.commit(); + } + + const request = service.request({ + intentId: 'after-publisher-refresh', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'cycle_unattributable', + }); + + runtime.dispose(); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + }); + + it('does not warn when an exact publisher handoff matches path and formats', () => { + const warnPublisherHandoffMismatch = vi.fn(); + const service = createSlotService({ + googletag: createGptHarness().adapter, + warnPublisherHandoffMismatch, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: Object.freeze([[300, 250]]), + }) + ).toEqual({ action: 'handoff', slot }); + expect(warnPublisherHandoffMismatch).not.toHaveBeenCalled(); + }); + + it('rolls back a pending publisher display without settling active or queued TS work', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: [300, 250], + }) + ).toEqual({ action: 'handoff', slot }); + expect( + service.preparePublisherDisplay({ initialLoadDisabled: false, target: 'slot-div' }) + ).toEqual({ action: 'suppress' }); + const active = service.request({ + intentId: 'active-before-publisher-display', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + const queued = service.request({ + intentId: 'queued-before-publisher-display', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + expect(active.status).toBe('active'); + expect(queued.status).toBe('queued'); + + const decision = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'slot-div', + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(decision.action).toBe('forward'); + expect(decision.admission).toBeDefined(); + expect(active.status).toBe('active'); + expect(queued.status).toBe('queued'); + + decision.admission?.rollback(); + decision.admission?.rollback(); + + expect(active.status).toBe('active'); + expect(queued.status).toBe('queued'); + service.dispose(); + await expect(active.result).resolves.toMatchObject({ status: 'cancelled' }); + await expect(queued.result).resolves.toMatchObject({ status: 'cancelled' }); + }); + + it('keeps a publisher cycle consumed before display rollback and makes later rollback inert', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + service.preparePublisherDisplay({ initialLoadDisabled: false, target: 'slot-div' }); + const decision = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'slot-div', + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(decision.admission).toBeDefined(); + + service.handleGptEvent('slotRequested', { slot }); + expect(service.snapshotForTest().cycles).toBe(1); + decision.admission?.rollback(); + decision.admission?.commit(); + expect(service.snapshotForTest().cycles).toBe(1); + + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + expect(service.snapshotForTest().cycles).toBe(0); + }); + + it('rolls back repeated display plus explicit and global refresh admissions without residue', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first'); + const second = bindTrustedSlot(service, navigation, 'second'); + for (const registeredSlotId of ['first', 'second'] as const) { + service.claimPublisherGptSlot({ + adUnitPath: `/network/${registeredSlotId}`, + elementId: `${registeredSlotId}-div`, + initialLoadDisabled: false, + sizes: [300, 250], + }); + service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: `${registeredSlotId}-div`, + }); + } + + for (let attempt = 0; attempt < 70; attempt += 1) { + const display = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'first-div', + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(display.admission).toBeDefined(); + display.admission?.rollback(); + } + const explicit = service.preparePublisherRefresh({ + requestedSlots: Object.freeze([first]), + slots: Object.freeze([first]), + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + const global = service.preparePublisherRefresh({ + requestedSlots: undefined, + slots: Object.freeze([first, second]), + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(explicit.admission).toBeDefined(); + expect(global.admission).toBeDefined(); + explicit.admission?.rollback(); + global.admission?.rollback(); + + const firstRequest = service.request({ + intentId: 'after-rolled-back-explicit-refresh', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }); + const secondRequest = service.request({ + intentId: 'after-rolled-back-global-refresh', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'second', + requestClass: 'primary', + }); + expect(firstRequest.status).toBe('active'); + expect(secondRequest.status).toBe('active'); + }); + + it('commits a global refresh only for the publisher physicals snapshotted before native entry', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first'); + bindTrustedSlot(service, navigation, 'second'); + service.claimPublisherGptSlot({ + adUnitPath: '/network/first', + elementId: 'first-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + const global = service.preparePublisherRefresh({ + requestedSlots: undefined, + slots: Object.freeze([first]), + }); + expect(global.action).toBe('forward'); + if (global.action !== 'forward') throw new Error('Expected global refresh forwarding'); + expect(global.admission).toBeDefined(); + + service.claimPublisherGptSlot({ + adUnitPath: '/network/second', + elementId: 'second-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + global.admission?.commit(); + + const firstRequest = service.request({ + intentId: 'global-snapshot-first', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }); + const secondRequest = service.request({ + intentId: 'global-snapshot-second', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'second', + requestClass: 'primary', + }); + await expect(firstRequest.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + expect(secondRequest.status).toBe('active'); + }); + + it('makes a pending publisher admission inert after navigation and service disposal', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + bindTrustedSlot(service, navigation); + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + service.preparePublisherDisplay({ initialLoadDisabled: false, target: 'slot-div' }); + const decision = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'slot-div', + }); + expect(decision.action).toBe('forward'); + if (decision.action !== 'forward') throw new Error('Expected display forwarding'); + expect(decision.admission).toBeDefined(); + + runtime.dispose(); + expect(() => decision.admission?.commit()).not.toThrow(); + expect(() => decision.admission?.rollback()).not.toThrow(); + service.dispose(); + expect(() => decision.admission?.commit()).not.toThrow(); + expect(() => decision.admission?.rollback()).not.toThrow(); + }); + + it('hydrates only one disconnected TS fallback with the configured prefix, path, and sizes', () => { + const dom = createReconciliationBoundary(); + const firstElement = {}; + const secondElement = {}; + dom.put('slot-first', firstElement); + dom.put('slot-second', secondElement); + const warnPublisherHandoffMismatch = vi.fn(); + const service = createSlotService({ + googletag: createGptHarness().adapter, + reconciliation: dom.boundary, + warnPublisherHandoffMismatch, + }); + const navigation = createNavigation(); + expect( + service.register(navigation, [serverRegistration('first'), serverRegistration('second')]) + ).toMatchObject({ ok: true }); + const first = {}; + const second = {}; + for (const [id, slot] of [ + ['first', first], + ['second', second], + ] as const) { + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: '/network/hydrated', + elementId: `slot-${id}`, + sizes: Object.freeze([[300, 250]]), + }, + elementIdPrefix: 'slot-', + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + dom.disconnect(`slot-${id}`); + } + + const hydration = Object.freeze({ + adUnitPath: '/network/hydrated', + elementId: 'slot-hydrated', + initialLoadDisabled: false, + sizes: Object.freeze([300, 250]), + }); + expect(service.claimPublisherGptSlot(hydration)).toEqual({ action: 'forward' }); + expect(service.recordPublisherDestruction(second)).toBe(true); + expect( + service.claimPublisherGptSlot({ ...hydration, adUnitPath: '/network/mismatch' }) + ).toEqual({ action: 'forward' }); + expect(service.claimPublisherGptSlot({ ...hydration, sizes: [728, 90] })).toEqual({ + action: 'forward', + }); + expect(service.claimPublisherGptSlot(hydration)).toEqual({ action: 'handoff', slot: first }); + expect(warnPublisherHandoffMismatch).not.toHaveBeenCalled(); + }); + + it('suppresses the exact first explicit refresh after a disabled-load handoff', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: true, + sizes: [300, 250], + }) + ).toEqual({ action: 'handoff', slot }); + + expect( + service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }) + ).toEqual({ action: 'suppress' }); + const forwarded = service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }); + expect(forwarded.action).toBe('forward'); + if (forwarded.action === 'forward') { + expect(forwarded.admission).toBeDefined(); + forwarded.admission?.commit(); + } + }); + + it('uses captured Set validation intrinsics on a hostile page', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const originalHas = Set.prototype.has; + const originalAdd = Set.prototype.add; + Set.prototype.has = function (): boolean { + throw new Error('poisoned has'); + } as typeof Set.prototype.has; + Set.prototype.add = function (): Set { + throw new Error('poisoned add'); + } as typeof Set.prototype.add; + let result: ReturnType | undefined; + try { + result = service.register(navigation, [ + serverRegistration('captured', { domAliases: ['captured-div'] }), + ]); + } finally { + Set.prototype.has = originalHas; + Set.prototype.add = originalAdd; + } + expect(result).toMatchObject({ ok: true }); + expect(service.resolveDomAlias('captured-div')?.registeredSlotId).toBe('captured'); + }); + + it('rolls back GPT identity publication when ownership becomes stale during adoption', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + expect(service.register(navigation, [serverRegistration('old')])).toMatchObject({ ok: true }); + const slot = {}; + const racedBinding = Object.defineProperties( + {}, + { + definition: { value: undefined }, + ownership: { + get: () => { + runtime.replaceNavigation(); + return 'publisher'; + }, + }, + slot: { value: slot }, + } + ) as GptSlotBinding; + + expect(service.adoptGptSlot(navigation.generation, 'old', racedBinding)).toEqual({ + ok: false, + reason: 'stale_owner', + }); + const next = runtime.currentNavigation; + if (!next) throw new Error('Expected replacement navigation'); + expect(service.register(next, [serverRegistration('next')])).toMatchObject({ ok: true }); + expect(service.adoptGptSlot(next.generation, 'next', { ownership: 'publisher', slot })).toEqual( + { ok: true } + ); + }); + + it('conditionally deletes a WeakMap identity published just before a stale-owner check', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + let phase: 'adopt' | 'register' | 'steady' = 'register'; + let adoptChecks = 0; + const generation = {}; + const owner = { + generation, + isCurrent: () => { + if (phase !== 'adopt') return true; + adoptChecks += 1; + return adoptChecks < 3; + }, + onDispose: vi.fn(), + } as unknown as NavigationSession; + expect(service.register(owner, [serverRegistration('slot')])).toMatchObject({ ok: true }); + const slot = {}; + phase = 'adopt'; + + expect(service.adoptGptSlot(generation, 'slot', { ownership: 'publisher', slot })).toEqual({ + ok: false, + reason: 'stale_owner', + }); + phase = 'steady'; + expect(service.adoptGptSlot(generation, 'slot', { ownership: 'publisher', slot })).toEqual({ + ok: true, + }); + }); +}); + +describe('navigation-owned DOM reconciliation', () => { + afterEach(() => vi.useRealTimers()); + + it('installs reconciliation only for one explicit reversible deferred owner', () => { + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const service = createSlotService({ + googletag: gpt.adapter, + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + + expect(dom.observe).not.toHaveBeenCalled(); + const release = service.activateReconciliation(); + expect(dom.observe).toHaveBeenCalledOnce(); + const disconnect = dom.observe.mock.results[0]?.value; + expect(typeof disconnect).toBe('function'); + expect(() => service.activateReconciliation()).toThrow('unavailable'); + + release(); + release(); + expect(disconnect).toHaveBeenCalledOnce(); + + const releaseAgain = service.activateReconciliation(); + expect(dom.observe).toHaveBeenCalledTimes(2); + releaseAgain(); + expect(dom.observe.mock.results[1]?.value).toHaveBeenCalledOnce(); + }); + + it('preserves the physical slot when DOM connectivity cannot be established', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: Object.freeze({ + ...dom.boundary, + isConnected: () => { + throw new Error('fictional DOM connectivity failure'); + }, + }), + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.trigger(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(gpt.destroySlots).not.toHaveBeenCalled(); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + }); + + it('reconciles a TS slot whose original DOM element was already absent at adoption', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.put('slot-div', {}); + dom.trigger(); + await vi.advanceTimersByTimeAsync(250); + + expect(gpt.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/network/slot', + [[300, 250]], + 'slot-div' + ); + }); + + it('debounces an exact disconnected TS slot through the 249/250 ms boundary', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const disposeCommittedArtifact = vi.fn(() => { + throw new Error('fictional artifact cleanup failure'); + }); + dom.put('slot-div', {}); + const service = createSlotService({ + disposeCommittedArtifact, + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(249); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ id: 'slot' }), + ]); + expect(gpt.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/network/slot', + [[300, 250]], + 'slot-div' + ); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + + const request = service.request({ + intentId: 'after-rebind', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + expect(request.status).toBe('active'); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(gpt.defineSlot.mock.results[0]?.value); + }); + + it('settles an invocation tied to the orphan before publishing the replacement', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + const orphan = bindTrustedSlot(service, navigation); + service.activate(); + const request = service.request({ + intentId: 'before-rebind', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(orphan); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + await vi.advanceTimersByTimeAsync(3_000); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([orphan]); + + service.request({ + intentId: 'after-rebind', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + expect(gpt.display).toHaveBeenLastCalledWith(gpt.defineSlot.mock.results[0]?.value); + }); + + it('runs one final unresolved pass at 5,000 ms and settles exact work', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(2_999); + const request = service.request({ + intentId: 'orphaned', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(2_000); + expect(request.status).toBe('active'); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await expect(request.result).resolves.toEqual({ status: 'failed', reason: 'slot_unresolved' }); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ id: 'slot' }), + ]); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + }); + + it.each([ + ['unresolved', 'destroy_false'], + ['unresolved', 'destroy_throw'], + ['ambiguous', 'destroy_false'], + ['ambiguous', 'destroy_throw'], + ] as const)('settles final %s cleanup %s as gpt_request_failed', async (resolution, failure) => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + if (failure === 'destroy_false') gpt.destroySlots.mockReturnValue(false); + else { + gpt.destroySlots.mockImplementation(() => { + throw new Error('fictional destroy failure'); + }); + } + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + if (resolution === 'ambiguous') dom.replaceAmbiguously('slot-div', [{}, {}]); + else dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(4_999); + const request = service.request({ + intentId: `${resolution}-${failure}`, + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(1); + + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ id: 'slot' }), + ]); + }); + + it('keeps final cleanup pending and lets navigation cancellation beat its late result', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ deferDestroyedResult: true }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(4_999); + const request = service.request({ + intentId: 'navigation-wins-late-cleanup', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + vi.advanceTimersByTime(1); + expect(request.status).toBe('active'); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + const nextResult = runtime.replaceNavigation(); + expect(nextResult.ok).toBe(true); + if (!nextResult.ok) throw new Error('Expected replacement navigation'); + const next = nextResult.value; + await expect(request.result).resolves.toEqual({ + status: 'cancelled', + reason: 'navigation_disposed', + }); + expect( + service.register(next, [ + serverRegistration('slot', { + adUnitCode: '/network/slot', + domAliases: ['slot-div'], + }), + ]) + ).toEqual({ ok: false, reason: 'slot_quarantined' }); + + gpt.resolveDeferredDestroyed(); + await Promise.resolve(); + await Promise.resolve(); + + expect(request.status).toBe('terminal'); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + const replacement = bindTrustedSlot(service, next); + gpt.resolveDeferredDestroyed(); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'late-old-slot', + slot: oldSlot, + }); + expect(service.recordPublisherDestruction(oldSlot)).toBe(false); + expect(service.isBoundGptSlot(next.generation, 'slot', replacement)).toBe(true); + }); + + it('lets request supersession win while final cleanup completes later', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ synchronousRun: false }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(4_999); + const request = service.request({ + intentId: 'supersession-wins-late-cleanup', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + vi.advanceTimersByTime(1); + expect(request.status).toBe('active'); + request.dispose(); + await expect(request.result).resolves.toEqual({ + status: 'cancelled', + reason: 'superseded', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(request.status).toBe('terminal'); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + + it('releases the exact committed artifact before retiring a failed reconciliation', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const disposeCommittedArtifact = vi.fn(); + dom.put('slot-div', {}); + const service = createSlotService({ + disposeCommittedArtifact, + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(5_000); + + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + expect(disposeCommittedArtifact.mock.invocationCallOrder[0]).toBeLessThan( + gpt.destroySlots.mock.invocationCallOrder[0] as number + ); + expect(gpt.destroySlots).toHaveBeenCalledOnce(); + }); + + it('commits a unique replacement found only by the final 5,000 ms pass', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(250); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(4_749); + dom.put('slot-div', {}); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(gpt.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/network/slot', + [[300, 250]], + 'slot-div' + ); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + + it('keeps an ambiguous replacement unresolved through the final pass', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + dom.replaceAmbiguously('slot-div', [{}, {}]); + await vi.advanceTimersByTimeAsync(2_999); + const request = service.request({ + intentId: 'ambiguous', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(2_001); + await expect(request.result).resolves.toEqual({ status: 'failed', reason: 'slot_unresolved' }); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + + it.each(['destroy_false', 'destroy_throw', 'define'] as const)( + 'settles %s transaction failure as gpt_request_failed without a second physical slot', + async (failure) => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + if (failure === 'destroy_false') gpt.destroySlots.mockReturnValue(false); + else if (failure === 'destroy_throw') { + gpt.destroySlots.mockImplementation(() => { + throw new Error('fictional destroy failure'); + }); + } else gpt.defineSlot.mockReturnValueOnce(undefined); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + const request = service.request({ + intentId: `failed-${failure}`, + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + expect(gpt.defineSlot).toHaveBeenCalledTimes(failure === 'define' ? 1 : 0); + expect(service.snapshotForTest().physicalSlots).toBe(0); + } + ); + + it('quarantines an exact replacement candidate the adapter could not destroy', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const orphan = Object.freeze({ orphan: true }); + const gpt = createGptHarness({ orphanOnReplace: orphan }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + const request = service.request({ + intentId: 'orphaned-replacement', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + const replacementBinding = { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: Object.freeze([[300, 250]]), + }, + ownership: 'trusted_server' as const, + slot: Object.freeze({ replacementAfterOrphan: true }), + }; + expect(service.adoptGptSlot(navigation.generation, 'slot', replacementBinding)).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + + expect(service.recordPublisherDestruction(orphan)).toBe(true); + expect(service.adoptGptSlot(navigation.generation, 'slot', replacementBinding)).toEqual({ + ok: true, + }); + }); + + it('lets expiry beat a final-pass replacement that cannot commit synchronously', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ synchronousRun: false }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + await Promise.resolve(); + + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(250); + await vi.advanceTimersByTimeAsync(4_749); + dom.put('slot-div', {}); + const request = service.request({ + intentId: 'expiry-wins', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(1); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'slot_unresolved', + }); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('lets publisher ownership transfer cancel a queued reconciliation transaction', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ synchronousRun: false }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + await Promise.resolve(); + + dom.replace('slot-div', {}); + vi.advanceTimersByTime(250); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'publisher', + slot, + }) + ).toEqual({ ok: true }); + await Promise.resolve(); + await Promise.resolve(); + + expect(gpt.defineSlot).not.toHaveBeenCalled(); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('allows two successful rebinds and fails a third disconnect immediately', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + expect(gpt.defineSlot).toHaveBeenCalledTimes(2); + + const request = service.request({ + intentId: 'capacity', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + dom.disconnect('slot-div'); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'reconciliation_capacity', + }); + expect(gpt.defineSlot).toHaveBeenCalledTimes(2); + expect(gpt.destroySlots).toHaveBeenCalledTimes(3); + }); + + it('cancels reconciliation on publisher transfer and disconnects with navigation', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'publisher', + slot, + }) + ).toEqual({ ok: true }); + await vi.advanceTimersByTimeAsync(5_000); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + + navigation.dispose(); + expect(dom.observe).toHaveBeenCalledTimes(1); + dom.trigger(); + await vi.runAllTimersAsync(); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + }); +}); + +describe('browser reconciliation boundary', () => { + it('resolves only one exact connected element and releases its observer', async () => { + const boundary = createBrowserSlotReconciliationBoundary(document, MutationObserver); + expect(boundary).toBeDefined(); + if (!boundary) throw new Error('Expected the browser reconciliation boundary'); + const host = document.createElement('section'); + const first = document.createElement('div'); + first.id = 'tsjs-reconciliation-exact'; + host.append(first); + document.body.append(host); + const callback = vi.fn(); + const release = boundary.observe(callback); + + expect(boundary.resolve(['tsjs-reconciliation-exact'])).toEqual({ + status: 'unique', + element: first, + elementId: 'tsjs-reconciliation-exact', + }); + expect(boundary.isConnected(first)).toBe(true); + + const duplicate = document.createElement('div'); + duplicate.id = first.id; + host.append(duplicate); + await vi.waitFor(() => expect(callback).toHaveBeenCalled()); + expect(boundary.resolve([first.id])).toEqual({ status: 'ambiguous' }); + + const callsBeforeRelease = callback.mock.calls.length; + release(); + host.remove(); + await Promise.resolve(); + expect(callback).toHaveBeenCalledTimes(callsBeforeRelease); + expect(boundary.isConnected(first)).toBe(false); + expect(boundary.resolve([first.id])).toEqual({ status: 'unresolved' }); + }); +}); + +function createReplacementHarness() { + const replacement = { addService: vi.fn() }; + const destroySlots = vi.fn((_slots: readonly object[]) => true); + const defineSlot = vi.fn((): object | undefined => replacement); + const pubads = { + addEventListener: vi.fn(), + getSlots: () => [], + refresh: vi.fn(), + removeEventListener: vi.fn(), + }; + const adapter = createBrowserGoogletagAdapter({ + googletag: { + apiReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot, + destroySlots, + display: vi.fn(), + pubads: () => pubads, + pubadsReady: true, + }, + }); + return { adapter, defineSlot, destroySlots, pubads, replacement }; +} + +describe('adapter-owned GPT replacement transaction', () => { + const definition = Object.freeze({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: Object.freeze([[300, 250]]), + }); + const commitReplacement = () => Object.freeze({ commit: () => true, rollback: vi.fn() }); + + it.each(['throw', 'false'] as const)( + 'never publishes a second physical slot after %s failure', + async (failure) => { + const harness = createReplacementHarness(); + if (failure === 'throw') { + harness.destroySlots.mockImplementation(() => { + throw new Error('destroy failed'); + }); + } else if (failure === 'false') { + harness.destroySlots.mockReturnValue(false); + } + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace({}, definition, () => true, commitReplacement) + ); + + await expect(operation.result).rejects.toBeDefined(); + expect(harness.defineSlot).not.toHaveBeenCalled(); + expect(harness.replacement.addService).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['after-destroy', 1, 0, 1], + ['after-define', 2, 1, 2], + ['after-addService', 3, 1, 2], + ] as const)( + 'checks stale generation %s and cleans any newly-defined object', + async (_site, staleAt, expectedDefinitions, expectedDestroys) => { + const harness = createReplacementHarness(); + let checks = 0; + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace( + {}, + definition, + () => { + checks += 1; + return checks < staleAt; + }, + commitReplacement + ) + ); + + await expect(operation.result).resolves.toEqual({ status: 'destroyed' }); + expect(harness.defineSlot).toHaveBeenCalledTimes(expectedDefinitions); + expect(harness.destroySlots).toHaveBeenCalledTimes(expectedDestroys); + expect(harness.replacement.addService).toHaveBeenCalledTimes(staleAt === 3 ? 1 : 0); + } + ); + + it('surfaces failure to destroy a newly-defined stale replacement', async () => { + const harness = createReplacementHarness(); + harness.destroySlots.mockReturnValueOnce(true).mockReturnValueOnce(false); + let checks = 0; + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace( + {}, + definition, + () => { + checks += 1; + return checks < 2; + }, + commitReplacement + ) + ); + + await expect(operation.result).rejects.toBeDefined(); + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + }); + + it('normalizes a defineSlot throw after destroying the old slot', async () => { + const harness = createReplacementHarness(); + const publisherFailure = new Error('publisher define failed'); + harness.defineSlot.mockImplementation(() => { + throw publisherFailure; + }); + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace({}, definition, () => true, commitReplacement) + ); + + await expect(operation.result).rejects.toMatchObject({ + cause: publisherFailure, + code: 'gpt_replacement_failed', + oldSlotDestroyed: true, + orphanedSlot: undefined, + }); + expect(harness.destroySlots).toHaveBeenCalledOnce(); + }); + + it('normalizes a generation callback throw after destroying the old slot', async () => { + const harness = createReplacementHarness(); + const ownerFailure = new Error('generation check failed'); + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace( + {}, + definition, + () => { + throw ownerFailure; + }, + commitReplacement + ) + ); + + await expect(operation.result).rejects.toMatchObject({ + cause: ownerFailure, + code: 'gpt_replacement_failed', + oldSlotDestroyed: true, + orphanedSlot: undefined, + }); + expect(harness.defineSlot).not.toHaveBeenCalled(); + expect(harness.destroySlots).toHaveBeenCalledOnce(); + }); + + it('normalizes commit-admission throws and destroys the exact uncommitted candidate', async () => { + const harness = createReplacementHarness(); + const admissionFailure = new Error('commit admission failed'); + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace( + {}, + definition, + () => true, + () => { + throw admissionFailure; + } + ) + ); + + await expect(operation.result).rejects.toMatchObject({ + cause: admissionFailure, + code: 'gpt_replacement_failed', + oldSlotDestroyed: true, + orphanedSlot: undefined, + }); + expect(harness.replacement.addService).not.toHaveBeenCalled(); + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + expect(harness.destroySlots).toHaveBeenNthCalledWith(2, [harness.replacement]); + }); + + it('leaves the service unbound after the real adapter destroys old then defineSlot throws', async () => { + vi.useFakeTimers(); + const harness = createReplacementHarness(); + harness.defineSlot.mockImplementation(() => { + throw new Error('publisher define failed'); + }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const oldSlot = { old: true }; + expect( + service.register(navigation, [ + serverRegistration('slot', { + adUnitCode: '/network/slot', + domAliases: ['slot-div'], + }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition, + ownership: 'trusted_server', + slot: oldSlot, + }) + ).toEqual({ ok: true }); + const request = service.request({ + intentId: 'real-define-throw', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect(service.recordPublisherDestruction(oldSlot)).toBe(false); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition, + ownership: 'trusted_server', + slot: { retry: true }, + }) + ).toEqual({ ok: true }); + }); + + it('leaves the service unbound when its generation check throws after old-slot destruction', async () => { + vi.useFakeTimers(); + const harness = createReplacementHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const oldSlot = { old: true }; + let oldSlotDestroyed = false; + const ownerFailure = new Error('owner check failed after destroy'); + const isCurrent = vi.spyOn(navigation, 'isCurrent').mockImplementation(() => { + if (oldSlotDestroyed) throw ownerFailure; + return true; + }); + harness.destroySlots.mockImplementation((slots) => { + if (slots[0] === oldSlot) oldSlotDestroyed = true; + return true; + }); + expect( + service.register(navigation, [ + serverRegistration('slot', { + adUnitCode: '/network/slot', + domAliases: ['slot-div'], + }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition, + ownership: 'trusted_server', + slot: oldSlot, + }) + ).toEqual({ ok: true }); + const request = service.request({ + intentId: 'real-current-throw', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect(service.recordPublisherDestruction(oldSlot)).toBe(false); + isCurrent.mockImplementation(() => true); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition, + ownership: 'trusted_server', + slot: { retry: true }, + }) + ).toEqual({ ok: true }); + }); + + it('rejects a defineSlot candidate that is the retired old object', async () => { + const harness = createReplacementHarness(); + const oldSlot = { addService: vi.fn() }; + harness.defineSlot.mockReturnValue(oldSlot); + const commit = vi.fn(); + const operation = harness.adapter.run((gpt) => + ( + gpt.transactionalReplace as unknown as ( + old: object, + candidateDefinition: GoogletagReplacementDefinition, + current: () => boolean, + prepareCommit: (candidate: object) => { commit: () => boolean; rollback: () => void } + ) => unknown + )( + oldSlot, + definition, + () => true, + () => ({ commit, rollback: vi.fn() }) + ) + ); + + await expect(operation.result).rejects.toBeDefined(); + expect(commit).not.toHaveBeenCalled(); + expect(harness.replacement.addService).not.toHaveBeenCalled(); + }); + + it('surfaces the reused old identity when rejecting it cannot clean it up', async () => { + const harness = createReplacementHarness(); + const oldSlot = { addService: vi.fn() }; + harness.defineSlot.mockReturnValue(oldSlot); + harness.destroySlots.mockReturnValueOnce(true).mockReturnValueOnce(false); + const operation = harness.adapter.run((gpt) => + gpt.transactionalReplace(oldSlot, definition, () => true, commitReplacement) + ); + + await expect(operation.result).rejects.toMatchObject({ + code: 'gpt_replacement_failed', + oldSlotDestroyed: true, + orphanedSlot: oldSlot, + }); + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + }); + + it('rolls back a synchronous service commit when the post-commit generation check is stale', async () => { + const harness = createReplacementHarness(); + let checks = 0; + let bound: object | undefined; + const rollback = vi.fn(() => { + bound = undefined; + }); + const operation = harness.adapter.run((gpt) => + ( + gpt.transactionalReplace as unknown as ( + old: object, + candidateDefinition: GoogletagReplacementDefinition, + current: () => boolean, + prepareCommit: (candidate: object) => { commit: () => boolean; rollback: () => void } + ) => unknown + )( + {}, + definition, + () => { + checks += 1; + return checks < 4; + }, + (candidate) => ({ + commit: () => { + bound = candidate; + return true; + }, + rollback, + }) + ) + ); + + await expect(operation.result).resolves.toEqual({ status: 'destroyed' }); + expect(rollback).toHaveBeenCalledOnce(); + expect(bound).toBeUndefined(); + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + }); + + it('surfaces the exact orphan candidate when post-commit cleanup cannot destroy it', async () => { + const harness = createReplacementHarness(); + harness.destroySlots.mockReturnValueOnce(true).mockReturnValueOnce(false); + const rollback = vi.fn(); + let checks = 0; + const operation = harness.adapter.run((gpt) => + ( + gpt.transactionalReplace as unknown as ( + old: object, + candidateDefinition: GoogletagReplacementDefinition, + current: () => boolean, + prepareCommit: (candidate: object) => { commit: () => boolean; rollback: () => void } + ) => unknown + )( + {}, + definition, + () => { + checks += 1; + return checks < 4; + }, + () => ({ commit: () => true, rollback }) + ) + ); + + await expect(operation.result).rejects.toMatchObject({ + code: 'gpt_replacement_failed', + orphanedSlot: harness.replacement, + }); + expect(rollback).toHaveBeenCalledOnce(); + }); +}); + +function readyListenerBinding() { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + const pubads = { + addEventListener, + getSlots: () => [], + refresh: vi.fn(), + removeEventListener, + }; + return { + addEventListener, + binding: { + apiReady: true, + cmd: { push: (command: () => void) => command() }, + display: vi.fn(), + pubads: () => pubads, + pubadsReady: true, + }, + removeEventListener, + }; +} + +describe('binding-aware GPT listener activation', () => { + it('installs observation without timers and starts readiness only after commit', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + service.activate(); + expect(vi.getTimerCount()).toBe(0); + + const missing = service.start(); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(10_000); + await expect(missing.result).rejects.toMatchObject({ code: 'external_ready_timeout' }); + + const ready = readyListenerBinding(); + target.googletag = ready.binding; + await expect(service.start().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); + + expect(ready.addEventListener.mock.calls.map(([type]) => type)).toEqual([ + 'slotRequested', + 'slotRenderEnded', + ]); + }); + + it('subscribes a replacement binding before allowing later operations without duplicating either', async () => { + const first = readyListenerBinding(); + const second = readyListenerBinding(); + const target: { googletag?: unknown } = { googletag: first.binding }; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + service.activate(); + await expect(service.start().result).resolves.toBeUndefined(); + target.googletag = second.binding; + await expect(service.start().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); + + expect(first.addEventListener).toHaveBeenCalledTimes(2); + expect(second.addEventListener).toHaveBeenCalledTimes(2); + expect(first.removeEventListener).toHaveBeenCalledTimes(2); + service.dispose(); + expect(first.removeEventListener).toHaveBeenCalledTimes(2); + expect(second.removeEventListener).toHaveBeenCalledTimes(2); + }); +}); + +describe('physical GPT cycles', () => { + afterEach(() => vi.useRealTimers()); + + it('preserves external_queue_full when GPT readiness admission is saturated', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + for (let index = 0; index < 64; index += 1) { + const queued = adapter.run(() => undefined); + void queued.result.catch(() => undefined); + } + + const request = service.request({ + intentId: 'queue-capacity', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'external_queue_full', + }); + service.dispose(); + adapter.dispose(); + }); + + it('preserves external_ready_timeout when GPT never becomes ready', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'readiness-deadline', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(10_000); + + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'external_ready_timeout', + }); + service.dispose(); + adapter.dispose(); + }); + + it('records intent before a synchronous slotRequested event and supports SRA per slot', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first'); + const second = bindTrustedSlot(service, navigation, 'second'); + harness.display.mockImplementation((slot: object) => { + service.handleGptEvent('slotRequested', { slot }); + }); + + const firstRequest = service.request({ + intentId: 'intent-first', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'first', + }); + const secondRequest = service.request({ + intentId: 'intent-second', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'second', + }); + await Promise.resolve(); + expect(harness.display.mock.calls.map(([slot]) => slot)).toEqual([first, second]); + + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'response-first', + slot: first, + }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: true, + responseIdentifier: 'response-second', + slot: second, + }); + await expect(firstRequest.result).resolves.toEqual({ + responseIdentifier: 'response-first', + status: 'rendered', + }); + await expect(secondRequest.result).resolves.toEqual({ + responseIdentifier: 'response-second', + status: 'empty', + }); + }); + + it('uses display only for registration under disabled initial load and one exact refresh', async () => { + const harness = createGptHarness({ initialLoadDisabled: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + + service.request({ + intentId: 'intent', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + + expect(harness.display).toHaveBeenCalledExactlyOnceWith(slot); + expect(harness.refresh).toHaveBeenCalledExactlyOnceWith( + [slot], + Object.freeze({ changeCorrelator: false }) + ); + }); + + it('treats a slotRequested raised by disabled-load display as publisher overlap and skips refresh', async () => { + const harness = createGptHarness({ initialLoadDisabled: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + harness.display.mockImplementation(() => { + service.handleGptEvent('slotRequested', { slot }); + }); + const request = service.request({ + intentId: 'display-overlap', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + + await expect(request.result).resolves.toEqual({ + reason: 'cycle_unattributable', + status: 'failed', + }); + expect(harness.refresh).not.toHaveBeenCalled(); + }); + + it('fails a disabled-initial-load request when refresh throws', async () => { + const harness = createGptHarness({ initialLoadDisabled: true }); + harness.refresh.mockImplementation(() => { + throw new Error('refresh unavailable'); + }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + + const request = service.request({ + intentId: 'intent', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + + await expect(request.result).resolves.toEqual({ + reason: 'gpt_request_failed', + status: 'failed', + }); + }); + + it('fails a disabled-initial-load request when refresh is unavailable', async () => { + const harness = createGptHarness({ initialLoadDisabled: true, missingRefresh: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'missing-refresh', + navigationGeneration: navigation.generation, + operation: 'display', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + + await expect(request.result).resolves.toEqual({ + reason: 'gpt_request_failed', + status: 'failed', + }); + }); + + it('records every SRA intent before one refresh and fans out events by object identity', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'sra-first'); + const second = bindTrustedSlot(service, navigation, 'sra-second'); + + const requests = service.requestBatch([ + { + intentId: 'sra-intent-first', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'sra-first', + }, + { + intentId: 'sra-intent-second', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'sra-second', + }, + ]); + await Promise.resolve(); + expect(harness.refresh).toHaveBeenCalledExactlyOnceWith( + [first, second], + Object.freeze({ changeCorrelator: false }) + ); + service.handleGptEvent('slotRequested', { slot: first }); + service.handleGptEvent('slotRequested', { slot: second }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'sra-first-response', + slot: first, + }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: true, + responseIdentifier: 'sra-second-response', + slot: second, + }); + await expect(Promise.all(requests.map(({ result }) => result))).resolves.toEqual([ + { responseIdentifier: 'sra-first-response', status: 'rendered' }, + { responseIdentifier: 'sra-second-response', status: 'empty' }, + ]); + }); + + it('rejects display batches at the type and runtime boundaries before mutation', () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const displayBatch = [ + { + intentId: 'valid-before-display', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'slot', + requestClass: 'primary', + }, + { + intentId: 'display-batch', + navigationGeneration: navigation.generation, + operation: 'display' as const, + registeredSlotId: 'slot', + requestClass: 'primary', + }, + ] as const; + const compileOnly = (): void => { + // @ts-expect-error requestBatch is refresh-only; single request retains display support. + service.requestBatch(displayBatch); + }; + expect(compileOnly).toBeTypeOf('function'); + const inventory = service.snapshotForTest(); + const runtimeRequestBatch = service.requestBatch as unknown as ( + inputs: readonly object[] + ) => unknown; + + expect(runtimeRequestBatch(displayBatch)).toEqual([]); + expect(service.snapshotForTest()).toEqual(inventory); + expect(harness.display).not.toHaveBeenCalled(); + expect(harness.refresh).not.toHaveBeenCalled(); + }); + + it.each(['unknown-slot', 'duplicate-slot', 'duplicate-intent', 'mixed-navigation'] as const)( + 'prevalidates the entire SRA batch atomically: %s', + (failure) => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const firstNavigation = createNavigation(); + const secondNavigation = createNavigation(); + bindTrustedSlot(service, firstNavigation, 'first'); + bindTrustedSlot(service, firstNavigation, 'second'); + bindTrustedSlot(service, secondNavigation, 'other-navigation'); + const first = { + intentId: 'first-intent', + navigationGeneration: firstNavigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'first', + requestClass: 'primary', + }; + const second = { + intentId: failure === 'duplicate-intent' ? first.intentId : 'second-intent', + navigationGeneration: + failure === 'mixed-navigation' ? secondNavigation.generation : firstNavigation.generation, + operation: 'refresh' as const, + registeredSlotId: + failure === 'unknown-slot' + ? 'missing' + : failure === 'duplicate-slot' + ? first.registeredSlotId + : failure === 'mixed-navigation' + ? 'other-navigation' + : 'second', + requestClass: 'primary', + }; + const inventory = service.snapshotForTest(); + + expect(service.requestBatch([first, second])).toEqual([]); + expect(service.snapshotForTest()).toEqual(inventory); + expect(harness.refresh).not.toHaveBeenCalled(); + } + ); + + it('treats an empty SRA batch as an inert rejection', () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + expect(service.requestBatch([])).toEqual([]); + expect(service.snapshotForTest()).toEqual({ + cycles: 0, + intents: 0, + physicalSlots: 0, + records: 0, + }); + expect(harness.refresh).not.toHaveBeenCalled(); + }); + + it('contains a throwing batch length read before validation', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const hostileInputs = new Proxy([], { + get: (target, key, receiver) => { + if (key === 'length') throw new Error('hostile batch length'); + return Reflect.get(target, key, receiver); + }, + }); + const runtimeRequestBatch = service.requestBatch as unknown as ( + inputs: readonly object[] + ) => unknown; + let outcome: unknown; + + expect(() => { + outcome = runtimeRequestBatch(hostileInputs); + }).not.toThrow(); + expect(outcome).toEqual([]); + expect(service.snapshotForTest()).toEqual({ + cycles: 0, + intents: 0, + physicalSlots: 0, + records: 0, + }); + }); + + it('does not leak partial admission through a poisoned Array map', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation, 'map-first'); + bindTrustedSlot(service, navigation, 'map-second'); + const inputs = [ + { + intentId: 'poison-map-first', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'map-first', + requestClass: 'primary', + }, + { + intentId: 'poison-map-second', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'map-second', + requestClass: 'primary', + }, + ]; + const originalMap = Array.prototype.map; + Array.prototype.map = function ( + this: Value[], + callback: (value: Value, index: number, array: Value[]) => Result, + thisArgument?: unknown + ): Result[] { + let targeted = false; + for (let index = 0; index < this.length; index += 1) { + const value = this[index] as { intentId?: unknown } | undefined; + if (value?.intentId === 'poison-map-first') targeted = true; + } + if (targeted) { + Reflect.apply(callback, thisArgument, [this[0], 0, this]); + throw new Error('poisoned map after partial admission'); + } + return Reflect.apply(originalMap, this, [callback, thisArgument]) as Result[]; + }; + let handles: readonly ReturnType[] | undefined; + let escaped: unknown; + try { + handles = service.requestBatch(inputs); + } catch (error) { + escaped = error; + } finally { + Array.prototype.map = originalMap; + } + + expect(escaped).toBeUndefined(); + expect(handles).toHaveLength(2); + for (const handle of handles ?? []) handle.dispose(); + await expect(Promise.all((handles ?? []).map(({ result }) => result))).resolves.toEqual([ + { reason: 'superseded', status: 'cancelled' }, + { reason: 'superseded', status: 'cancelled' }, + ]); + await Promise.resolve(); + expect(harness.refresh).not.toHaveBeenCalled(); + expect(service.snapshotForTest().intents).toBe(0); + }); + + it('does not leak post-admission intents through a poisoned Array iterator', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation, 'iterator-first'); + bindTrustedSlot(service, navigation, 'iterator-second'); + const inputs = [ + { + intentId: 'poison-iterator-first', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'iterator-first', + requestClass: 'primary', + }, + { + intentId: 'poison-iterator-second', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'iterator-second', + requestClass: 'primary', + }, + ]; + const originalIterator = Array.prototype[Symbol.iterator]; + Array.prototype[Symbol.iterator] = function (): ArrayIterator { + for (let index = 0; index < this.length; index += 1) { + const value = this[index] as { intentId?: unknown } | undefined; + if (value?.intentId === 'poison-iterator-first') { + throw new Error('poisoned iterator after admission'); + } + } + return Reflect.apply(originalIterator, this, []) as ArrayIterator; + }; + let handles: readonly ReturnType[] | undefined; + let escaped: unknown; + try { + handles = service.requestBatch(inputs); + } catch (error) { + escaped = error; + } finally { + Array.prototype[Symbol.iterator] = originalIterator; + } + + expect(escaped).toBeUndefined(); + expect(handles).toHaveLength(2); + for (let index = 0; index < (handles?.length ?? 0); index += 1) { + handles?.[index]?.dispose(); + } + await expect(Promise.all((handles ?? []).map(({ result }) => result))).resolves.toEqual([ + { reason: 'superseded', status: 'cancelled' }, + { reason: 'superseded', status: 'cancelled' }, + ]); + await Promise.resolve(); + expect(harness.refresh).not.toHaveBeenCalled(); + expect(service.snapshotForTest().intents).toBe(0); + }); + + it('rolls back every admitted batch handle when a later request unexpectedly throws', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + let poison = false; + let batchChecks = 0; + const owner = { + generation: {}, + isCurrent: () => { + if (!poison) return true; + batchChecks += 1; + if (batchChecks === 4) throw new Error('second request admission failed'); + return true; + }, + onDispose: vi.fn(), + } as unknown as NavigationSession; + bindTrustedSlot(service, owner, 'rollback-first'); + bindTrustedSlot(service, owner, 'rollback-second'); + const inventory = service.snapshotForTest(); + poison = true; + + expect( + service.requestBatch([ + { + intentId: 'rollback-first', + navigationGeneration: owner.generation, + operation: 'refresh', + registeredSlotId: 'rollback-first', + requestClass: 'primary', + }, + { + intentId: 'rollback-second', + navigationGeneration: owner.generation, + operation: 'refresh', + registeredSlotId: 'rollback-second', + requestClass: 'primary', + }, + ]) + ).toEqual([]); + expect(service.snapshotForTest()).toEqual(inventory); + }); + + it('keeps publisher display intent publisher-owned and fails ambiguous overlap', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + expect(service.recordPublisherIntent(slot)).toBe(true); + + const request = service.request({ + intentId: 'intent', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + + await expect(request.result).resolves.toEqual({ + reason: 'cycle_unattributable', + status: 'failed', + }); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'publisher', + slot, + }); + expect(harness.refresh).not.toHaveBeenCalled(); + }); + + it('allows one queued replacement, supersedes its same-class predecessor, and rejects opposite overlap', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = (intentId: string, requestClass: string) => + service.request({ + intentId, + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass, + registeredSlotId: 'slot', + }); + const active = request('active', 'primary'); + const replaced = request('queued-one', 'primary'); + const queued = request('queued-two', 'primary'); + await expect(replaced.result).resolves.toEqual({ + reason: 'superseded', + status: 'cancelled', + }); + const conflicting = request('queued-fallback', 'fallback'); + await expect(queued.result).resolves.toEqual({ + reason: 'cycle_unattributable', + status: 'failed', + }); + await expect(conflicting.result).resolves.toEqual({ + reason: 'cycle_unattributable', + status: 'failed', + }); + await expect(active.result).resolves.toEqual({ + reason: 'cycle_unattributable', + status: 'failed', + }); + }); + + it('queues one same-class replacement behind an open trusted-server cycle', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const first = service.request({ + intentId: 'first-primary', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + + const second = service.request({ + intentId: 'second-primary', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + expect(second.status).toBe('queued'); + expect(harness.refresh).toHaveBeenCalledTimes(1); + + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'first-primary-response', + slot, + }); + await expect(first.result).resolves.toEqual({ + responseIdentifier: 'first-primary-response', + status: 'rendered', + }); + await Promise.resolve(); + expect(harness.refresh).toHaveBeenCalledTimes(2); + + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: true, + responseIdentifier: 'second-primary-response', + slot, + }); + await expect(second.result).resolves.toEqual({ + responseIdentifier: 'second-primary-response', + status: 'empty', + }); + }); + + it('promotes a queued replacement when its active predecessor cancels before invocation', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const first = service.request({ + intentId: 'cancelled-before-invocation', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + const second = service.request({ + intentId: 'promoted-after-cancellation', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + expect(second.status).toBe('queued'); + + first.dispose(); + await expect(first.result).resolves.toEqual({ + reason: 'superseded', + status: 'cancelled', + }); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.refresh).toHaveBeenCalledTimes(1); + expect(second.status).toBe('active'); + + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'promoted-response', + slot, + }); + await expect(second.result).resolves.toEqual({ + responseIdentifier: 'promoted-response', + status: 'rendered', + }); + expect(service.snapshotForTest()).toMatchObject({ cycles: 0, intents: 0 }); + }); + + it('fails active and queued TS work when publisher intent makes ownership ambiguous', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const active = service.request({ + intentId: 'active', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + const queued = service.request({ + intentId: 'queued', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + + expect(service.recordPublisherIntent(slot)).toBe(true); + await expect(active.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + await expect(queued.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'publisher', + slot, + }); + expect(service.snapshotForTest().intents).toBe(0); + }); + + it('disposes an operation that settled synchronously before its handle was published', async () => { + const harness = createGptHarness({ synchronousRun: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + harness.refresh.mockImplementation(() => { + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'synchronous', + slot, + }); + }); + + const request = service.request({ + intentId: 'synchronous', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); + expect( + harness.operationDisposals[harness.operationDisposals.length - 1] + ).toHaveBeenCalledOnce(); + }); + + it('safe-retires an invoked pre-cycle cancellation instead of clearing its only safety timer', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'cancelled', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + request.dispose(); + await expect(request.result).resolves.toMatchObject({ reason: 'superseded' }); + await Promise.resolve(); + + expect(harness.destroySlots).toHaveBeenCalledTimes(1); + expect(harness.defineSlot).toHaveBeenCalledTimes(1); + }); + + it.each([ + [2_999, true], + [3_001, false], + ] as const)('arbitrates slotRequested at %i ms without timeout re-arm', async (at, wins) => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: `intent-${at}`, + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + + if (at < 3_000) { + await vi.advanceTimersByTimeAsync(at); + service.handleGptEvent('slotRequested', { slot }); + } else { + await vi.advanceTimersByTimeAsync(at); + service.handleGptEvent('slotRequested', { slot }); + } + + if (wins) { + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `response-${at}`, + slot, + }); + await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); + } else { + await expect(request.result).resolves.toEqual({ + reason: 'gpt_request_timeout', + status: 'failed', + }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `late-${at}`, + slot, + }); + expect(service.snapshotForTest().cycles).toBe(0); + } + }); + + it.each(['event-first', 'timeout-first'] as const)( + 'arbitrates callback registration order at the exact 3,000 ms boundary: %s', + async (order) => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + if (order === 'event-first') { + setTimeout(() => service.handleGptEvent('slotRequested', { slot }), 3_000); + } + const request = service.request({ + intentId: order, + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + if (order === 'timeout-first') { + setTimeout(() => service.handleGptEvent('slotRequested', { slot }), 3_000); + } + await vi.advanceTimersByTimeAsync(3_000); + + if (order === 'event-first') { + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: order, + slot, + }); + await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); + } else { + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + } + } + ); + + it.each([ + [9_999, true], + [10_001, false], + ] as const)('arbitrates slotRenderEnded at %i ms from invocation', async (at, wins) => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: `intent-${at}`, + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + + if (at < 10_000) { + await vi.advanceTimersByTimeAsync(at); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `response-${at}`, + slot, + }); + } else { + await vi.advanceTimersByTimeAsync(at); + } + + await expect(request.result).resolves.toEqual( + wins + ? { responseIdentifier: `response-${at}`, status: 'rendered' } + : { reason: 'gpt_completion_timeout', status: 'failed' } + ); + }); + + it.each(['event-first', 'timeout-first'] as const)( + 'arbitrates callback registration order at the exact 10,000 ms boundary: %s', + async (order) => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: `completion-${order}`, + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + if (order === 'event-first') { + setTimeout( + () => + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: order, + slot, + }), + 10_000 + ); + } + service.handleGptEvent('slotRequested', { slot }); + if (order === 'timeout-first') { + setTimeout( + () => + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: order, + slot, + }), + 10_000 + ); + } + await vi.advanceTimersByTimeAsync(10_000); + + await expect(request.result).resolves.toMatchObject( + order === 'event-first' ? { status: 'rendered' } : { reason: 'gpt_completion_timeout' } + ); + } + ); + + it('deduplicates a response identifier without completing a replacement cycle', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const first = service.request({ + intentId: 'first', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'duplicate', + slot, + }); + await expect(first.result).resolves.toMatchObject({ status: 'rendered' }); + + const second = service.request({ + intentId: 'second', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'duplicate', + slot, + }); + await vi.advanceTimersByTimeAsync(10_000); + await expect(second.result).resolves.toEqual({ + reason: 'gpt_completion_timeout', + status: 'failed', + }); + }); + + it('recovers a completion timeout through the exact destroy/redefine transaction', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const disposeCommittedArtifact = vi.fn(); + const service = createSlotService({ + disposeCommittedArtifact, + googletag: harness.adapter, + }); + const navigation = createNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + const first = service.request({ + intentId: 'completion-timeout', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot: oldSlot }); + await vi.advanceTimersByTimeAsync(10_000); + await expect(first.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); + await Promise.resolve(); + const replacement = harness.defineSlot.mock.results[0]?.value; + if (typeof replacement !== 'object' || replacement === null) { + throw new Error('Expected completion-timeout replacement'); + } + expect(harness.destroySlots).toHaveBeenCalledExactlyOnceWith([oldSlot]); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'late-completion', + slot: oldSlot, + }); + const recovered = service.request({ + intentId: 'recovered', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + expect(recovered.status).toBe('active'); + expect(harness.refresh).toHaveBeenLastCalledWith( + [replacement], + Object.freeze({ changeCorrelator: false }) + ); + service.handleGptEvent('slotRequested', { slot: replacement }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'replacement-completion', + slot: replacement, + }); + await expect(recovered.result).resolves.toEqual({ + responseIdentifier: 'replacement-completion', + status: 'rendered', + }); + }); + + it('never releases publisher request-timeout quarantine from later GPT events', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = { publisher: true }; + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'publisher', + slot, + }) + ).toEqual({ ok: true }); + const timedOut = service.request({ + intentId: 'publisher-timeout', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(timedOut.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'unattributable-late', + slot, + }); + const later = service.request({ + intentId: 'publisher-later', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await expect(later.result).resolves.toEqual({ + reason: 'gpt_request_failed', + status: 'failed', + }); + }); + + it.each(['throw', 'false'] as const)( + 'keeps one retired object and quarantines failed request-timeout recovery: %s', + async (failure) => { + vi.useFakeTimers(); + const harness = createGptHarness(); + if (failure === 'throw') { + harness.destroySlots.mockImplementation(() => { + throw new Error('destroy failed'); + }); + } else { + harness.destroySlots.mockReturnValue(false); + } + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const timedOut = service.request({ + intentId: 'timed-out', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(timedOut.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + const later = service.request({ + intentId: 'later', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await expect(later.result).resolves.toEqual({ + reason: 'gpt_request_failed', + status: 'failed', + }); + expect(harness.defineSlot).not.toHaveBeenCalled(); + expect(service.snapshotForTest().physicalSlots).toBe(1); + } + ); + + it('binds one successful request-timeout replacement and ignores events from the retired object', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + const timedOut = service.request({ + intentId: 'timed-out', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(timedOut.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + const replacement = harness.defineSlot.mock.results[0]?.value; + if (typeof replacement !== 'object' || replacement === null) { + throw new Error('Expected a replacement slot'); + } + + service.handleGptEvent('slotRequested', { slot: oldSlot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'retired-old', + slot: oldSlot, + }); + const later = service.request({ + intentId: 'later', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + expect(harness.refresh).toHaveBeenLastCalledWith( + [replacement], + Object.freeze({ changeCorrelator: false }) + ); + service.handleGptEvent('slotRequested', { slot: replacement }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'replacement', + slot: replacement, + }); + await expect(later.result).resolves.toMatchObject({ status: 'rendered' }); + }); + + it('destroys a replacement created after generation became stale and never binds it', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + harness.defineSlot.mockImplementation((_path, _sizes, elementId) => { + const replacement = { elementId, replacement: true }; + navigation.dispose(); + return replacement; + }); + const request = service.request({ + intentId: 'stale', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect(harness.destroySlots).toHaveBeenCalledTimes(2); + expect(service.resolveRegisteredSlot('slot')).toBeUndefined(); + }); + + it('keeps publisher-owned navigation quarantine until its exact completion', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = { publisher: true }; + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'publisher', + slot, + }) + ).toEqual({ ok: true }); + service.recordPublisherIntent(slot); + service.handleGptEvent('slotRequested', { slot }); + navigation.dispose(); + expect(harness.destroySlots).not.toHaveBeenCalled(); + + const next = createNavigation(); + expect(service.register(next, [serverRegistration('next')])).toMatchObject({ ok: true }); + expect(service.adoptGptSlot(next.generation, 'next', { ownership: 'publisher', slot })).toEqual( + { ok: false, reason: 'slot_quarantined' } + ); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'old-navigation', + slot, + }); + expect(service.adoptGptSlot(next.generation, 'next', { ownership: 'publisher', slot })).toEqual( + { ok: true } + ); + }); + + it('blocks an active publisher placement across navigation until its completion drains', () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const registration = serverRegistration('slot', { + adUnitCode: '/network/slot', + domAliases: ['slot-div'], + }); + const slot = { publisher: true }; + expect(service.register(navigation, [registration])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { ownership: 'publisher', slot }) + ).toEqual({ ok: true }); + expect(service.recordPublisherIntent(slot)).toBe(true); + service.handleGptEvent('slotRequested', { slot }); + navigation.dispose(); + + const next = createNavigation(); + expect(service.register(next, [registration])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + expect(service.register(next, [registration])).toMatchObject({ ok: true }); + }); + + it.each(['before', 'after'] as const)( + 'keeps an old completion inert %s replacement completion on the same DOM id', + async (order) => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + const oldRequest = service.request({ + intentId: 'old', + navigationGeneration: navigation.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot: oldSlot }); + + const replaced = runtime.replaceNavigation(); + if (!replaced.ok) throw new Error('Expected replacement navigation'); + await expect(oldRequest.result).resolves.toMatchObject({ reason: 'navigation_disposed' }); + const newSlot = bindTrustedSlot(service, replaced.value); + const newRequest = service.request({ + intentId: 'new', + navigationGeneration: replaced.value.generation, + operation: 'refresh', + requestClass: 'primary', + registeredSlotId: 'slot', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot: newSlot }); + const finishOld = () => + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `old-${order}`, + slot: oldSlot, + }); + const finishNew = () => + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: `new-${order}`, + slot: newSlot, + }); + if (order === 'before') { + finishOld(); + finishNew(); + } else { + finishNew(); + finishOld(); + } + + await expect(newRequest.result).resolves.toEqual({ + responseIdentifier: `new-${order}`, + status: 'rendered', + }); + expect(service.snapshotForTest().cycles).toBe(0); + } + ); + + it('releases a navigation-disposed TS physical slot with no late cycle bookkeeping', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + + navigation.dispose(); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.destroySlots).toHaveBeenCalledTimes(1); + expect(service.snapshotForTest()).toMatchObject({ physicalSlots: 0, records: 0 }); + }); +}); + +describe('Task 11 adversarial ownership review', () => { + afterEach(() => vi.useRealTimers()); + + it('accepts paired UTF-16 surrogates and rejects unpaired identities and aliases', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + + expect(service.register(navigation, [serverRegistration('paired-😀')])).toMatchObject({ + ok: true, + }); + + for (const registration of [ + serverRegistration('broken-\ud800'), + serverRegistration('broken-\udc00'), + serverRegistration('slot', { adUnitCode: 'path-\ud800' }), + serverRegistration('slot', { domAliases: ['alias-\udc00'] }), + ]) { + expect(service.register(navigation, [registration])).toEqual({ + ok: false, + reason: 'invalid_slot_id', + }); + } + }); + + it('re-adopts an idle publisher object without retaining its old navigation strongly', () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const slot = { publisher: true }; + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { ownership: 'publisher', slot }) + ).toEqual({ ok: true }); + + const next = runtime.replaceNavigation(); + if (!next.ok) throw new Error('Expected replacement navigation'); + expect(service.snapshotForTest()).toMatchObject({ physicalSlots: 0, records: 0 }); + expect(service.register(next.value, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(next.value.generation, 'slot', { ownership: 'publisher', slot }) + ).toEqual({ ok: true }); + expect(service.snapshotForTest().physicalSlots).toBe(1); + }); + + it('rejects an existing GPT identity when the destination record already owns another object', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const first = {}; + const second = {}; + expect( + service.register(navigation, [serverRegistration('one'), serverRegistration('two')]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'one', { ownership: 'publisher', slot: first }) + ).toEqual({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'two', { ownership: 'publisher', slot: second }) + ).toEqual({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'two', { ownership: 'publisher', slot: first }) + ).toEqual({ ok: false, reason: 'gpt_object_collision' }); + }); + + it('releases an exact publisher quarantine only through explicit publisher destruction', async () => { + vi.useFakeTimers(); + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const slot = { publisher: true }; + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { ownership: 'publisher', slot }) + ).toEqual({ ok: true }); + const request = service.request({ + intentId: 'publisher-timeout', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + + const next = runtime.replaceNavigation(); + if (!next.ok) throw new Error('Expected replacement navigation'); + expect(service.register(next.value, [serverRegistration('slot')])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + expect(service.recordPublisherDestruction(slot)).toBe(true); + expect(service.register(next.value, [serverRegistration('slot')])).toMatchObject({ ok: true }); + }); + + it('quarantines every failed TS placement key and never retries its destroy on navigation', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + harness.destroySlots.mockReturnValue(false); + const service = createSlotService({ googletag: harness.adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'timeout', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + expect(harness.destroySlots).toHaveBeenCalledTimes(1); + + const next = runtime.replaceNavigation(); + if (!next.ok) throw new Error('Expected replacement navigation'); + await Promise.resolve(); + expect(harness.destroySlots).toHaveBeenCalledTimes(1); + for (const registration of [ + serverRegistration('slot'), + serverRegistration('other-id', { adUnitCode: '/network/slot' }), + serverRegistration('other-alias', { domAliases: ['slot-div'] }), + ]) { + expect(service.register(next.value, [registration])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + } + expect(service.recordPublisherDestruction(slot)).toBe(true); + expect(service.register(next.value, [serverRegistration('slot')])).toMatchObject({ ok: true }); + }); + + it('requires a usable replacement definition for trusted-server adoption', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'trusted_server', + slot: {}, + }) + ).toEqual({ ok: false, reason: 'gpt_request_failed' }); + }); + + it('reads a replacement definition once and owns an immutable placement snapshot', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + expect( + service.register(navigation, [ + serverRegistration('slot', { + adUnitCode: '/network/original', + domAliases: ['original-div'], + }), + ]) + ).toMatchObject({ ok: true }); + let adUnitPath = '/network/original'; + let elementId = 'original-div'; + const sizes = [[300, 250]]; + const reads = { adUnitPath: 0, elementId: 0, sizes: 0 }; + const definition = { + get adUnitPath() { + reads.adUnitPath += 1; + return adUnitPath; + }, + get elementId() { + reads.elementId += 1; + return elementId; + }, + get sizes() { + reads.sizes += 1; + return sizes; + }, + }; + const slot = { original: true }; + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition, + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + expect(reads).toEqual({ adUnitPath: 1, elementId: 1, sizes: 1 }); + + adUnitPath = '/network/redirected'; + elementId = 'redirected-div'; + sizes[0] = [999, 999]; + const request = service.request({ + intentId: 'immutable-definition', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect(reads).toEqual({ adUnitPath: 1, elementId: 1, sizes: 1 }); + expect(harness.defineSlot).toHaveBeenCalledWith( + '/network/original', + [[300, 250]], + 'original-div' + ); + }); + + it.each(['outer-array', 'inner-pair'] as const)( + 'contains a hostile replacement sizes graph without adoption mutation: %s', + (failure) => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ + ok: true, + }); + const innerPair = new Proxy([300, 250], { + get: (target, key, receiver) => { + if (failure === 'inner-pair' && key === '0') throw new Error('hostile pair index'); + return Reflect.get(target, key, receiver); + }, + }); + const sizes = new Proxy([innerPair], { + get: (target, key, receiver) => { + if (failure === 'outer-array' && key === 'length') { + throw new Error('hostile sizes length'); + } + return Reflect.get(target, key, receiver); + }, + }); + const inventory = service.snapshotForTest(); + + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes, + }, + ownership: 'trusted_server', + slot: {}, + }) + ).toEqual({ ok: false, reason: 'gpt_request_failed' }); + expect(service.snapshotForTest()).toEqual(inventory); + expect(service.resolveRegisteredSlot('slot')).toBeDefined(); + } + ); + + it('counts multiple publisher intents and preserves two publisher cycles', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + expect(service.recordPublisherIntent(slot)).toBe(true); + expect(service.recordPublisherIntent(slot)).toBe(true); + + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + service.handleGptEvent('slotRequested', { slot }); + expect(service.snapshotForTest().cycles).toBe(1); + service.handleGptEvent('slotRenderEnded', { isEmpty: true, slot }); + expect(service.snapshotForTest().cycles).toBe(0); + }); + + it('returns an exact accepted cycle handle and retires it on replacement and navigation', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + + const first = service.handleGptEvent('slotRequested', { slot }); + expect(Object.isFrozen(first)).toBe(true); + expect(Reflect.ownKeys(first ?? {})).toEqual(['isRetired']); + expect(first?.isRetired()).toBe(false); + expect(service.handleGptEvent('slotRequested', { slot })).toBeUndefined(); + expect( + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'first-response', + slot, + }) + ).toBe(first); + expect(first?.isRetired()).toBe(false); + + const second = service.handleGptEvent('slotRequested', { slot }); + expect(second).not.toBe(first); + expect(first?.isRetired()).toBe(true); + expect(second?.isRetired()).toBe(false); + + navigation.dispose(); + expect(second?.isRetired()).toBe(true); + }); + + it('bounds publisher intent accounting and fails closed on overflow', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + for (let index = 0; index < 64; index += 1) { + expect(service.recordPublisherIntent(slot)).toBe(true); + } + expect(service.recordPublisherIntent(slot)).toBe(false); + + const blocked = service.request({ + intentId: 'publisher-overflow', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(blocked.result).resolves.toEqual({ + reason: 'gpt_request_failed', + status: 'failed', + }); + }); + + it('fails and conservatively drains a TS cycle overlapped by publisher intent', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const active = service.request({ + intentId: 'active', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + + expect(service.recordPublisherIntent(slot)).toBe(true); + await expect(active.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + const blocked = service.request({ + intentId: 'blocked', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(blocked.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + service.handleGptEvent('slotRequested', { slot }); + expect(service.snapshotForTest().cycles).toBe(1); + }); + + it('rejects the first opposite-class queued request with the active intent', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const active = service.request({ + intentId: 'active', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + const opposite = service.request({ + intentId: 'opposite', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'fallback', + }); + + await expect(active.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + await expect(opposite.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + }); + + it('quarantines a synchronous requested cycle when the external invocation then throws', async () => { + const harness = createGptHarness({ synchronousRun: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + harness.refresh.mockImplementation(() => { + service.handleGptEvent('slotRequested', { slot }); + throw new Error('after-side-effect'); + }); + const request = service.request({ + intentId: 'partial', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_failed' }); + const blocked = service.request({ + intentId: 'blocked', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(blocked.result).resolves.toMatchObject({ reason: 'slot_quarantined' }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + }); + + it('keeps a shared synchronous SRA operation alive for an unfinished sibling', async () => { + const harness = createGptHarness({ synchronousRun: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first'); + const second = bindTrustedSlot(service, navigation, 'second'); + harness.refresh.mockImplementation(() => { + service.handleGptEvent('slotRequested', { slot: first }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot: first }); + service.handleGptEvent('slotRequested', { slot: second }); + }); + const requests = service.requestBatch([ + { + intentId: 'first', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }, + { + intentId: 'second', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'second', + requestClass: 'primary', + }, + ]); + await expect(requests[0]?.result).resolves.toMatchObject({ status: 'rendered' }); + expect(requests[1]?.status).toBe('active'); + expect( + harness.operationDisposals[harness.operationDisposals.length - 1] + ).not.toHaveBeenCalled(); + service.handleGptEvent('slotRenderEnded', { isEmpty: true, slot: second }); + await expect(requests[1]?.result).resolves.toMatchObject({ status: 'empty' }); + }); + + it('does not invoke an SRA batch after its subscription continuation is disposed', async () => { + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation, 'deferred-first'); + bindTrustedSlot(service, navigation, 'deferred-second'); + const requests = service.requestBatch([ + { + intentId: 'deferred-first', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'deferred-first', + requestClass: 'primary', + }, + { + intentId: 'deferred-second', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'deferred-second', + requestClass: 'primary', + }, + ]); + navigation.dispose(); + + await expect(Promise.all(requests.map(({ result }) => result))).resolves.toEqual([ + { reason: 'navigation_disposed', status: 'cancelled' }, + { reason: 'navigation_disposed', status: 'cancelled' }, + ]); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.refresh).not.toHaveBeenCalled(); + }); + + it('enforces delayed-handler deadlines from invocation with a monotonic injected clock', async () => { + vi.useFakeTimers(); + let current = 100; + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter, now: () => current }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'delayed', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + current = 3_101; + service.handleGptEvent('slotRequested', { slot }); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + }); + + it('does not let a timer fire before the injected clock reaches its deadline', async () => { + vi.useFakeTimers(); + let current = 0; + const service = createSlotService({ + googletag: createGptHarness().adapter, + now: () => current, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'lagged-clock', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + current = 2_999; + await vi.advanceTimersByTimeAsync(3_000); + expect(request.status).toBe('active'); + current = 3_000; + await vi.advanceTimersByTimeAsync(1); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + }); + + it('fails closed on malformed completion truth instead of rendering it', async () => { + vi.useFakeTimers(); + const malformedEvents = [ + {}, + { isEmpty: 'false' }, + Object.defineProperty({}, 'isEmpty', { get: () => false }), + ]; + for (let index = 0; index < malformedEvents.length; index += 1) { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation, `slot-${index}`); + const request = service.request({ + intentId: `malformed-${index}`, + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: `slot-${index}`, + requestClass: 'primary', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + const event = { slot }; + const malformed = malformedEvents[index]; + const descriptor = malformed + ? Object.getOwnPropertyDescriptor(malformed, 'isEmpty') + : undefined; + if (descriptor) Object.defineProperty(event, 'isEmpty', descriptor); + service.handleGptEvent('slotRenderEnded', event); + expect(request.status).toBe('active'); + await vi.advanceTimersByTimeAsync(10_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); + } + }); + + it('enforces the completion deadline in the handler when timer delivery is blocked', async () => { + vi.useFakeTimers(); + let current = 0; + const harness = createGptHarness(); + const service = createSlotService({ + googletag: harness.adapter, + now: () => current, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'blocked-completion-timer', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + current = 100; + service.handleGptEvent('slotRequested', { slot }); + current = 10_001; + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); + expect(service.snapshotForTest().cycles).toBe(0); + const replacement = harness.defineSlot.mock.results[0]?.value; + if (typeof replacement !== 'object' || replacement === null) { + throw new Error('Expected handler-enforced timeout replacement'); + } + + const next = service.request({ + intentId: 'after-late-exact-completion', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + expect(harness.refresh).toHaveBeenCalledTimes(2); + service.handleGptEvent('slotRequested', { slot: replacement }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot: replacement }); + await expect(next.result).resolves.toMatchObject({ status: 'rendered' }); + }); + + it('fails active and queued work when publisher intent overlaps the opened TS cycle', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const active = service.request({ + intentId: 'active', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + const queued = service.request({ + intentId: 'queued', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + + expect(service.recordPublisherIntent(slot)).toBe(true); + await expect(active.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + await expect(queued.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + }); + + it('keeps promoted listeners across an async command that emits synchronously and then throws', async () => { + const commands: Array<() => void> = []; + const listeners = new Map void>>(); + const pubads = { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const current = listeners.get(type) ?? new Set(); + current.add(listener); + listeners.set(type, current); + }, + getSlots: () => [slot], + refresh: vi.fn(() => { + for (const listener of listeners.get('slotRequested') ?? []) listener({ slot }); + throw new Error('after synchronous event'); + }), + removeEventListener: (type: string, listener: (event: unknown) => void) => { + listeners.get(type)?.delete(listener); + }, + }; + const adapter = createBrowserGoogletagAdapter({ + googletag: { + apiReady: true, + cmd: { push: (command: () => void) => commands.push(command) }, + display: vi.fn(), + pubads: () => pubads, + pubadsReady: true, + }, + }); + const service = createSlotService({ googletag: adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'async-partial', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + commands.shift()?.(); + await Promise.resolve(); + await Promise.resolve(); + commands.shift()?.(); + + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_failed' }); + expect(listeners.get('slotRequested')?.size).toBe(1); + expect(listeners.get('slotRenderEnded')?.size).toBe(1); + for (const listener of listeners.get('slotRenderEnded') ?? []) { + listener({ isEmpty: false, slot }); + } + }); + + it('quarantines every synchronously opened SRA cycle when shared refresh throws', async () => { + const harness = createGptHarness({ synchronousRun: true }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first-partial'); + const second = bindTrustedSlot(service, navigation, 'second-partial'); + harness.refresh.mockImplementation(() => { + service.handleGptEvent('slotRequested', { slot: first }); + service.handleGptEvent('slotRequested', { slot: second }); + throw new Error('shared refresh failed'); + }); + const requests = service.requestBatch([ + { + intentId: 'first-partial', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first-partial', + requestClass: 'primary', + }, + { + intentId: 'second-partial', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'second-partial', + requestClass: 'primary', + }, + ]); + + await expect(Promise.all(requests.map(({ result }) => result))).resolves.toEqual([ + { reason: 'gpt_request_failed', status: 'failed' }, + { reason: 'gpt_request_failed', status: 'failed' }, + ]); + expect(service.snapshotForTest().cycles).toBe(2); + }); + + it('tracks an exact orphan candidate until publisher destruction releases its placement', async () => { + vi.useFakeTimers(); + const orphan = { orphan: true }; + const harness = createGptHarness({ orphanOnReplace: orphan }); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'orphan', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + expect(service.recordPublisherDestruction(orphan)).toBe(true); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot: { replacementAfterOrphan: true }, + }) + ).toEqual({ ok: true }); + }); + + it('retains a reused old identity when rejecting it cannot destroy the candidate', async () => { + vi.useFakeTimers(); + const harness = createGptHarness({ returnOldOnReplace: true }); + harness.destroySlots.mockReturnValueOnce(true).mockReturnValueOnce(false); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'reused-old-orphan', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot: { blocked: true }, + }) + ).toEqual({ ok: false, reason: 'slot_quarantined' }); + expect(service.recordPublisherDestruction(oldSlot)).toBe(true); + }); + + it.each([true, false])( + 'never cleans or republishes a replacement candidate owned by another record: cleanup=%s', + async (candidateCleanupWouldSucceed) => { + vi.useFakeTimers(); + const harness = createReplacementHarness(); + harness.destroySlots + .mockReturnValueOnce(true) + .mockReturnValueOnce(candidateCleanupWouldSucceed); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const firstDefinition = Object.freeze({ + adUnitPath: '/network/first', + elementId: 'first-div', + sizes: Object.freeze([[300, 250]]), + }); + const secondDefinition = Object.freeze({ + adUnitPath: '/network/second', + elementId: 'second-div', + sizes: Object.freeze([[300, 250]]), + }); + const oldSlot = { old: true }; + expect( + service.register(navigation, [ + serverRegistration('first', { + adUnitCode: firstDefinition.adUnitPath, + domAliases: [firstDefinition.elementId], + }), + serverRegistration('second', { + adUnitCode: secondDefinition.adUnitPath, + domAliases: [secondDefinition.elementId], + }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'first', { + definition: firstDefinition, + ownership: 'trusted_server', + slot: oldSlot, + }) + ).toEqual({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, 'second', { + definition: secondDefinition, + ownership: 'trusted_server', + slot: harness.replacement, + }) + ).toEqual({ ok: true }); + const request = service.request({ + intentId: `collision-${String(candidateCleanupWouldSucceed)}`, + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect(harness.destroySlots).toHaveBeenCalledOnce(); + expect(harness.replacement.addService).not.toHaveBeenCalled(); + expect( + service.adoptGptSlot(navigation.generation, 'second', { + definition: secondDefinition, + ownership: 'trusted_server', + slot: harness.replacement, + }) + ).toEqual({ ok: true }); + const blocked = service.request({ + intentId: 'original-remains-quarantined', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }); + await expect(blocked.result).resolves.toMatchObject({ reason: 'gpt_request_failed' }); + } + ); + + it('leaves a clean define failure unbound and immediately re-adoptable', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + harness.defineSlot.mockReturnValue(undefined); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'define-failure', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot: { retry: true }, + }) + ).toEqual({ ok: true }); + }); + + it('deletes a stale destroyed identity so a later navigation may adopt it', async () => { + vi.useFakeTimers(); + const harness = createGptHarness(); + const service = createSlotService({ googletag: harness.adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const oldSlot = bindTrustedSlot(service, navigation); + harness.defineSlot.mockImplementation((_path, _sizes, elementId) => { + const candidate = { elementId }; + runtime.replaceNavigation(); + return candidate; + }); + const request = service.request({ + intentId: 'stale-destroyed', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); + await expect(request.result).resolves.toMatchObject({ reason: 'gpt_request_timeout' }); + await Promise.resolve(); + const next = runtime.currentNavigation; + if (!next) throw new Error('Expected replacement navigation'); + expect(service.register(next, [serverRegistration('slot')])).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(next.generation, 'slot', { ownership: 'publisher', slot: oldSlot }) + ).toEqual({ ok: true }); + }); + + it.each(['single', 'batch'] as const)( + 'rolls back provisional service subscription admission after %s preflight rejection', + async (kind) => { + const harness = createGptHarness({ synchronousRun: true }); + const subscribe = vi.fn((_type: string, _listener: (event: unknown) => void) => vi.fn()); + const facade = Object.freeze({ ...harness.facade, subscribe }); + let rejectNext = true; + const adapter: GoogletagAdapter = Object.freeze({ + bindingStatus: () => 'present', + dispose: vi.fn(), + notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), + observePublisherCalls: () => vi.fn(), + traceToken: () => undefined, + run: (command: (gpt: Readonly) => T) => { + let value: T; + try { + value = command(facade); + } catch (error) { + return Object.freeze({ + status: 'present' as const, + result: Promise.reject(error), + dispose: vi.fn(), + }); + } + const result = rejectNext + ? Promise.reject(new Error('post-command rejection')) + : Promise.resolve(value); + rejectNext = false; + return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); + }, + }); + const service = createSlotService({ googletag: adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const input = { + intentId: 'preflight', + navigationGeneration: navigation.generation, + operation: 'refresh' as const, + registeredSlotId: 'slot', + requestClass: 'primary', + }; + const failed = kind === 'single' ? [service.request(input)] : service.requestBatch([input]); + await expect(failed[0]?.result).resolves.toMatchObject({ reason: 'gpt_request_failed' }); + const retried = service.request({ ...input, intentId: 'retry' }); + await Promise.resolve(); + + expect(subscribe).toHaveBeenCalledTimes(4); + retried.dispose(); + } + ); + + it('fails closed after bounded placement quarantine storage saturates', () => { + const harness = createGptHarness(); + harness.destroySlots.mockReturnValue(false); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + for (let recordIndex = 0; recordIndex < 9; recordIndex += 1) { + const id = `saturated-${recordIndex}`; + const aliases = Array.from({ length: 256 }, (_, aliasIndex) => `${id}-alias-${aliasIndex}`); + expect( + service.register(navigation, [ + serverRegistration(id, { adUnitCode: `/network/${id}`, domAliases: aliases }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: `/network/${id}`, + elementId: aliases[0] ?? `${id}-div`, + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot: { id }, + }) + ).toEqual({ ok: true }); + } + navigation.dispose(); + const next = createNavigation(); + expect(service.register(next, [serverRegistration('unrelated')])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + }); + + it('clears saturated placement quarantine only after every saturated owner releases once', () => { + const harness = createGptHarness(); + harness.destroySlots.mockReturnValue(false); + const service = createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const oldSlots: object[] = []; + for (let recordIndex = 0; recordIndex < 9; recordIndex += 1) { + const id = `recover-saturated-${recordIndex}`; + const aliases = Array.from({ length: 256 }, (_, aliasIndex) => `${id}-${aliasIndex}`); + const slot = { id }; + oldSlots[oldSlots.length] = slot; + expect( + service.register(navigation, [ + serverRegistration(id, { adUnitCode: `/network/${id}`, domAliases: aliases }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: `/network/${id}`, + elementId: aliases[0] ?? `${id}-div`, + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + } + navigation.dispose(); + const next = createNavigation(); + expect(service.register(next, [serverRegistration('unrelated-after-saturation')])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + + for (let index = 0; index < oldSlots.length - 1; index += 1) { + expect(service.recordPublisherDestruction(oldSlots[index] as object)).toBe(true); + } + expect(service.recordPublisherDestruction(oldSlots[7] as object)).toBe(false); + expect(service.register(next, [serverRegistration('unrelated-after-saturation')])).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + + expect(service.recordPublisherDestruction(oldSlots[8] as object)).toBe(true); + expect( + service.register(next, [serverRegistration('unrelated-after-saturation')]) + ).toMatchObject({ + ok: true, + }); + }); + + it.each(['throw-before', 'mutate-then-throw'] as const)( + 'releases only confirmed shared-key quarantine increments: %s', + async (failure) => { + const originalSet = Map.prototype.set; + let poison = false; + Map.prototype.set = function ( + this: Map, + key: Key, + value: Value + ): Map { + const targeted = poison && key === ('ad-unit:/shared' as Key) && value === (2 as Value); + if (targeted && failure === 'throw-before') throw new Error('failed before increment'); + const result = Reflect.apply(originalSet, this, [key, value]) as Map; + if (targeted) throw new Error('failed after increment'); + return result; + }; + vi.resetModules(); + let fresh: typeof import('../../src/services/slots'); + try { + fresh = await import('../../src/services/slots'); + } finally { + Map.prototype.set = originalSet; + } + const harness = createGptHarness(); + harness.destroySlots.mockReturnValue(false); + const service = fresh.createSlotService({ googletag: harness.adapter }); + const navigation = createNavigation(); + const firstSlot = { first: true }; + const secondSlot = { second: true }; + for (const [id, slot] of [ + ['first', firstSlot], + ['second', secondSlot], + ] as const) { + expect( + service.register(navigation, [ + serverRegistration(id, { adUnitCode: '/shared', domAliases: [`${id}-div`] }), + ]) + ).toMatchObject({ ok: true }); + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: `/network/${id}`, + elementId: `${id}-div`, + sizes: [[300, 250]], + }, + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + } + poison = true; + navigation.dispose(); + const next = createNavigation(); + + expect(service.recordPublisherDestruction(secondSlot)).toBe(true); + expect(service.recordPublisherDestruction(secondSlot)).toBe(false); + expect( + service.register(next, [serverRegistration('third', { adUnitCode: '/shared' })]) + ).toEqual({ ok: false, reason: 'slot_quarantined' }); + + expect(service.recordPublisherDestruction(firstSlot)).toBe(true); + expect( + service.register(next, [serverRegistration('third', { adUnitCode: '/shared' })]) + ).toMatchObject({ ok: true }); + } + ); + + it('rolls back a Map publication whose captured set mutates and then throws', async () => { + const originalSet = Map.prototype.set; + let poison = false; + Map.prototype.set = function (this: Map, key: K, value: V): Map { + const result = Reflect.apply(originalSet, this, [key, value]) as Map; + if (poison && key === 'mutate-then-throw-slot') throw new Error('mutated then threw'); + return result; + }; + vi.resetModules(); + let fresh: typeof import('../../src/services/slots'); + try { + fresh = await import('../../src/services/slots'); + } finally { + Map.prototype.set = originalSet; + } + const service = fresh.createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + poison = true; + expect(service.register(navigation, [serverRegistration('mutate-then-throw-slot')])).toEqual({ + ok: false, + reason: 'stale_owner', + }); + poison = false; + expect(service.resolveRegisteredSlot('mutate-then-throw-slot')).toBeUndefined(); + expect(service.snapshotForTest().records).toBe(0); + }); + + it('uses captured iterator next intrinsics after publisher prototype poisoning', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const mapIteratorPrototype = Object.getPrototypeOf(new Map().values()) as { + next: () => IteratorResult; + }; + const setIteratorPrototype = Object.getPrototypeOf(new Set().values()) as { + next: () => IteratorResult; + }; + const mapNext = mapIteratorPrototype.next; + const setNext = setIteratorPrototype.next; + mapIteratorPrototype.next = () => { + throw new Error('poisoned map iterator'); + }; + setIteratorPrototype.next = () => { + throw new Error('poisoned set iterator'); + }; + try { + expect(service.snapshotForTest()).toMatchObject({ physicalSlots: 1, records: 1 }); + expect(() => service.dispose()).not.toThrow(); + } finally { + mapIteratorPrototype.next = mapNext; + setIteratorPrototype.next = setNext; + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/targeting.test.ts b/crates/trusted-server-js/lib/test/services/targeting.test.ts new file mode 100644 index 000000000..022abf5d3 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -0,0 +1,775 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createBrowserGoogletagAdapter } from '../../src/adapters/googletag'; +import { createTargetingService } from '../../src/services/targeting'; + +function createTargetingHarness(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + const clearTargeting = vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }); + const getTargeting = vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])); + const setTargeting = vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + return { clearTargeting, getTargeting, setTargeting, values }; +} + +describe('owner-aware targeting journal', () => { + it('restores the exact publisher predecessor after the current TS owner releases', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const frame = service.own(slot, 'key', 'trusted', 'owner-one', targeting); + expect(frame).toBeDefined(); + expect(targeting.values.get('key')).toEqual(['trusted']); + + frame?.release(); + + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(targeting.setTargeting).toHaveBeenLastCalledWith('key', ['publisher']); + }); + + it('keeps equal-string generations distinct and rebases non-top release without a GPT write', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const older = service.own(slot, 'key', 'same', 'older', targeting); + const newer = service.own(slot, 'key', 'same', 'newer', targeting); + targeting.setTargeting.mockClear(); + + older?.release(); + expect(targeting.setTargeting).not.toHaveBeenCalled(); + expect(targeting.clearTargeting).not.toHaveBeenCalled(); + newer?.release(); + + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(targeting.setTargeting).toHaveBeenCalledExactlyOnceWith('key', ['publisher']); + }); + + it.each(['same', 'different'] as const)( + 'invalidates the restoration chain before a publisher %s-value write', + (publisherValue) => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const frame = service.own(slot, 'key', 'same', 'owner', targeting); + + service.invalidatePublisherMutation(slot, 'key'); + targeting.setTargeting('key', publisherValue === 'same' ? 'same' : 'publisher-new'); + targeting.setTargeting.mockClear(); + frame?.release(); + + expect(targeting.setTargeting).not.toHaveBeenCalled(); + expect(targeting.clearTargeting).not.toHaveBeenCalled(); + expect(targeting.values.get('key')).toEqual([ + publisherValue === 'same' ? 'same' : 'publisher-new', + ]); + } + ); + + it('invalidates one key or all keys for publisher clear operations', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ one: ['publisher-one'], two: ['publisher-two'] }); + const one = service.own(slot, 'one', 'ts-one', 'owner', targeting); + const two = service.own(slot, 'two', 'ts-two', 'owner', targeting); + service.invalidatePublisherMutation(slot, 'one'); + targeting.clearTargeting('one'); + one?.release(); + expect(targeting.values.get('one')).toBeUndefined(); + + service.invalidatePublisherMutation(slot); + targeting.clearTargeting(); + two?.release(); + expect(targeting.values.size).toBe(0); + }); + + it('drops a stale chain instead of overwriting a publisher mutation before the next TS write', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const stale = service.own(slot, 'key', 'old-ts', 'old-owner', targeting); + targeting.setTargeting('key', 'publisher-race'); + const current = service.own(slot, 'key', 'new-ts', 'new-owner', targeting); + stale?.release(); + current?.release(); + + expect(targeting.values.get('key')).toEqual(['publisher-race']); + }); + + it('preserves sibling-key journals when a stale key is replaced', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ one: ['publisher-one'], two: ['publisher-two'] }); + const stale = service.own(slot, 'one', 'old-one', 'old-owner', targeting); + const sibling = service.own(slot, 'two', 'trusted-two', 'sibling-owner', targeting); + targeting.setTargeting('one', 'publisher-race'); + + const current = service.own(slot, 'one', 'new-one', 'new-owner', targeting); + expect(service.snapshotForTest()).toEqual({ frames: 2, slots: 1 }); + stale?.release(); + current?.release(); + sibling?.release(); + + expect(targeting.values.get('one')).toEqual(['publisher-race']); + expect(targeting.values.get('two')).toEqual(['publisher-two']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('rolls back publication when setTargeting throws and contains cleanup failures', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + targeting.setTargeting.mockImplementationOnce(() => { + throw new Error('set failed'); + }); + + expect(() => service.own(slot, 'key', 'ts', 'owner', targeting)).toThrow('set failed'); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + + const frame = service.own(slot, 'key', 'ts', 'owner', targeting); + targeting.setTargeting.mockImplementationOnce(() => { + throw new Error('restore failed'); + }); + expect(() => frame?.release()).not.toThrow(); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + service.disposeOwner('owner'); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('uses the real adapter to invalidate before publisher set, per-key clear, and clear-all', async () => { + const values = new Map([['key', ['publisher']]]); + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }; + const serviceObject = { + addEventListener: vi.fn(), + getSlots: () => [slot], + refresh: vi.fn(), + removeEventListener: vi.fn(), + }; + const googletag = { + apiReady: true, + cmd: { push: (command: () => void) => command() }, + display: vi.fn(), + pubads: () => serviceObject, + pubadsReady: true, + }; + const adapter = createBrowserGoogletagAdapter({ googletag }); + const service = createTargetingService(); + const observation = service.observePublisherMutations(slot, adapter); + await expect(observation.result).resolves.toBeUndefined(); + const write = adapter.run((gpt) => + service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ); + const frame = await write.result; + expect(values.get('key')).toEqual(['trusted']); + + slot.setTargeting('key', 'publisher-new'); + frame?.release(); + expect(values.get('key')).toEqual(['publisher-new']); + + const perKey = await adapter.run((gpt) => + service.own(slot, 'key', 'trusted-two', 'owner-two', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + slot.clearTargeting('key'); + perKey?.release(); + expect(values.get('key')).toBeUndefined(); + + values.set('key', ['publisher-three']); + const clearAll = await adapter.run((gpt) => + service.own(slot, 'key', 'trusted-three', 'owner-three', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + slot.clearTargeting(); + clearAll?.release(); + expect(values.size).toBe(0); + }); + + it('invalidates a TS journal when its captured native setter reenters a same-value publisher set', async () => { + const values = new Map([['key', ['publisher']]]); + let reentered = false; + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + if (reentered) return; + reentered = true; + slot.setTargeting(key, value); + }), + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + await expect(service.observePublisherMutations(slot, adapter).result).resolves.toBeUndefined(); + + const frame = await adapter.run((gpt) => + service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + frame?.release(); + + expect(values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it.each(['same_set', 'different_set', 'per_key_clear', 'clear_all'] as const)( + 'invalidates after publisher wrapper replacement for %s without calling that replacement on release', + async (mutation) => { + const values = new Map([ + ['key', ['publisher']], + ['sibling', ['publisher-sibling']], + ]); + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + await expect( + service.observePublisherMutations(slot, adapter).result + ).resolves.toBeUndefined(); + const frame = await adapter.run((gpt) => + service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + + const publisherSet = vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + const publisherClear = vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }); + if (mutation === 'same_set' || mutation === 'different_set') { + slot.setTargeting = publisherSet; + slot.setTargeting('key', mutation === 'same_set' ? 'trusted' : 'publisher-new'); + } else { + slot.clearTargeting = publisherClear; + slot.clearTargeting(mutation === 'per_key_clear' ? 'key' : undefined); + } + + frame?.release(); + + expect(publisherSet).toHaveBeenCalledTimes( + mutation === 'same_set' || mutation === 'different_set' ? 1 : 0 + ); + expect(publisherClear).toHaveBeenCalledTimes( + mutation === 'per_key_clear' || mutation === 'clear_all' ? 1 : 0 + ); + if (mutation === 'same_set') expect(values.get('key')).toEqual(['trusted']); + else if (mutation === 'different_set') expect(values.get('key')).toEqual(['publisher-new']); + else expect(values.get('key')).toBeUndefined(); + if (mutation === 'clear_all') expect(values.size).toBe(0); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + } + ); + + it('invalidates when a targeting read replaces an observed wrapper during release', async () => { + const values = new Map([['key', ['publisher']]]); + const publisherReplacement = vi.fn((key: string, value: string | readonly string[]): void => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + await expect(service.observePublisherMutations(slot, adapter).result).resolves.toBeUndefined(); + const frame = service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => { + adapter.run((gpt) => gpt.clearTargeting(slot, key)); + }, + getTargeting: (key) => { + let current: readonly string[] = Object.freeze([]); + adapter.run((gpt) => { + current = gpt.getTargeting(slot, key); + }); + return current; + }, + setTargeting: (key, value) => { + adapter.run((gpt) => gpt.setTargeting(slot, key, value)); + }, + }); + slot.getTargeting.mockImplementationOnce((key: string) => { + slot.setTargeting = publisherReplacement; + return Object.freeze([...(values.get(key) ?? [])]); + }); + + frame?.release(); + + expect(publisherReplacement).not.toHaveBeenCalled(); + expect(values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); +}); + +function adapterForTargetingSlot(slot: object) { + const pubads = { + addEventListener: vi.fn(), + getSlots: () => [slot], + refresh: vi.fn(), + removeEventListener: vi.fn(), + }; + return createBrowserGoogletagAdapter({ + googletag: { + apiReady: true, + cmd: { push: (command: () => void) => command() }, + display: vi.fn(), + pubads: () => pubads, + pubadsReady: true, + }, + }); +} + +describe('adapter-owned targeting interception', () => { + it('suppresses TS facade writes and preserves publisher order, arguments, return, and throw', async () => { + const order: string[] = []; + const publisherError = new Error('native clear failed'); + const setTargeting = vi.fn((key: string, value: string) => { + order.push(`native-set:${key}:${value}`); + return 'native-result'; + }); + const clearTargeting = vi.fn(() => { + order.push('native-clear'); + throw publisherError; + }); + const slot = { clearTargeting, getTargeting: () => [], setTargeting }; + const adapter = adapterForTargetingSlot(slot); + const observer = vi.fn((_slot: object, key?: string) => order.push(`observer:${key ?? '*'}`)); + const operation = adapter.run((gpt) => { + gpt.observeTargeting(slot, { beforePublisherMutation: observer }); + gpt.setTargeting(slot, 'ts-key', 'ts-value'); + }); + await expect(operation.result).resolves.toBeUndefined(); + expect(observer).not.toHaveBeenCalled(); + order.length = 0; + + expect(slot.setTargeting('publisher-key', 'publisher-value')).toBe('native-result'); + expect(order).toEqual(['observer:publisher-key', 'native-set:publisher-key:publisher-value']); + order.length = 0; + expect(() => slot.clearTargeting()).toThrow(publisherError); + expect(order).toEqual(['observer:*', 'native-clear']); + }); + + it('uses one wrapper with independent observers and restores exactly after out-of-order release', async () => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const slot = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + const adapter = adapterForTargetingSlot(slot); + const first = vi.fn(); + const second = vi.fn(); + const releases = await adapter.run( + (gpt) => + [ + gpt.observeTargeting(slot, { beforePublisherMutation: first }), + gpt.observeTargeting(slot, { beforePublisherMutation: second }), + ] as const + ).result; + const installedSet = slot.setTargeting; + + slot.setTargeting('both', 'value'); + expect(first).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledOnce(); + releases[0](); + expect(slot.setTargeting).toBe(installedSet); + slot.setTargeting('second', 'value'); + expect(first).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledTimes(2); + releases[1](); + + expect(slot.setTargeting).toBe(originalSet); + expect(slot.clearTargeting).toBe(originalClear); + slot.setTargeting('native', 'value'); + expect(second).toHaveBeenCalledTimes(2); + }); + + it('reports wrapper replacement fail-closed and never overwrites a publisher replacement', async () => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const replacementSet = vi.fn(); + const target = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + let trapDescriptors = false; + const slot = new Proxy(target, { + getOwnPropertyDescriptor: (current, key) => { + if (trapDescriptors) throw new Error('publisher descriptor trap'); + return Reflect.getOwnPropertyDescriptor(current, key); + }, + }); + const adapter = adapterForTargetingSlot(slot); + const observation = await adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ).result; + + expect(observation.isCurrent()).toBe(true); + target.setTargeting = replacementSet; + expect(observation.isCurrent()).toBe(false); + observation(); + expect(target.setTargeting).toBe(replacementSet); + expect(target.clearTargeting).toBe(originalClear); + + const trapped = await adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ).result; + trapDescriptors = true; + expect(() => trapped.isCurrent()).not.toThrow(); + expect(trapped.isCurrent()).toBe(false); + expect(() => trapped()).not.toThrow(); + }); + + it('rolls back the first method when transactional observer installation cannot wrap the second', async () => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const slot = { getTargeting: () => [], setTargeting: originalSet } as unknown as { + clearTargeting: () => void; + getTargeting: () => readonly string[]; + setTargeting: (key: string, value: string) => void; + }; + Object.defineProperty(slot, 'clearTargeting', { + configurable: false, + value: originalClear, + writable: false, + }); + const adapter = adapterForTargetingSlot(slot); + const operation = adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(slot.setTargeting).toBe(originalSet); + expect(slot.clearTargeting).toBe(originalClear); + }); + + it.each(['false', 'throw'] as const)( + 'compare-restores setTargeting when a Proxy define trap mutates then returns %s', + async (failure) => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const target = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + let attempted = false; + const slot = new Proxy(target, { + defineProperty: (current, key, descriptor) => { + const result = Reflect.defineProperty(current, key, descriptor); + if (key === 'setTargeting' && !attempted) { + attempted = true; + if (failure === 'throw') throw new Error('mutated then threw'); + return false; + } + return result; + }, + }); + const adapter = adapterForTargetingSlot(slot); + const operation = adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(target.setTargeting).toBe(originalSet); + expect(target.clearTargeting).toBe(originalClear); + } + ); + + it.each(['false', 'throw'] as const)( + 'restores both wrappers when the clearTargeting Proxy define trap mutates then returns %s', + async (failure) => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const target = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + let attempted = false; + const slot = new Proxy(target, { + defineProperty: (current, key, descriptor) => { + const result = Reflect.defineProperty(current, key, descriptor); + if (key === 'clearTargeting' && !attempted) { + attempted = true; + if (failure === 'throw') throw new Error('mutated then threw'); + return false; + } + return result; + }, + }); + const adapter = adapterForTargetingSlot(slot); + const operation = adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ); + + await expect(operation.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(target.setTargeting).toBe(originalSet); + expect(target.clearTargeting).toBe(originalClear); + } + ); + + it('lets one observation dispose its wrappers after its adapter operation settled', async () => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const slot = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + const observation = service.observePublisherMutations(slot, adapter); + await expect(observation.result).resolves.toBeUndefined(); + expect(slot.setTargeting).not.toBe(originalSet); + + observation.dispose(); + + expect(slot.setTargeting).toBe(originalSet); + expect(slot.clearTargeting).toBe(originalClear); + }); +}); + +describe('targeting mutate-then-throw recovery', () => { + it('rejects a successful no-op write and removes only its failed frame', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const older = service.own(slot, 'key', 'older', 'older-owner', targeting); + targeting.setTargeting.mockImplementationOnce(() => undefined); + + expect(() => service.own(slot, 'key', 'newer', 'newer-owner', targeting)).toThrow( + 'GPT targeting postcondition failed' + ); + expect(targeting.values.get('key')).toEqual(['older']); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + older?.release(); + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('retains owner-disposable quarantine when a successful write leaves the wrong value', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + targeting.setTargeting.mockImplementationOnce((key) => { + targeting.values.set(key, Object.freeze(['wrong-value'])); + }); + + expect(() => service.own(slot, 'key', 'trusted', 'owner', targeting)).toThrow( + 'GPT targeting postcondition failed' + ); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + service.disposeOwner('owner'); + expect(targeting.values.get('key')).toEqual(['wrong-value']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('restores the publisher predecessor when installation mutates then throws', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + targeting.setTargeting.mockImplementationOnce((key, value) => { + targeting.values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + throw new Error('mutated then threw'); + }); + + expect(() => service.own(slot, 'key', 'trusted', 'owner', targeting)).toThrow( + 'mutated then threw' + ); + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('retains owner-disposable quarantine when failed restoration did not mutate', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const frame = service.own(slot, 'key', 'trusted', 'owner', targeting); + targeting.setTargeting.mockImplementationOnce(() => { + throw new Error('failed before mutation'); + }); + + frame?.release(); + + expect(targeting.values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + service.disposeOwner('owner'); + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('removes ownership when restoration mutates to the predecessor and then throws', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const frame = service.own(slot, 'key', 'trusted', 'owner', targeting); + targeting.setTargeting.mockImplementationOnce((key, value) => { + targeting.values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + throw new Error('mutated then threw'); + }); + + frame?.release(); + + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('retains an owner-disposable frame when post-failure state cannot be read', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + targeting.setTargeting.mockImplementationOnce((key, value) => { + targeting.values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + throw new Error('mutated then threw'); + }); + targeting.getTargeting + .mockImplementationOnce(() => ['publisher']) + .mockImplementationOnce(() => { + throw new Error('unreadable after failure'); + }); + + expect(() => service.own(slot, 'key', 'trusted', 'owner', targeting)).toThrow( + 'mutated then threw' + ); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + targeting.getTargeting.mockImplementation((key: string) => + Object.freeze([...(targeting.values.get(key) ?? [])]) + ); + service.disposeOwner('owner'); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('retains owner-disposable quarantine when failed installation leaves unknown state', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + targeting.setTargeting.mockImplementationOnce((key) => { + targeting.values.set(key, Object.freeze(['publisher-interference'])); + throw new Error('mutated unpredictably then threw'); + }); + + expect(() => service.own(slot, 'key', 'trusted', 'owner', targeting)).toThrow( + 'mutated unpredictably then threw' + ); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + service.disposeOwner('owner'); + expect(targeting.values.get('key')).toEqual(['publisher-interference']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('rolls back only a newer failed publication when the older TS value never changed', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['publisher'] }); + const older = service.own(slot, 'key', 'older', 'older-owner', targeting); + targeting.setTargeting.mockImplementationOnce(() => { + throw new Error('newer failed before mutation'); + }); + + expect(() => service.own(slot, 'key', 'newer', 'newer-owner', targeting)).toThrow( + 'newer failed before mutation' + ); + expect(targeting.values.get('key')).toEqual(['older']); + expect(service.snapshotForTest()).toEqual({ frames: 1, slots: 1 }); + older?.release(); + expect(targeting.values.get('key')).toEqual(['publisher']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + + it('releases service observation ownership when adapter promotion rejects', async () => { + const externalRelease = vi.fn(); + const facade = { + observeTargeting: () => externalRelease, + } as never; + const adapter = { + run: (command: (gpt: never) => void) => { + command(facade); + return Object.freeze({ + status: 'incompatible' as const, + result: Promise.reject(new Error('promotion rejected')), + dispose: vi.fn(), + }); + }, + } as never; + const service = createTargetingService(); + const observation = service.observePublisherMutations({}, adapter); + + await expect(observation.result).rejects.toThrow('promotion rejected'); + expect(externalRelease).toHaveBeenCalledOnce(); + service.dispose(); + expect(externalRelease).toHaveBeenCalledOnce(); + }); + + it('disposes frames through captured Set iterator next after prototype poisoning', () => { + const service = createTargetingService(); + const targeting = createTargetingHarness({ key: ['publisher'] }); + service.own({}, 'key', 'trusted', 'owner', targeting); + const iteratorPrototype = Object.getPrototypeOf(new Set().values()) as { + next: () => IteratorResult; + }; + const originalNext = iteratorPrototype.next; + iteratorPrototype.next = () => { + throw new Error('poisoned iterator'); + }; + try { + expect(() => service.disposeOwner('owner')).not.toThrow(); + } finally { + iteratorPrototype.next = originalNext; + } + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts index 881a4515f..92f4d0593 100644 --- a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts +++ b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts @@ -1,37 +1,61 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { createBeaconGuard, BeaconGuardConfig } from '../../src/shared/beacon_guard'; +import { createBeaconGuard } from '../../src/shared/beacon_guard'; +import type { BeaconGuardConfig } from '../../src/shared/beacon_guard'; + +function hasHttpHostname(url: string, hostname: string): boolean { + try { + const parsed = new URL(url); + return ( + (parsed.protocol === 'https:' || parsed.protocol === 'http:') && parsed.hostname === hostname + ); + } catch { + return false; + } +} + +function rewriteToProxy(url: string, proxyPath: string): string { + const parsed = new URL(url); + return `http://localhost${proxyPath}${parsed.pathname}${parsed.search}${parsed.hash}`; +} describe('Beacon Guard', () => { - let originalSendBeacon: typeof navigator.sendBeacon; - let originalFetch: typeof window.fetch; + let originalSendBeaconDescriptor: PropertyDescriptor | undefined; + let originalFetchDescriptor: PropertyDescriptor | undefined; let sendBeaconSpy: ReturnType; let fetchSpy: ReturnType; let config: BeaconGuardConfig; beforeEach(() => { // Save originals - originalSendBeacon = navigator.sendBeacon; - originalFetch = window.fetch; + originalSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + originalFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); // Create spies that simulate real sendBeacon/fetch behaviour sendBeaconSpy = vi.fn(() => true); - navigator.sendBeacon = sendBeaconSpy; + navigator.sendBeacon = sendBeaconSpy as typeof navigator.sendBeacon; fetchSpy = vi.fn(() => Promise.resolve(new Response('', { status: 200 }))); - window.fetch = fetchSpy; + window.fetch = fetchSpy as typeof window.fetch; config = { name: 'Test', - isTargetUrl: (url: string) => url.includes('analytics.example.com'), - rewriteUrl: (url: string) => - url.replace(/https?:\/\/analytics\.example\.com/, 'http://localhost/proxy'), + isTargetUrl: (url: string) => hasHttpHostname(url, 'analytics.example.com'), + rewriteUrl: (url: string) => rewriteToProxy(url, '/proxy'), }; }); afterEach(() => { - navigator.sendBeacon = originalSendBeacon; - window.fetch = originalFetch; + if (originalSendBeaconDescriptor) { + Object.defineProperty(navigator, 'sendBeacon', originalSendBeaconDescriptor); + } else { + Reflect.deleteProperty(navigator, 'sendBeacon'); + } + if (originalFetchDescriptor) { + Object.defineProperty(window, 'fetch', originalFetchDescriptor); + } else { + Reflect.deleteProperty(window, 'fetch'); + } }); describe('createBeaconGuard', () => { @@ -82,6 +106,18 @@ describe('Beacon Guard', () => { expect(sendBeaconSpy).toHaveBeenCalledWith('https://other.example.com/track', 'data'); }); + it.each([ + 'https://analytics.example.com.evil.test/collect', + 'https://analytics.example.com@evil.test/collect', + ])('should pass through an analytics hostname lookalike: %s', (url) => { + const guard = createBeaconGuard(config); + guard.install(); + + navigator.sendBeacon(url, 'data'); + + expect(sendBeaconSpy).toHaveBeenCalledWith(url, 'data'); + }); + it('should forward body data', () => { const guard = createBeaconGuard(config); guard.install(); @@ -130,7 +166,7 @@ describe('Beacon Guard', () => { await window.fetch(request); // The spy should receive a new Request with the rewritten URL - const calledArg = fetchSpy.mock.calls[0][0]; + const calledArg = fetchSpy.mock.calls[0]![0] as Request; expect(calledArg).toBeInstanceOf(Request); expect(calledArg.url).toContain('/proxy/g/collect?tid=G-TEST'); }); @@ -146,12 +182,177 @@ describe('Beacon Guard', () => { }); }); + it('restores the exact publisher-owned descriptors on reset', () => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const guard = createBeaconGuard(config); + + guard.install(); + guard.reset(); + + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconDescriptor); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + }); + + it.each(['sendBeacon', 'fetch'] as const)( + 'leaves a publisher %s replacement intact while releasing the other wrapper', + (replaced) => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const guard = createBeaconGuard(config); + guard.install(); + const replacementSendBeacon = vi.fn(() => false) as typeof navigator.sendBeacon; + const replacementFetch = vi.fn(() => Promise.resolve(new Response())) as typeof window.fetch; + if (replaced === 'sendBeacon') navigator.sendBeacon = replacementSendBeacon; + else window.fetch = replacementFetch; + + guard.reset(); + + if (replaced === 'sendBeacon') { + expect(navigator.sendBeacon).toBe(replacementSendBeacon); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + } else { + expect(window.fetch).toBe(replacementFetch); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual( + sendBeaconDescriptor + ); + } + } + ); + + it('leaves descriptor-attribute changes to the installed wrappers intact', () => { + const guard = createBeaconGuard(config); + guard.install(); + const installedSendBeacon = navigator.sendBeacon; + const installedFetch = window.fetch; + const sendBeaconReplacement = { + configurable: true, + enumerable: false, + value: installedSendBeacon, + writable: true, + } satisfies PropertyDescriptor; + const fetchReplacement = { + configurable: true, + enumerable: false, + value: installedFetch, + writable: true, + } satisfies PropertyDescriptor; + Object.defineProperty(navigator, 'sendBeacon', sendBeaconReplacement); + Object.defineProperty(window, 'fetch', fetchReplacement); + + guard.reset(); + + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconReplacement); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchReplacement); + }); + + it('does not invoke or replace hostile publisher accessors during reset', () => { + const guard = createBeaconGuard(config); + guard.install(); + const sendBeaconGetter = vi.fn(() => { + throw new Error('sendBeacon getter must remain inert'); + }); + const fetchGetter = vi.fn(() => { + throw new Error('fetch getter must remain inert'); + }); + const sendBeaconReplacement = { + configurable: true, + enumerable: true, + get: sendBeaconGetter, + } satisfies PropertyDescriptor; + const fetchReplacement = { + configurable: true, + enumerable: true, + get: fetchGetter, + } satisfies PropertyDescriptor; + Object.defineProperty(navigator, 'sendBeacon', sendBeaconReplacement); + Object.defineProperty(window, 'fetch', fetchReplacement); + + expect(() => guard.reset()).not.toThrow(); + + expect(sendBeaconGetter).not.toHaveBeenCalled(); + expect(fetchGetter).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconReplacement); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchReplacement); + }); + + it('isolates hostile descriptor inspection and still releases the other wrapper', () => { + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const guard = createBeaconGuard(config); + guard.install(); + const installedSendBeacon = navigator.sendBeacon; + const nativeDescriptor = Object.getOwnPropertyDescriptor; + const descriptor = vi + .spyOn(Object, 'getOwnPropertyDescriptor') + .mockImplementation((target, property) => { + if (target === navigator && property === 'sendBeacon') { + throw new Error('publisher descriptor inspection failed'); + } + return nativeDescriptor(target, property); + }); + + expect(() => guard.reset()).not.toThrow(); + descriptor.mockRestore(); + + expect(navigator.sendBeacon).toBe(installedSendBeacon); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + }); + + it('releases an installed wrapper after a later patch assignment fails', () => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + if (!fetchDescriptor || !('value' in fetchDescriptor)) { + throw new Error('test requires an own fetch data descriptor'); + } + const nonWritableFetchDescriptor = { + ...fetchDescriptor, + writable: false, + } satisfies PropertyDescriptor; + Object.defineProperty(window, 'fetch', nonWritableFetchDescriptor); + const guard = createBeaconGuard(config); + + expect(() => guard.install()).toThrow(TypeError); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).not.toEqual( + sendBeaconDescriptor + ); + + expect(() => guard.reset()).not.toThrow(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconDescriptor); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(nonWritableFetchDescriptor); + }); + + it('restores stacked guards in reverse order and remains idempotent', () => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const first = createBeaconGuard(config); + const second = createBeaconGuard({ + ...config, + name: 'Second', + }); + first.install(); + const firstSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const firstFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + second.install(); + + second.reset(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual( + firstSendBeaconDescriptor + ); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(firstFetchDescriptor); + + first.reset(); + first.reset(); + second.reset(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconDescriptor); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + }); + describe('multiple guards', () => { it('should allow independent guards to coexist', () => { const config2: BeaconGuardConfig = { name: 'Other', - isTargetUrl: (url: string) => url.includes('other-tracker.com'), - rewriteUrl: (url: string) => url.replace(/https?:\/\/other-tracker\.com/, '/other-proxy'), + isTargetUrl: (url: string) => hasHttpHostname(url, 'other-tracker.com'), + rewriteUrl: (url: string) => rewriteToProxy(url, '/other-proxy'), }; const guard1 = createBeaconGuard(config); @@ -160,8 +361,12 @@ describe('Beacon Guard', () => { guard1.install(); guard2.install(); + const lookalikeUrl = 'https://other-tracker.com.evil.test/collect'; + navigator.sendBeacon(lookalikeUrl, 'data'); + expect(guard1.isInstalled()).toBe(true); expect(guard2.isInstalled()).toBe(true); + expect(sendBeaconSpy).toHaveBeenCalledWith(lookalikeUrl, 'data'); }); }); }); diff --git a/crates/trusted-server-js/lib/test/shared/origin.test.ts b/crates/trusted-server-js/lib/test/shared/origin.test.ts index 9b0963398..1546c97cc 100644 --- a/crates/trusted-server-js/lib/test/shared/origin.test.ts +++ b/crates/trusted-server-js/lib/test/shared/origin.test.ts @@ -1,36 +1,74 @@ import { describe, expect, it } from 'vitest'; -import { normalizeTrustedOrigin } from '../../src/shared/origin'; +import { + normalizeTrustedOrigin, + trustedDocumentHttpOrigin, + trustedHttpOrigin, +} from '../../src/shared/origin'; -describe('shared/origin.ts', () => { - it('accepts ordinary http(s) origins', () => { +describe('normalizeTrustedOrigin', () => { + it('accepts ordinary and IPv6 HTTP(S) origins', () => { expect(normalizeTrustedOrigin('https://news.publisher.example')).toBe( 'https://news.publisher.example' ); expect(normalizeTrustedOrigin('http://localhost:7676')).toBe('http://localhost:7676'); - }); - - it('accepts IPv6 literal origins', () => { - // A DNS-shaped pattern rejects bracketed hosts, which would drop the stamp - // and push the opaque-origin runtime onto the -sensitive baseURI. expect(normalizeTrustedOrigin('http://[::1]:7676')).toBe('http://[::1]:7676'); expect(normalizeTrustedOrigin('https://[2001:db8::1]')).toBe('https://[2001:db8::1]'); }); - it('normalizes a full URL down to its origin', () => { + it('normalizes a full URL down to its credential-free origin', () => { expect(normalizeTrustedOrigin('https://publisher.example/some/page?q=1#frag')).toBe( 'https://publisher.example' ); + expect(normalizeTrustedOrigin('https://user:password@publisher.example/path')).toBe(''); + }); + + it.each([ + 'null', + 'about:srcdoc', + 'javascript:alert(1)', + 'data:text/html,x', + '/first-party/proxy', + '', + undefined, + 42, + ])('rejects an unusable candidate: %s', (candidate) => { + expect(normalizeTrustedOrigin(candidate)).toBe(''); + }); +}); + +describe('trustedHttpOrigin', () => { + it('derives the exact publisher origin from a stamped or inherited base URL', () => { + expect(trustedHttpOrigin('https://publisher.example')).toBe('https://publisher.example'); + expect(trustedHttpOrigin('http://publisher.example:8080/path/index.html')).toBe( + 'http://publisher.example:8080' + ); }); - it('rejects opaque, non-http(s), and unparseable values', () => { - expect(normalizeTrustedOrigin('null')).toBe(''); - expect(normalizeTrustedOrigin('about:srcdoc')).toBe(''); - expect(normalizeTrustedOrigin('javascript:alert(1)')).toBe(''); - expect(normalizeTrustedOrigin('data:text/html,x')).toBe(''); - expect(normalizeTrustedOrigin('/first-party/proxy')).toBe(''); - expect(normalizeTrustedOrigin('')).toBe(''); - expect(normalizeTrustedOrigin(undefined)).toBe(''); - expect(normalizeTrustedOrigin(42)).toBe(''); + it.each([ + '', + 'about:srcdoc', + 'data:text/html,creative', + 'javascript:alert(1)', + 'https://user:password@publisher.example/path', + ])('fails closed for an unusable trusted base URL: %s', (candidate) => { + expect(trustedHttpOrigin(candidate)).toBe(''); + }); +}); + +describe('trustedDocumentHttpOrigin', () => { + it('keeps a real document origin authoritative over a creative-only stamp', () => { + expect( + trustedDocumentHttpOrigin( + 'https://publisher.example', + 'https://publisher-script-spoof.example' + ) + ).toBe('https://publisher.example'); + }); + + it('uses the stamped base only for an opaque document origin', () => { + expect(trustedDocumentHttpOrigin('null', 'https://publisher.example/article')).toBe( + 'https://publisher.example' + ); }); }); diff --git a/crates/trusted-server-js/lib/test/shared/scheduler.test.ts b/crates/trusted-server-js/lib/test/shared/scheduler.test.ts index aa4a21ecc..59f8a6bd9 100644 --- a/crates/trusted-server-js/lib/test/shared/scheduler.test.ts +++ b/crates/trusted-server-js/lib/test/shared/scheduler.test.ts @@ -42,4 +42,18 @@ describe('shared/scheduler', () => { await Promise.resolve(); expect(perform).toHaveBeenCalledTimes(2); }); + + it('cancels queued and future work after disposal', async () => { + const perform = vi.fn(); + const schedule = createMutationScheduler(perform); + const el = document.createElement('div'); + + schedule(el); + schedule.dispose(); + await Promise.resolve(); + schedule(el); + await Promise.resolve(); + + expect(perform).not.toHaveBeenCalled(); + }); }); diff --git a/crates/trusted-server-js/lib/test/shared/script_guard.test.ts b/crates/trusted-server-js/lib/test/shared/script_guard.test.ts new file mode 100644 index 000000000..15a2771c0 --- /dev/null +++ b/crates/trusted-server-js/lib/test/shared/script_guard.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createScriptGuard } from '../../src/shared/script_guard'; + +describe('shared layered script guard', () => { + const guards: Array<{ reset(): void }> = []; + + afterEach(() => { + for (let index = guards.length - 1; index >= 0; index -= 1) guards[index]?.reset(); + guards.length = 0; + }); + + it('owns document-write rewriting and restores the exact native method', () => { + const nativeWrite = vi.fn<(...args: string[]) => void>(); + document.write = nativeWrite as unknown as typeof document.write; + const guard = createScriptGuard({ + deepInterception: { documentWriteUrlHint: 'sdk.example' }, + id: 'shared-layered-test', + isTargetUrl: (url) => new URL(url, window.location.href).hostname === 'sdk.example', + rewriteUrl: (url) => { + const parsed = new URL(url, window.location.href); + return `${window.location.origin}/proxy${parsed.pathname}`; + }, + }); + guards.push(guard); + + guard.install(); + const installedWrite = document.write; + document.write(''); + + expect(nativeWrite).toHaveBeenCalledTimes(1); + expect(nativeWrite.mock.calls[0]?.[0]).toContain('/proxy/runtime.js'); + + guard.reset(); + expect(document.write).toBe(nativeWrite); + expect(installedWrite).not.toBe(nativeWrite); + }); + + it('removes fallback instance src descriptors during reset', () => { + const nativeGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const descriptorSpy = vi + .spyOn(Object, 'getOwnPropertyDescriptor') + .mockImplementation( + (target: object, property: PropertyKey): PropertyDescriptor | undefined => { + if (target === HTMLScriptElement.prototype && property === 'src') return undefined; + return nativeGetOwnPropertyDescriptor(target, property); + } + ); + const guard = createScriptGuard({ + deepInterception: { documentWriteUrlHint: 'sdk.example' }, + id: 'shared-layered-instance-test', + isTargetUrl: (url) => new URL(url, window.location.href).hostname === 'sdk.example', + rewriteUrl: (url) => { + const parsed = new URL(url, window.location.href); + return `${window.location.origin}/proxy${parsed.pathname}`; + }, + }); + guards.push(guard); + + try { + guard.install(); + const script = document.createElement('script'); + script.src = 'https://sdk.example/first.js'; + expect(script.src).toContain('/proxy/first.js'); + + guard.reset(); + script.src = 'https://sdk.example/after-reset.js'; + expect(script.src).toBe('https://sdk.example/after-reset.js'); + } finally { + descriptorSpy.mockRestore(); + } + }); +}); diff --git a/crates/trusted-server-js/lib/tsconfig.json b/crates/trusted-server-js/lib/tsconfig.json index b17377a14..4c2fed413 100644 --- a/crates/trusted-server-js/lib/tsconfig.json +++ b/crates/trusted-server-js/lib/tsconfig.json @@ -1,16 +1,21 @@ { "compilerOptions": { "target": "ES2018", - "lib": ["ES2020", "DOM"], + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "noImplicitOverride": true, + "useUnknownInCatchVariables": true, "skipLibCheck": true, "noEmit": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "types": ["vitest/globals", "node"] + "types": ["vitest/globals", "node", "vite/client"] }, "include": ["src", "test"] } diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index acb591cdc..b445204b0 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -1,19 +1,36 @@ import path from 'node:path'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; + +import { RELEASE_CATALOG } from './src/kernel/release_catalog.ts'; + +const integrationIds = RELEASE_CATALOG.map(({ id }) => id); export default defineConfig({ + define: { + __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify('a'.repeat(64)), + __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(integrationIds), + __TSJS_EMBEDDED_RUNTIME_CATALOG_V1__: JSON.stringify( + RELEASE_CATALOG.map(({ id, phase, trigger, consumes, provides }) => ({ + id, + phase, + trigger, + consumes, + provides, + })) + ), + }, resolve: { alias: { // prebid.js doesn't expose src/adapterManager.js via its package // "exports" map, but we need it for client-side bidder validation. // Map the specifier to the actual dist file. 'prebid.js/src/adapterManager.js': path.resolve( - __dirname, + import.meta.dirname, 'node_modules/prebid.js/dist/src/src/adapterManager.js' ), 'prebid.js/src/adRendering.js': path.resolve( - __dirname, + import.meta.dirname, 'node_modules/prebid.js/dist/src/src/adRendering.js' ), }, @@ -21,6 +38,15 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, + // These suites deliberately use node:test. CI invokes them through their + // package scripts; importing them through Vitest either rewrites the VM + // contract fixture or leaves Vitest with no registered suite. + exclude: [ + ...configDefaults.exclude, + 'test/contract/aps-renderer-es5.test.mjs', + 'test/eslint/no-adtech-globals.test.mjs', + 'test/build/*.test.mjs', + ], // Run tests in the main thread to avoid spawning // child processes/workers, which are blocked in this sandbox. threads: false, diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index 7ddc060ab..37ce07827 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; use hex::encode; @@ -6,6 +6,73 @@ use sha2::{Digest as _, Sha256}; include!(concat!(env!("OUT_DIR"), "/tsjs_modules.rs")); +/// Release artifact role recorded in the generated inventory. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TsjsArtifactRole { + /// Inline minimal bootstrap controller and fallback artifact. + Bootstrap, + /// Base of the parser-blocking provisional first-display artifact. + FirstDisplayBase, + /// One closed, server-selected provisional first-display slice. + FirstDisplaySlice, + /// Sole TSJS kernel artifact. + Core, + /// Catalogued critical or deferred integration module. + Integration, +} + +/// Fixed catalog phase for one integration module. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TsjsModulePhase { + /// Parser-blocking provisional phase, disposed or adopted after protected paint. + FirstDisplay, + /// Parser-blocking server-composed first-display module. + Critical, + /// Authenticated module loaded only after the protected phase gate. + Deferred, +} + +/// Immutable generated artifact metadata shared with the server. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TsjsArtifactMetadata { + /// Canonical artifact identifier. + pub id: &'static str, + /// Artifact release role. + pub role: TsjsArtifactRole, + /// Catalog phase for integration artifacts. + pub phase: Option, + /// Fixed deferred trigger, when applicable. + pub trigger: Option<&'static str>, + /// Declared consumed capability edges. + pub inputs: &'static [&'static str], + /// Server-owned inclusion predicate from the canonical catalog. + pub include: Option<&'static str>, + /// Declared provided capability keys. + pub outputs: &'static [&'static str], + /// Generated artifact filename. + pub file: &'static str, + /// SHA-256 over exact uncompressed response bytes. + pub hash: &'static str, +} + +/// Maximum catalogued critical modules. +pub const MAX_CRITICAL_MODULES: usize = GENERATED_MAX_CRITICAL_MODULES; +/// Maximum integrations in one boot manifest. +pub const MAX_MANIFEST_MODULES: usize = GENERATED_MAX_MANIFEST_MODULES; +/// Return the sentinel-normalized release identifier shared by every bundle. +#[must_use] +#[inline] +pub const fn release_id() -> &'static str { + TSJS_RELEASE_ID +} + +/// Return the generated, executable GPT bootstrap fallback proposal. +#[must_use] +#[inline] +pub const fn gpt_bootstrap_fallback_bundle() -> &'static str { + GPT_BOOTSTRAP_FALLBACK +} + /// Return the JS bundle content for a given module ID (e.g., "core", "prebid"). #[must_use] #[inline] @@ -17,7 +84,119 @@ pub fn module_bundle(id: &str) -> Option<&'static str> { #[must_use] #[inline] pub fn all_module_ids() -> Vec<&'static str> { - TSJS_MODULES.iter().map(|module| module.id).collect() + TSJS_ARTIFACTS + .iter() + .filter(|artifact| artifact.role == "core" || artifact.role == "integration") + .map(|artifact| artifact.id) + .collect() +} + +/// Return all closed first-display component IDs in canonical mask order. +#[must_use] +pub fn all_first_display_ids() -> Vec<&'static str> { + TSJS_ARTIFACTS + .iter() + .filter(|artifact| { + artifact.role == "first_display_base" || artifact.role == "first_display_slice" + }) + .map(|artifact| artifact.id) + .collect() +} + +/// Return all catalogued integration IDs in canonical phase/injection order. +#[must_use] +pub fn all_integration_ids() -> Vec<&'static str> { + TSJS_ARTIFACTS + .iter() + .filter(|artifact| artifact.role == "integration") + .map(|artifact| artifact.id) + .collect() +} + +/// Return generated metadata for bootstrap, core, and every catalog module. +#[must_use] +pub fn all_artifact_metadata() -> Vec { + TSJS_ARTIFACTS.iter().map(public_metadata).collect() +} + +/// Return generated metadata for the twenty integration modules. +#[must_use] +pub fn all_integration_metadata() -> Vec { + TSJS_ARTIFACTS + .iter() + .filter(|artifact| artifact.role == "integration") + .map(public_metadata) + .collect() +} + +/// Return generated metadata for the base and twelve first-display components. +#[must_use] +pub fn all_first_display_metadata() -> Vec { + TSJS_ARTIFACTS + .iter() + .filter(|artifact| { + artifact.role == "first_display_base" || artifact.role == "first_display_slice" + }) + .map(public_metadata) + .collect() +} + +/// Return the exact generated bytes for one closed first-display component. +#[must_use] +pub fn first_display_component_bundle(id: &str) -> Option<&'static str> { + TSJS_ARTIFACTS + .iter() + .find(|artifact| { + (artifact.role == "first_display_base" || artifact.role == "first_display_slice") + && artifact.id == id + }) + .map(|artifact| artifact.bundle) +} + +/// Compose the base plus selected optional first-display slices in canonical order. +/// +/// Unknown, duplicate, or explicitly supplied base IDs fail closed. +#[must_use] +pub fn concatenate_first_display_slices(ids: &[&str]) -> Option { + let selected = ids.iter().copied().collect::>(); + if selected.len() != ids.len() || selected.contains("first_display") { + return None; + } + if selected.iter().any(|id| { + !TSJS_ARTIFACTS + .iter() + .any(|artifact| artifact.role == "first_display_slice" && artifact.id == *id) + }) { + return None; + } + let parts = TSJS_ARTIFACTS + .iter() + .filter(|artifact| { + artifact.role == "first_display_base" + || (artifact.role == "first_display_slice" && selected.contains(artifact.id)) + }) + .map(|artifact| artifact.bundle) + .collect::>(); + Some(parts.join(";\n")) +} + +/// SHA-256 of one validated, canonically ordered first-display composition. +#[must_use] +pub fn concatenated_first_display_hash(ids: &[&str]) -> Option { + concatenate_first_display_slices(ids).map(|body| { + let mut hasher = Sha256::new(); + hasher.update(body.as_bytes()); + encode(hasher.finalize()) + }) +} + +/// Return generated metadata for a catalogued integration module. +#[must_use] +pub fn integration_metadata(id: &str) -> Option { + TSJS_ARTIFACTS + .iter() + .find(|artifact| artifact.role == "integration" && artifact.id == id) + .map(public_metadata) } /// Concatenate core + the requested integration modules into a single JS string. @@ -64,19 +243,130 @@ pub fn concatenated_hash(ids: &[&str]) -> String { #[must_use] #[inline] pub fn single_module_hash(id: &str) -> Option { - module_bundle(id).map(|content| { - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - encode(hasher.finalize()) - }) + TSJS_ARTIFACTS + .iter() + .find(|artifact| artifact.role == "integration" && artifact.id == id) + .map(|artifact| artifact.hash.to_owned()) } fn module_map() -> &'static HashMap<&'static str, &'static str> { static MAP: OnceLock> = OnceLock::new(); MAP.get_or_init(|| { - TSJS_MODULES + TSJS_ARTIFACTS .iter() - .map(|module| (module.id, module.bundle)) + .filter(|artifact| artifact.role == "core" || artifact.role == "integration") + .map(|artifact| (artifact.id, artifact.bundle)) .collect() }) } + +fn public_metadata(artifact: &TsjsGeneratedArtifactMeta) -> TsjsArtifactMetadata { + TsjsArtifactMetadata { + id: artifact.id, + role: match artifact.role { + "bootstrap" => TsjsArtifactRole::Bootstrap, + "first_display_base" => TsjsArtifactRole::FirstDisplayBase, + "first_display_slice" => TsjsArtifactRole::FirstDisplaySlice, + "core" => TsjsArtifactRole::Core, + "integration" => TsjsArtifactRole::Integration, + _ => unreachable!("generated artifact role should be validated"), + }, + phase: artifact.phase.map(|phase| match phase { + "first_display" => TsjsModulePhase::FirstDisplay, + "critical" => TsjsModulePhase::Critical, + "deferred" => TsjsModulePhase::Deferred, + _ => unreachable!("generated artifact phase should be validated"), + }), + trigger: artifact.trigger, + include: artifact.include, + inputs: artifact.inputs, + outputs: artifact.outputs, + file: artifact.file, + hash: artifact.hash, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_catalog_metadata_has_exact_phase_order_and_derived_capacities() { + let metadata = all_integration_metadata(); + let generated = include_str!(concat!(env!("OUT_DIR"), "/tsjs_modules.rs")); + + assert_eq!(metadata.len(), 20, "should embed all catalog modules"); + assert_eq!(MAX_CRITICAL_MODULES, 14); + assert_eq!(MAX_MANIFEST_MODULES, 20); + assert!( + !generated.contains("INTERNAL_DIAGNOSTICS_SUBSCRIPTIONS"), + "the synchronous diagnostics ingress must not generate subscription capacity" + ); + assert_eq!(metadata[0].id, "render_runtime"); + assert_eq!(metadata[0].phase, Some(TsjsModulePhase::Critical)); + assert_eq!(metadata[13].id, "testlight"); + assert_eq!(metadata[13].phase, Some(TsjsModulePhase::Critical)); + assert_eq!(metadata[14].id, "diagnostics_presentation"); + assert_eq!(metadata[14].phase, Some(TsjsModulePhase::Deferred)); + assert_eq!(metadata[19].id, "sourcepoint_lifecycle"); + assert_eq!(metadata[19].trigger, Some("first_display_or_idle")); + assert_eq!( + metadata[0].outputs, + &[ + "slots.v1", + "auction.v1", + "render.v1", + "messages.v1", + "trace.v1", + "trace.presentation.v1", + "direct.v1" + ] + ); + } + + #[test] + fn generated_artifact_inventory_includes_bootstrap_core_and_catalog_once() { + let artifacts = all_artifact_metadata(); + + assert_eq!(artifacts.len(), 35); + assert_eq!(artifacts[0].id, "bootstrap"); + assert_eq!(artifacts[0].role, TsjsArtifactRole::Bootstrap); + assert_eq!(artifacts[1].id, "first_display"); + assert_eq!(artifacts[1].role, TsjsArtifactRole::FirstDisplayBase); + assert!( + artifacts[2..14] + .iter() + .all(|artifact| artifact.role == TsjsArtifactRole::FirstDisplaySlice) + ); + assert_eq!(artifacts[14].id, "core"); + assert_eq!(artifacts[14].role, TsjsArtifactRole::Core); + assert!( + artifacts[15..] + .iter() + .all(|artifact| artifact.role == TsjsArtifactRole::Integration) + ); + assert_eq!(all_module_ids().len(), 21); + assert_eq!(all_first_display_ids().len(), 13); + } + + #[test] + fn first_display_composition_is_closed_and_canonical() { + let body = concatenate_first_display_slices(&["gpt_initial", "aps_initial"]) + .expect("should compose known unique slices"); + let base = first_display_component_bundle("first_display") + .expect("should embed first-display base"); + let aps = first_display_component_bundle("aps_initial") + .expect("should embed APS first-display slice"); + let gpt = first_display_component_bundle("gpt_initial") + .expect("should embed GPT first-display slice"); + + assert!(body.starts_with(base)); + assert!( + body.find(aps).expect("should contain APS") + < body.find(gpt).expect("should contain GPT") + ); + assert!(concatenate_first_display_slices(&["aps_initial", "aps_initial"]).is_none()); + assert!(concatenate_first_display_slices(&["first_display"]).is_none()); + assert!(concatenate_first_display_slices(&["unknown"]).is_none()); + } +} diff --git a/crates/trusted-server-js/src/lib.rs b/crates/trusted-server-js/src/lib.rs index 2c816b154..76fb1486e 100644 --- a/crates/trusted-server-js/src/lib.rs +++ b/crates/trusted-server-js/src/lib.rs @@ -6,5 +6,10 @@ pub mod bundle; pub use bundle::{ - all_module_ids, concatenate_modules, concatenated_hash, module_bundle, single_module_hash, + MAX_CRITICAL_MODULES, MAX_MANIFEST_MODULES, TsjsArtifactMetadata, TsjsArtifactRole, + TsjsModulePhase, all_artifact_metadata, all_first_display_ids, all_first_display_metadata, + all_integration_ids, all_integration_metadata, all_module_ids, + concatenate_first_display_slices, concatenate_modules, concatenated_first_display_hash, + concatenated_hash, first_display_component_bundle, gpt_bootstrap_fallback_bundle, + integration_metadata, module_bundle, release_id, single_module_hash, }; diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index b4ba6b797..97173ff01 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -143,7 +143,7 @@ sequenceDiagram Note over Client,Mock: Response Assembly activate TS activate Client - Orch->>Orch: Transform to OpenRTB response
Preserve typed render source
Optionally sanitize creative HTML
Optionally rewrite creative URLs
Add orchestrator metadata + Orch->>Orch: Transform to OpenRTB response
Preserve typed render source
Optionally sanitize and rewrite ordinary creatives
Add orchestrator metadata Orch-->>TS: OpenRTB BidResponse Note right of Orch: APS winner carries ext.trusted_server.renderer
with no adm; ordinary winners retain sanitized adm/cache data @@ -156,13 +156,8 @@ sequenceDiagram %% === Creative Rendering === rect rgb(239,246,255) Note over Client,Mock: Creative Rendering - alt APS winner - Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer - Note right of Client: Fragment-bound nonce and one-time acknowledgement
No allow-same-origin on the outer frame - else Ordinary creative - Client->>Client: Inject winning creative
Render iframe
Load creative resources - Note right of Client: Default: first-party proxy/click URLs
rewrite_creatives=false: accepted external URLs remain direct - end + Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer/v1 + Note right of Client: Fragment-bound nonce and one-time acknowledgement
No allow-same-origin on the outer frame deactivate Client end ``` @@ -331,7 +326,7 @@ Transforms auction requests into OpenRTB 2.x format and sends them to a Prebid S - Bids include decoded `price` (clear decimal CPM) - Creative HTML provided in `adm` field -- Winning creative URLs rewritten to first-party proxy format by default when the `/auction` response is assembled +- Winning creative URLs rewritten to first-party proxy format when `[auction].rewrite_creatives` is enabled - Per-bidder timing (`responsetimemillis`), errors, and warnings always attached as response metadata - When `debug` is enabled, PBS debug payload and per-bid status (`bidstatus`) also included @@ -594,8 +589,7 @@ markup with its inner content. `rewrite_creatives` (default `true`) runs an HTML rewriter (`lol_html`) that converts eligible external resource and click URLs to signed first-party paths, adds `data-tsclick`, rewrites inline CSS `url(...)` values, removes bidder-supplied `` elements, and injects the -unified creative TSJS runtime exactly once, whether or not the bidder supplied a -`` element. In every mode, a creative +unified creative TSJS runtime exactly once, including for body-less fragments. In every mode, a creative larger than the 1 MiB per-creative cap is rejected and its `adm` is dropped. ```toml @@ -719,7 +713,7 @@ environment overrides to apply; see | Field | Type | Default | Description | | ------------------------ | ------ | ----------------------------- | ----------------------------------------------------------------- | | `enabled` | bool | `false` | Enable APS provider | -| `account_id` | string | — | APS account ID (required; `pub_id` is an alias) | +| `account_id` | string | — | APS account ID (required; integers are rejected) | | `endpoint` | string | Built-in APS OpenRTB endpoint | Optional APS OpenRTB endpoint override | | `timeout_ms` | u32 | `800` | Request timeout | | `debug` | bool | `false` | Include the raw APS HTTP exchange in `/auction` provider metadata | @@ -805,11 +799,11 @@ The orchestrator is designed to be resilient: The auction system logs at multiple levels throughout execution: -| Level | Examples | -| ------- | --------------------------------------------------------------------------------------- | -| `info` | Auction request received, provider launch, bid counts, winner selection, total timing | -| `debug` | Bid-drop reasons, mediation restoration notes, creative processing mode and byte counts | -| `warn` | Provider launch failures, parse failures, mediator bids without decoded prices | +| Level | Examples | +| ------- | ------------------------------------------------------------------------------------- | +| `info` | Auction request received, provider launch, bid counts, winner selection, total timing | +| `debug` | Bid-drop reasons, mediation restoration notes, creative rewrite sizes | +| `warn` | Provider launch failures, parse failures, mediator bids without decoded prices | ### Response Metadata diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ddb6544ce..b21699e33 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -662,11 +662,16 @@ fetches never carry Basic credentials, so every visitor gets `401` — on `/_ts/page-bids` that means no ads after any client-side navigation. Match the admin routes specifically (`^/_ts/admin`) instead. -Upgrading from a release before `/_ts/page-bids` existed: if any handler -pattern covers it, narrow the pattern. The Trusted Server JS bundle falls back -to the deprecated `/__ts/page-bids` alias in the meantime, but that alias is -scheduled for removal -([#970](https://github.com/IABTechLab/trusted-server/issues/970)). +If an older deployment used a different SPA auction path, update its handler +rules at the same time as the TSJS cutover. `/_ts/page-bids` is the only SPA +auction endpoint; older path spellings are unknown routes. + +The `/integrations/aps/*` family is different: its renderer and live-runner +routes are reserved before `[[handlers]]` is evaluated. They are browser-facing +resources and are intentionally anonymous, so a handler pattern that matches +`/integrations/aps/` does **not** add Basic Auth. Apply any admission control, +rate limiting, or request shielding for those routes in the deployment platform, +not through `[[handlers]]`. ::: @@ -1264,9 +1269,8 @@ context that shares the publisher's origin. With `rewrite_creatives = true` not excluded by rewrite configuration are converted to signed first-party endpoints, and any bidder-supplied `` element is removed. The `POST /auction` path emits root-relative endpoints and injects the creative TSJS -runtime exactly once — whether or not the bidder supplied a ``, since bare -fragments are the common `adm` shape; the foreign-origin SSAT renderer emits -absolute endpoints and does not inject that bundle. With both disabled, `adm` ships +runtime exactly once, including for body-less fragments; the foreign-origin +SSAT renderer emits absolute endpoints and does not inject that bundle. With both disabled, `adm` ships exactly as the bidder returned it — except that a creative larger than the 1 MiB per-creative cap is rejected in every mode and its `adm` is dropped. Accepted external URLs are not host allowlisted by the sanitizer. Neither diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 24e8dc0c3..778177c7e 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -71,12 +71,12 @@ rewrite_creatives = true Regardless of mode, a creative larger than the 1 MiB per-creative cap is rejected and its `adm` is dropped. -| `sanitize_creatives` | `rewrite_creatives` | Auction winning-bid `adm` behavior | -| -------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `false` (default) | `false` | Deliver the creative exactly as the bidder returned it (subject to the size cap). | -| `true` | `false` | Strip executable markup (`script`/`object`/`embed`/`form`, event handlers) with its inner content, then deliver without rewriting. Sanitizer-accepted external resource, click, and inline CSS URLs remain direct. | -| `false` | `true` (default) | Rewrite eligible resource/CSS and click URLs in the raw bidder markup to signed first-party endpoints, removing any bidder `` element. Executable markup is preserved. | -| `true` | `true` | Sanitize first, then rewrite. `POST /auction` emits root-relative endpoints and injects creative TSJS exactly once, whether or not the bidder supplied a ``; SSAT/page-bids emits absolute endpoints for its foreign-origin renderer and does not inject the bundle. | +| `sanitize_creatives` | `rewrite_creatives` | Auction winning-bid `adm` behavior | +| -------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `false` (default) | `false` | Deliver the creative exactly as the bidder returned it (subject to the size cap). | +| `true` | `false` | Strip executable markup (`script`/`object`/`embed`/`form`, event handlers) with its inner content, then deliver without rewriting. Sanitizer-accepted external resource, click, and inline CSS URLs remain direct. | +| `false` | `true` (default) | Rewrite eligible resource/CSS and click URLs in the raw bidder markup to signed first-party endpoints, removing any bidder `` element. Executable markup is preserved. | +| `true` | `true` | Sanitize first, then rewrite. `POST /auction` emits root-relative endpoints and injects creative TSJS exactly once, including for body-less fragments; SSAT/page-bids emits absolute endpoints for its foreign-origin renderer and does not inject the bundle. | ::: warning Sanitization blanks script-based creatives Sanitization removes `script`/`object`/`embed`/`form` and similar elements @@ -94,22 +94,22 @@ directly. Creatives rendered by Trusted Server's own path run in a sandboxed iframe **without** `allow-same-origin`, i.e. an opaque origin. The injected creative runtime's click guard recovers mutated clicks there via a GET -`/first-party/proxy-rebuild` navigation (302 chain). - -Two capabilities are unavailable in that context. **CORS-mode subresources** — -ES modules, `crossorigin` fonts, `fetch`/XHR — cannot load through -`/first-party/proxy`, because that endpoint deliberately sends no -`Access-Control-Allow-Origin`: it is a generic signed fetcher that forwards the -EC ID and client-derived headers, so letting an opaque creative frame read its -responses would turn it into a readable bidder-controlled proxy. Ordinary -subresources (``, `"#, - ctx.request_host - )] + let config_json = serde_json::to_string(&serde_json::json!({ + "host": ctx.request_host, + })) + .expect("should serialize integration config") + .replace("` after the TSJS bundle tag +- Snippets are prepended into `` before the TSJS bundle tags - Called once per HTML response - Multiple integrations can each contribute snippets - If no snippets are returned, no extra markup is added +The core snapshots and freezes each manifest integration's configuration, deletes the transient transport, and exposes only the final immutable `window.tsjs.boot` and `TsjsApi` surfaces. + See [Integration Guide](/guide/integration-guide) for creating custom rewriters. ## TSJS Injection diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index b5348ed9f..3bea7cd79 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -621,7 +621,7 @@ Warning: viceroy version mismatch **Solution:** ```bash -cargo install viceroy --version 0.17.0 --locked --force +cargo install viceroy --version 0.19.0 --locked --force ``` --- diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9314f983b..5e834386e 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -50,7 +50,7 @@ Simulates the full Fastly production environment locally. Install and configure the Fastly CLI using the [Fastly setup guide](/guide/fastly), then install Viceroy: ```bash -cargo install viceroy --version 0.17.0 --locked --force +cargo install viceroy --version 0.19.0 --locked --force ``` Start the local Fastly simulator: diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 4346fd7e0..8aa56c062 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -224,15 +224,18 @@ impl IntegrationHeadInjector for MyIntegration { fn integration_id(&self) -> &'static str { "my_integration" } fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec { - vec![format!( - r#""#, - ctx.request_host - )] + let config_json = serde_json::to_string(&serde_json::json!({ + "mode": "my_integration", + "host": ctx.request_host, + })) + .expect("should serialize integration config") + .replace("` element is first encountered. The returned snippets are concatenated before the unified script tag, so ordering between integrations is not guaranteed — keep snippets self-contained. +`html_processor.rs` calls `head_inserts` once per HTML response when the `` element is first encountered. The returned snippets are concatenated before the unified script tag, so ordering between integrations is not guaranteed — keep snippets self-contained. The core validates, snapshots, freezes, and consumes the matching manifest integration's value, then deletes the transient `_integrationConfig` transport before publishing the exact `TsjsApi`. ::: tip When to Use Head Injection Use `IntegrationHeadInjector` when you need to emit configuration, inline scripts, or `` tags that must appear early in ``. For attribute or script content changes on existing elements, prefer `IntegrationAttributeRewriter` or `IntegrationScriptRewriter` instead. @@ -331,7 +334,7 @@ Tests or scaffolding can inject configs by calling `settings.integrations.insert **3. HTML Rewrites Through the Registry** -When the integration is enabled, the `IntegrationAttributeRewriter` removes any ` ``` For `[]`, omit the property with `skip_serializing_if`, matching the existing -`clientSideBidders` convention. A browser that receives no property treats it as an -empty list. This makes upgrade and rollback backwards compatible: old configuration -has no behavior change; an older shim safely ignores the extra injected property; -and a newer shim with old configuration has no exclusions. +`clientSideBidders` convention. The registrar treats an absent property as an empty +list, so every otherwise eligible refresh slot remains auctionable. ## 5. Matching and refresh behavior ### 5.1 Match predicate -Extend `RefreshGptSlot` with: - -```ts -getAdUnitPath?: () => string; -``` - -At each publisher refresh, read the injected readonly array from -`getInjectedConfig()?.excludedGamAdUnitPathSuffixes ?? []`. A slot is excluded only +At each publisher refresh, snapshot the validated readonly array from the +`prebid.v1` capability. A slot is excluded only when all of the following hold: 1. The array is non-empty. -2. `slot.getAdUnitPath` is a function. -3. Calling it returns a string. +2. The deferred GPT adapter can read the slot's GAM ad-unit path. +3. Reading it returns a string. 4. The returned GAM path `endsWith()` at least one configured suffix, using exact, case-sensitive JavaScript string comparison. -Do not derive paths from the element ID or injected `adSlots` metadata. Do not use -`getSizes()` as a fallback. A missing getter, a non-string return value, an empty -path, or a getter that throws is **fail-open**: the slot remains auction-eligible. -The implementation catches only the getter failure around that call; it neither -suppresses the GPT refresh nor broadens an exclusion because telemetry is absent. +Do not derive paths from the element ID or projected slot metadata. Do not use sizes +as a fallback. A missing getter, a non-string return value, an empty path, or a +getter that throws is **fail-open**: the slot remains auction-eligible. A matching path is excluded only from the synthetic refresh auction. It is not removed from GPT's target list. ### 5.2 Required algorithm -Keep the existing `adInitRefreshInProgress` check as the first branch, before slot -resolution, targeting cleanup, and path inspection: +The slot service's publisher-refresh decision must run before the optional policy: ```text -if adInitRefreshInProgress: - originalRefresh(slots, options) - return - -targetSlots = explicit slots, or pubads.getSlots() for bare refresh -if targetSlots is empty: - originalRefresh(slots, options) +slotDecision = slotService.preparePublisherRefresh(call) +if slotDecision is suppress: + suppress the publisher call return -deliverySlots = publisher delivery slots in targetSlots -independentSlots = targetSlots excluding deliverySlots -if independentSlots is empty: - originalRefresh(slots, options) +targetSlots = slotDecision replacement slots, otherwise observed call slots +if targetSlots is invalid or empty: + preserve slotDecision return -clear TS/Prebid refresh-targeting keys from every independent slot -auctionSlots = independentSlots excluding suffix-matched slots +defer the publisher call +clear TS/Prebid refresh-targeting keys from every target slot +auctionSlots = targetSlots excluding suffix-matched slots if auctionSlots is empty: - originalRefresh(slots, options) + settle the deferred call immediately return -adUnits = synthetic refresh ad units for auctionSlots only -pbjs.requestBids({ adUnits, timeout, bidsBackHandler }) -bidsBackHandler: - pbjs.setTargetingForGPTAsync(auction-slot codes only) - originalRefresh(slots, options) +run one adapter-backed synthetic auction for auctionSlots +settle after completion, the 1.5-second watchdog, disposal, or navigation replacement ``` Build candidate codes, recover publisher bidder params, and recover client-side bids only for `auctionSlots`; excluded slots must not be represented in `adUnits` at all. -The existing scoped targeting behavior therefore continues to affect only eligible -slots. +The adapter-backed auction scopes targeting to eligible synthetic codes only. ### 5.3 Refresh sequences -| Call and slot set | Prebid behavior | GPT behavior | -| -------------------------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| `refresh([normal], options)` | Auction `normal`, target its synthetic code after bids return. | Refresh `[normal]` with the same options after the callback. | -| `refresh([excluded], options)` | Clear TS/Prebid keys; do not call `requestBids()` or `setTargetingForGPTAsync()`. | Immediately refresh `[excluded]` with the same options. | -| Bare `refresh(options)`; all slots excluded | Resolve slots; clear independent keys; skip Prebid. | Immediately pass through the original bare refresh and options. | -| Bare `refresh(options)`; mixed normal and excluded slots | Clear independent slots; auction eligible slots. | After the callback, pass through the original bare refresh and options. | -| Any refresh while `adInitRefreshInProgress` is true | No cleanup, match, auction, or targeting. | Directly pass through the original `slots` and options unchanged. | -| Missing/throwing `getAdUnitPath()` | Treat the slot as normal and auction it. | Existing post-auction refresh behavior. | +| Call and slot set | Prebid behavior | GPT behavior | +| -------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `refresh([normal], options)` | Auction `normal`, target its synthetic code after bids return. | Refresh `[normal]` with the same options after the callback. | +| `refresh([excluded], options)` | Clear TS/Prebid keys; do not call `requestBids()` or `setTargetingForGPTAsync()`. | Immediately refresh `[excluded]` with the same options. | +| Bare `refresh(options)`; all slots excluded | Clear observed targets; skip Prebid. | Resume when policy cleanup settles. | +| Bare `refresh(options)`; mixed normal and excluded slots | Clear targets; auction eligible slots only. | Resume after auction completion or watchdog. | +| Slot-service protected initial display | No cleanup, match, auction, or targeting. | Preserve the slot-service decision. | +| Missing/throwing `getAdUnitPath()` | Treat the slot as normal and auction it. | Existing post-auction refresh behavior. | -The resolved list is used only for cleanup and synthetic-auction selection. The -wrapper preserves the publisher's original refresh form, so GPT itself resolves the -registered slots for bare calls. The original options object is passed through -unchanged. +The sole GPT adapter retains the publisher call and options while the policy owns +only its asynchronous completion latch. ### 5.4 Targeting and initial-load invariants @@ -242,22 +212,22 @@ The cleanup step remains before filtering and is limited to the existing from excluded slots so GAM cannot serve using an obsolete header-bid winner, while preserving GAM path metadata and every unrelated publisher targeting key. -`adInitRefreshInProgress` continues to bypass cleanup and auctioning directly. This -preserves `disableInitialLoad()` and the initial Trusted Server targeting handoff: -that one internal refresh must deliver already-applied targeting to GAM instead of -being converted into a client-side refresh auction. +The slot service continues to protect initial Trusted Server targeting handoff before +the Prebid policy runs, so it cannot be converted into a client-side refresh auction. ## 6. Implementation areas -| File | Planned change | -| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `crates/trusted-server-core/src/integrations/prebid.rs` | Add config field, validation/canonicalization, head-injected camel-case array, and Rust tests. | -| `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` | Add injected config/type support, guarded path matcher, and filter `targetSlots` into `auctionSlots` after cleanup. | -| `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` | Add explicit/global/mixed/fail-open refresh tests. | -| `trusted-server.example.toml` | Add a commented fictional configuration example. | -| `docs/guide/integrations/prebid.md` | Document the field, exact matcher semantics, and GAM-preservation caveat. | -| `docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md` | Update only if implementation exposes a necessary design correction. | -| `docs/superpowers/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md` | Mark implementation evidence/status only if project practice requires it. | +| File | Planned change | +| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Add config field, validation/canonicalization, head-injected camel-case array, and Rust tests. | +| `crates/trusted-server-js/lib/src/integrations/prebid/module.ts` | Validate boot config and publish the frozen `prebid.v1` capability. | +| `crates/trusted-server-js/lib/src/integrations/prebid/refresh.ts` | Implement guarded path matching and the cancellable synthetic-refresh policy. | +| `crates/trusted-server-js/lib/src/integrations/prebid/later.ts` | Register the policy through the single GPT observer. | +| `crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts` | Cover literal filtering, fail-open behavior, cancellation, and adapter-backed auctions. | +| `trusted-server.example.toml` | Add a commented fictional configuration example. | +| `docs/guide/integrations/prebid.md` | Document the field, exact matcher semantics, and GAM-preservation caveat. | +| `docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md` | Update only if implementation exposes a necessary design correction. | +| `docs/superpowers/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md` | Mark implementation evidence/status only if project practice requires it. | No generated `dist` file, minified external bundle, or publisher source file is a source-of-truth edit target. @@ -292,24 +262,23 @@ source-of-truth edit target. the normal auction path without throwing from the wrapper. - Case mismatch and a trailing-slash mismatch do not exclude, proving literal case-sensitive suffix behavior. -- Existing `adInitRefreshInProgress` test still proves direct pass-through without - cleanup or auction; existing normal refresh and client-side-bid recovery tests - remain green. +- Existing GPT protected-refresh tests still prove bypass without cleanup or auction; + normal refresh and client-side-bid recovery tests remain green. ## 8. External bundle and browser verification `crates/trusted-server-js/lib/build-prebid-external.mjs` remains the supported source build path for the immutable external Prebid bundle; `build-all.mjs` intentionally does not build Prebid. This feature does not change that external bundle: its refresh -filter lives in the server-served `tsjs-prebid` shim. Implementers must change the -shim source rather than editing generated/minified assets. +filter lives in the release-bound `prebid_later` registrar module. Implementers +change TypeScript source rather than generated/minified assets. Roll out the Trusted Server application and its configuration together: 1. Build and test the source change. 2. Deploy the application/config containing the suffix list. -3. Verify the injected `window.__tsjs_prebid.excludedGamAdUnitPathSuffixes` has the - expected values. +3. Verify the consumed Prebid integration config has the expected suffixes; the + transient `_integrationConfig` transport must be deleted before runtime commit. 4. In browser instrumentation, verify a matching slot calls GPT refresh without a corresponding Trusted Server refresh `/auction` request, while a normal display slot in the same global refresh still produces `/auction` and receives refreshed @@ -317,9 +286,8 @@ Roll out the Trusted Server application and its configuration together: 5. Verify GAM records the excluded slot's request/impression with a controlled staging page or harness. -No new external Prebid bundle is required for this option. A bundle generated before -the shim split is the separate migration exception described in the Prebid guide; -changes to external adapters or User ID modules still use the normal bundle workflow. +No new external Prebid bundle is required for this option. Changes to external +adapters or User ID modules still use the normal bundle workflow. ## 9. Operational caveats and risks diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index 68e1cf75e..422eb8ef8 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -81,9 +81,9 @@ competing container slot and an invalid duplicate definition. destroy a slot after ownership has transferred. The wrapper is not a global deduplicator. It only handles IDs present in TS's -handoff registry or one uniquely safe hydrated-ID match and must preserve native -`defineSlot`, all supported `display()` argument forms, and `refresh()` options for -every other placement. +handoff registry, or one uniquely safe hydrated-ID match, and must preserve native +`defineSlot`, all supported `display()` argument forms, and both `refresh()` +arguments for every other placement. ## Implementation shape diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md new file mode 100644 index 000000000..197be2ba3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -0,0 +1,5090 @@ +# APS Render Fix and TSJS Resilience Architecture — Design + +- **Status:** revision 38 — hard-cutover contract with a lean first-display owner, + atomic persistent-runtime takeover, current-`main` authority, retired-branch + concept-gap coverage, and + merge-blocking load-time remediation +- **Date:** 2026-08-04 +- **Implementation baseline:** current `origin/main` at design revision 38, + `5a76e59cd7939efd28b742eda4166bf53f10bb10` ("Preserve APS renderer + handshake behavior"). `main` is the sole source for starting code, behavior, + tests, APIs, CI, dependency state, and performance comparison. Implementation + refreshes and integrates current `main` again before resuming work and before + cutover; this recorded SHA is review provenance, not permission to ignore later + `main` changes. +- **Retired-branch evidence:** the immutable historical snapshot + `905984e62a0858c53d9f0ff6dd3a1bf190cf311d` from retired `rc/july` is only a + finite TSJS concept-gap checklist. It is not a baseline, merge source, API + authority, or reason to preserve retired mechanics. +- **Compatibility:** this is a coordinated hard cutover. No backward-compatible + aliases, dual APIs, or N/N-1 browser/server protocol are required. +- **Decision:** this document covers APS render correctness, the TSJS architecture + needed to make that correctness durable, and preservation or explicit + architectural replacement of current-`main` behavior plus each explicitly + retained concept found by the retired-branch audit. It does not add an external + telemetry system or release experimentation. + +## 0. Scope and constraints + +### 0.1 Goals + +1. An accepted APS bid renders through every supported Trusted Server path: + SSAT/GPT, the Trusted Server Prebid adapter through GAM Universal Creative, + SPA page bids, and direct `/auction` rendering. +2. Render ownership, identity, and completion are explicit. Races, stale SPA + work, ambiguous GPT events, duplicate creative requests, and timeouts settle + deterministically instead of failing silently. +3. TSJS has one persistent runtime kernel, preceded only by the bounded + first-display agent in §5.2. The agent is not a second runtime: it publishes no + runtime API or capability broker, accepts only the immutable initial work selected + by the server, and retires through the one atomic ownership transfer. Integration + bundles cannot create independent copies of shared state. +4. The Rust auction result, publisher projection, TypeScript parser, Prebid + registration, GPT targeting, Universal Creative bridge, and direct renderer + agree on one APS descriptor and identity contract. +5. Security boundaries are testable: untrusted creative messages cannot claim a + different slot or attempt, replay a consumed capability, or revive work from a + prior navigation. +6. Existing non-APS rendering behavior remains correct unless this design + explicitly replaces a shared lifecycle surface. +7. Every affected TSJS behavior on current `main` is preserved, rebuilt behind the + new architecture, or explicitly superseded by a named and tested replacement + contract. The retired-branch audit additionally identifies required TSJS + concepts that are not already present on `main`; the audit never makes retired + mechanics authoritative. No required behavior may disappear silently merely + because its old global, wrapper, bootstrap, or carrier is deleted. +8. A server-projected initial display pays only for one server-composed lean + first-display artifact. The persistent runtime is neither requested nor prepared + until that immutable batch is terminal and has received its paint opportunity. + Optional integration behavior, diagnostics presentation, programmatic/direct + auctions, and later navigation therefore do not delay or compete with the + protected path. A page without eligible server-projected initial work loads the + persistent runtime directly; a later programmatic first display remains correct + but is not claimed to have the lean transfer profile. + +### 0.2 Non-goals + +- No change to analytics/telemetry schemas, durable data systems, billing, + experimentation, or deployment routing. Those belong to separate designs. +- No change to the APS upstream OpenRTB endpoint contract, including APS's + deliberate absence of `nurl` and `burl`. +- No rewrite of Prebid.js itself or of the decoupled Prebid strategy. +- No refactor of unrelated integration internals. They receive only the thin + registration/bootstrap changes required by the new TSJS runtime, plus any + mechanical disposal or adapter injection needed to preserve their current-main + behavior. + +Existing local render tracing, GPT diagnostics, logging, counters, debug output, +and telemetry integrations remain functional through the cutover. They may move +behind the core-owned diagnostics ingress, the GPT-owned fact stream, the trace +owner's private presentation capability, or the final diagnostics namespace, but +their observable concepts and non-interference guarantees are in scope. Correctness +must not depend on a new observation reaching presentation code or an external sink. +Any new analytics contract requires a separate design. + +### 0.3 Architectural rules + +- Make invalid states unrepresentable where practical and reject them at the + boundary otherwise. +- Every asynchronous operation belongs to a runtime, navigation, auction batch, + or render-attempt lifetime and has a deterministic disposer. +- Every render attempt reaches exactly one terminal result in memory. +- A timeout cancels or fails only the object it owns; shared work is aborted only + when no live child still needs it. +- Ad-tech globals are accessed only through adapters. +- Cross-window messages are versioned, exact-shaped, capability-bound, and + source/port checked. +- No correctness path waits for logging, telemetry, notification delivery, or any + other side effect unrelated to rendering. +- Code that is unnecessary for the initial projected render cannot be imported by + the first-display entry. The production core is a post-paint takeover artifact, + not parser-blocking first-display code. A later module may join only the already + committed persistent runtime through its exact release-bound capability contract; + it cannot construct another runtime, adapter owner, slot registry, or message + dispatcher. + +### 0.4 Current-`main` authority and retired-branch TSJS concept audit + +Current `origin/main` is the only normative starting point. Before implementation +resumes, the worktree fetches and integrates current `main`, records its exact SHA, +and runs the existing `main` tests before behavior changes. The same refresh occurs +before cutover. Every source edit, regression fixture, package/toolchain decision, +bundle delta, performance ratio, and release artifact is based on that integrated +`main`. When this design intentionally changes a `main` behavior, the relevant +contract below is the authority; otherwise current `main` wins. + +The `rc/july` branch is retired. It must not be fetched as an implementation input, +merged, rebased, or cherry-picked into this work. The immutable historical snapshot +`905984e62a0858c53d9f0ff6dd3a1bf190cf311d` is retained only because its TSJS tree, +embedded bootstraps, tooling, and browser tests form a finite audit that can expose a +required concept absent from current `main`. The in-spec manifest in §0.5 proves +that this historical checklist is complete. For each ledger row, implementation +first identifies the current-`main` owner and tests and reuses them when they satisfy +this design. Only a concept explicitly retained by the ledger and missing or +incomplete on `main` becomes gap work. Historical source shape, names, incidental +semantics, and unrelated retired-branch features are not requirements. + +The audit is behavioral, not commit-hash ancestry. `main` may contain a concept +through a squash, reimplementation, or later replacement even when a historical +commit is not its Git ancestor. Every retained ledger row starts **proof-pending**. +The implementation identifies or authors a focused contract and runs it against a +detached, otherwise untouched worktree at the recorded current-main SHA: + +- **main-owned:** current source already has an owner and the focused contract + passes. Existing tests are reused; a newly authored test-only proof is retained in + the candidate without changing production behavior. +- **implementation-gap:** the focused contract runs and fails because the required + behavior is absent or incomplete. Only that demonstrated gap becomes production + implementation work. +- **coverage-gap:** no adequate focused contract exists yet. The next action is to + author the test alone and rerun it against untouched current main; this is an + intermediate blocked classification, never permission to implement or import + historical production code. + +An infrastructure/setup failure remains proof-pending rather than being relabeled as +a behavioral failure. Each final classification records the main SHA, current owner +paths, exact test path/command, result, and disposition. Every row must end as +main-owned or implementation-gap before production edits for that row. The +historical patch is never applied merely because its original hash is absent. + +Authority order is exact: this reviewed hard-cutover contract, then current `main`, +then the retired snapshot as non-normative discovery evidence only. A contradiction +is never resolved in favor of retired code implicitly. The implementation plan must +contain no `rc/july` merge task and no release gate comparing the candidate to +`rc/july`. + +Each ledger entry has one of these dispositions: + +- **Preserve:** retain the observable behavior and its failure semantics. +- **Rebuild:** retain the outcome but replace the old mechanism with the runtime, + adapter, service, or integration module named here. +- **Supersede:** deliberately replace an old mechanism with a stricter named + contract. The ledger must state the behavioral change and prove either that the + new owner makes the old compensation unnecessary or that the new terminal + failure is complete, bounded, and preferable to a partial second runtime. +- **Exclude:** keep the existing feature untouched because it is not TSJS work; + this disposition cannot hide affected current-main TSJS behavior or an explicitly + retained ledger concept. + +Hard cutover authorizes removal of old mechanisms and names. It does not authorize +silent loss of current-main behavior or a retained ledger outcome. An observable +outcome may change only through an explicit **Supersede** entry that names the old +and final behavior, gives the architectural reason, and has boundary tests for the +replacement contract. A source deletion is complete only when its current-main +regression tests and ledger replacement/supersession proof pass. + +| ID | Audited TSJS concept | Disposition and final owner | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RCJ-CORE-01` | Core config/context, callback queue, auction parsing, direct request/rendering, SPA generation checks, and shared helpers continue to serve every enabled integration. | **Rebuild:** kernel, services, and composition root; exact behavioral corpus runs before and after the switch. | +| `RCJ-CORE-02` | Programmatic ad-unit registration can drive direct `/auction`; core also exposes version/queue, placeholder render helpers, mutable generic config, and the local logger. | **Preserve/supersede:** §5.4 defines the exact final API; typed registration/request APIs and immutable config replace placeholders/mutable config; logger methods/default remain, while invalid levels now throw without mutation instead of being retained with warn fallback. | +| `RCJ-BOOT-01` | The edge-injected `gpt_bootstrap.js` duplicates initial-load tracking, slot handoff, hydration scheduling, GPT definition/targeting/display/refresh, and can render initial ads without the main TSJS bundle. | **Rebuild/supersede:** the bounded first-display agent owns only the immutable projected display and atomically transfers it to the persistent runtime; it publishes no degraded API/runtime. Missing/partial/takeover failure settles through §5.3 fallback without replay. | +| `RCJ-TRACE-01` | Render tracing records one honest impression timeline, bounded history, current-slot state, DOM stamps/badges, local overlay, no stale auction attribution, and emits `tsjs:adRendered`. | **Rebuild/supersede:** first-display facts cross the exact data-only handoff into the core trace reducer, `tsjs.diagnostics.renderTrace`, and deferred presentation attachment; public data subscription replaces mutable globals/CustomEvent, and no integration writes trace state directly. | +| `RCJ-GPT-01` | A TS fallback and a later publisher `defineSlot` share one physical GPT slot and one initial request; ownership transfer prevents later TS destruction. | **Rebuild:** the one-use first-display capsule transfers the exact physical object into the persistent GPT adapter/slot service, which then owns publisher handoff; no guessed reconstruction, function sentinel, duplicate wrapper, definition, or request. | +| `RCJ-GPT-02` | Responsive/hydrated slot resolution chooses the unique active placement, recovers DOM replacement, and never silently chooses an ambiguous sibling. | **Rebuild:** navigation-scoped aliases plus runtime-owned DOM binding/reconciliation. | +| `RCJ-GPT-03` | Native publisher GPT calls, service state, SRA, disabled initial load, refresh options, targeting cleanup, and publisher-owned slots retain their native semantics. | **Preserve/Rebuild:** the sole GPT adapter owns interception and event fan-out; publisher activity never becomes TS-owned work. | +| `RCJ-GPT-04` | A TS-owned PUC response may resize only its authenticated still-collapsed ordinary 1×1 GAM shell, never unrelated, anchor, fixed, sticky, or already-expanded frames. | **Preserve/Rebuild:** current render attempt owns one guarded resize after a response is successfully posted. | +| `RCJ-PREBID-01` | The publisher-specific artifact is pure Prebid.js; the Trusted Server shim is a separate TSJS integration module, and the external bundle remains independently useful if that module fails. | **Preserve/Rebuild:** external artifact plus Prebid adapter/integration module; TS code is not vendored into the external Prebid artifact. | +| `RCJ-PREBID-02` | Missing, late, duplicate, older, or partial Prebid artifacts fail safely: publisher queues drain, TS refresh handling is not installed without a real API, and installation is idempotent. | **Preserve/Rebuild:** artifact watchdog plus release-matched module transaction and bounded readiness queue. | +| `RCJ-PREBID-03` | Adapter manifests distinguish module names from registered bidder codes/aliases; client-side bidder coverage, user-ID modules, EIDs, native bids, and publisher callbacks keep working. | **Preserve:** typed artifact contract and black-box artifact tests; TS-owned bid identities alone are replaced. | +| `RCJ-PREBID-04` | Configured GAM-path exclusions remove only matching slots from the synthetic Prebid refresh auction while clearing stale TS keys and retaining every slot/options in the GPT refresh. | **Preserve/Rebuild:** one refresh policy in the Prebid integration module over the GPT adapter; global, explicit, mixed, all-excluded, and fail-open path cases remain exact. | +| `RCJ-APS-01` | First-class APS OpenRTB admission, typed descriptor projection, direct rendering, Trusted Server Prebid-adapter rendering, and PUC rendering remain supported. | **Preserve/Rebuild:** Rust admission plus the shared render lifecycle described in §§3–4. | +| `RCJ-APS-02` | `bid.meta`, generated Prebid `adId`, upstream bid-id fallback, and old `hb_adid` precedence carried APS identity through lossy boundaries. | **Supersede:** the server-minted `r1_` reservation is the only TS PUC authority; native Prebid IDs and PBS Cache UUIDs remain byte-preserved for their own purposes. | +| `RCJ-APS-03` | PUC uses one-use ports, APS callbacks—not script load—determine success, renderer tombstones are bounded, and lifecycle callbacks cannot corrupt later attempts. | **Preserve/Rebuild:** bridge dispatcher, owner-control channel, reservation service, and terminal latch. | +| `RCJ-APS-04` | The PUC document, renderer document, and descendant creative receive the winning dimensions without default margins, scrollbars, overflow, or clipping. | **Preserve:** exact CSS/DOM sizing contract in §4.4 and three-level browser assertions. | +| `RCJ-CREATIVE-01` | Auction creative sanitization remains opt-in/default-off, rewriting retains its existing independent setting, and every delivery path observes the same configured processing boundary. | **Preserve:** creative integration module and server processing; this design does not silently enable sanitization or broaden rewriting. | +| `RCJ-CREATIVE-02` | Opaque-origin click recovery accepts only validated absolute HTTP(S) navigation, persists the validated URL, rejects non-network schemes, and keeps creative sandbox isolation. | **Preserve/Rebuild:** creative integration module over shared origin/DOM helpers, with unit and real-browser sandbox coverage. | +| `RCJ-CREATIVE-03` | `tscreative.installGuards/setConfig/getConfig`, `tsCreativeConfig`, automatic install, click-guard default-on, and render-guard default-off control the creative browser guards. | **Preserve/supersede:** `CreativeBootV1` retains the defaults and the integration module auto-installs transactionally; mutable/install command globals are deleted and immutable `tsjs.boot.creative` is the only inspection/config surface. | +| `RCJ-DIAG-01` | GPT runtime diagnostics reports raw GPT observations, exact slot binding/replacement, request cycles/timing, bounded export, overlay/badges, and no lifecycle interference. | **Preserve/Rebuild:** diagnostics integration module consumes the GPT adapter event stream and exposes `tsjs.diagnostics.gpt`; it never installs a second GPT control wrapper. | +| `RCJ-INT-01` | DataDome, Didomi, Google Tag Manager, Lockr, Osano, Permutive, Sourcepoint, and Testlight retain their current proxy guards, configuration, consent/segment, queue, and timing behavior. | **Preserve:** thin transactional integration modules plus complete pre/post-cutover black-box suites; internal feature behavior is otherwise unchanged. | +| `RCJ-INT-02` | Shared script, beacon, DOM-insertion, scheduling, origin, and async helpers retain per-integration matching and failure isolation. | **Rebuild where shared:** helper factories with integration-owned configuration; one module failure cannot unwind another integration module or publisher code. | +| `RCJ-QUAL-01` | Lint covers production source, tests, scripts, diagnostics, and build code; TypeScript and artifact checks cover the actual shipped combinations. | **Preserve/strengthen:** full-package lint/typecheck plus architecture, maximal-bundle, generated-artifact, browser, and retained-heap gates. | + +The commit clusters that exposed these concepts include the render-trace series +starting at `966c8569c`; GPT recovery/handoff/responsive/native-behavior commits +`4f45974e5`, `9b1985c8b`, `340d1efb4`, `0fdd13e7d`, `ca678fe69`, and +`b200be53c`; Prebid decoupling/resilience and refresh commits `001ad385c`, +`cdff89706`, `f3dc6ba70`, `60a85e661`, and `a007bd0d0`; creative hardening +commits `1929dc83a`, `fde835110`, `9b21ba450`, `20977105f`, `1db074d4b`, and +`3d9e2b693`; GPT diagnostics `11a4a7d25`; full-package lint `941473407`; APS +admission/rendering commits from `f916ddf90` through `a08bebfbd`; and the final +PUC/sizing chain `248fe9558`, `ed38f3e13`, `905984e62`. The executable tree +inventory, not this illustrative hash list, is the completeness authority for the +retired concept checklist only. Current `main` and this design remain the behavior +authorities. + +### 0.5 In-spec retired concept-inventory manifest + +To keep this a one-file design and make the audit runnable from a main-only shallow +checkout, the retired concept-inventory manifest embeds the complete materialized +historical path list. No test resolves the retired commit or invokes `git ls-tree`. +A contract test extracts `retired-rcjuly-tsjs-concept-manifest-v1`, requires the +inventory to contain exactly 144 sorted unique paths, recomputes SHA-256 over their +exact UTF-8 text with one LF after every path, and matches the recorded digest. It +then requires every inventory path to match at least one `exact`, `prefix`, or +`prefixes` mapping. A path receives the union of every matching row. Every +`lib/src` path must receive at least one non-`RCJ-QUAL-01` id, and a mapping that +matches no inventory path also fails. This proves only that the audit did not omit a +historical TSJS concept; it needs no retired Git object and makes no historical +source a build or behavior input. Separate ledger evidence maps every retained row +to its current-main owner/test or records a specific gap to implement. + +```json retired-rcjuly-tsjs-concept-manifest-v1 +{ + "version": 1, + "authority": "concept-audit-only", + "retiredSnapshot": "905984e62a0858c53d9f0ff6dd3a1bf190cf311d", + "inventoryCount": 144, + "inventorySha256": "b1e28c8b30f0b8d95e38c0f8f57394df4ad43f760ae7abf5631e2054228aef08", + "inventory": [ + "crates/trusted-server-core/src/integrations/aps.rs", + "crates/trusted-server-core/src/integrations/datadome.rs", + "crates/trusted-server-core/src/integrations/datadome/protection.rs", + "crates/trusted-server-core/src/integrations/datadome/protection_scope.rs", + "crates/trusted-server-core/src/integrations/didomi.rs", + "crates/trusted-server-core/src/integrations/google_tag_manager.rs", + "crates/trusted-server-core/src/integrations/gpt.rs", + "crates/trusted-server-core/src/integrations/gpt_bootstrap.js", + "crates/trusted-server-core/src/integrations/gpt_diagnostics.rs", + "crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js", + "crates/trusted-server-core/src/integrations/lockr.rs", + "crates/trusted-server-core/src/integrations/mod.rs", + "crates/trusted-server-core/src/integrations/osano.rs", + "crates/trusted-server-core/src/integrations/permutive.rs", + "crates/trusted-server-core/src/integrations/prebid.rs", + "crates/trusted-server-core/src/integrations/sourcepoint.rs", + "crates/trusted-server-core/src/integrations/testlight.rs", + "crates/trusted-server-core/src/trace_cookie.rs", + "crates/trusted-server-core/src/tsjs.rs", + "crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts", + "crates/trusted-server-integration-tests/browser/tests/nextjs/api-passthrough.spec.ts", + "crates/trusted-server-integration-tests/browser/tests/nextjs/form-rewriting.spec.ts", + "crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts", + "crates/trusted-server-integration-tests/browser/tests/nextjs/navigation.spec.ts", + "crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts", + "crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts", + "crates/trusted-server-integration-tests/browser/tests/shared/script-bundle.spec.ts", + "crates/trusted-server-integration-tests/browser/tests/shared/script-injection.spec.ts", + "crates/trusted-server-integration-tests/browser/tests/wordpress/admin-injection.spec.ts", + "crates/trusted-server-js/lib/.gitignore", + "crates/trusted-server-js/lib/.prettierignore", + "crates/trusted-server-js/lib/.prettierrc.json", + "crates/trusted-server-js/lib/build-all.mjs", + "crates/trusted-server-js/lib/build-prebid-external.mjs", + "crates/trusted-server-js/lib/eslint.config.js", + "crates/trusted-server-js/lib/package-lock.json", + "crates/trusted-server-js/lib/package.json", + "crates/trusted-server-js/lib/src/core/auction.ts", + "crates/trusted-server-js/lib/src/core/config.ts", + "crates/trusted-server-js/lib/src/core/context.ts", + "crates/trusted-server-js/lib/src/core/global.d.ts", + "crates/trusted-server-js/lib/src/core/index.ts", + "crates/trusted-server-js/lib/src/core/log.ts", + "crates/trusted-server-js/lib/src/core/queue.ts", + "crates/trusted-server-js/lib/src/core/registry.ts", + "crates/trusted-server-js/lib/src/core/render.ts", + "crates/trusted-server-js/lib/src/core/request.ts", + "crates/trusted-server-js/lib/src/core/styles/normalize.css", + "crates/trusted-server-js/lib/src/core/templates/iframe.html", + "crates/trusted-server-js/lib/src/core/trace.ts", + "crates/trusted-server-js/lib/src/core/types.ts", + "crates/trusted-server-js/lib/src/core/util.ts", + "crates/trusted-server-js/lib/src/index.ts", + "crates/trusted-server-js/lib/src/integrations/aps/render.ts", + "crates/trusted-server-js/lib/src/integrations/creative/click.ts", + "crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts", + "crates/trusted-server-js/lib/src/integrations/creative/iframe.ts", + "crates/trusted-server-js/lib/src/integrations/creative/image.ts", + "crates/trusted-server-js/lib/src/integrations/creative/index.ts", + "crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts", + "crates/trusted-server-js/lib/src/integrations/datadome/index.ts", + "crates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/didomi/index.ts", + "crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts", + "crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/gpt/index.ts", + "crates/trusted-server-js/lib/src/integrations/gpt/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts", + "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts", + "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts", + "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts", + "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts", + "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts", + "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts", + "crates/trusted-server-js/lib/src/integrations/lockr/index.ts", + "crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/osano/index.ts", + "crates/trusted-server-js/lib/src/integrations/permutive/index.ts", + "crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/permutive/segments.ts", + "crates/trusted-server-js/lib/src/integrations/prebid/index.ts", + "crates/trusted-server-js/lib/src/integrations/prebid/prebid_modules/aliases.d.ts", + "crates/trusted-server-js/lib/src/integrations/prebid/prebid_modules/liveIntentIdSystem.ts", + "crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json", + "crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts", + "crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts", + "crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts", + "crates/trusted-server-js/lib/src/integrations/testlight/index.ts", + "crates/trusted-server-js/lib/src/shared/async.ts", + "crates/trusted-server-js/lib/src/shared/beacon_guard.ts", + "crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts", + "crates/trusted-server-js/lib/src/shared/globals.ts", + "crates/trusted-server-js/lib/src/shared/origin.ts", + "crates/trusted-server-js/lib/src/shared/scheduler.ts", + "crates/trusted-server-js/lib/src/shared/script_guard.ts", + "crates/trusted-server-js/lib/test/build-prebid-external.test.mjs", + "crates/trusted-server-js/lib/test/core/auction.test.ts", + "crates/trusted-server-js/lib/test/core/config.test.ts", + "crates/trusted-server-js/lib/test/core/context.test.ts", + "crates/trusted-server-js/lib/test/core/index.test.ts", + "crates/trusted-server-js/lib/test/core/registry.test.ts", + "crates/trusted-server-js/lib/test/core/render.test.ts", + "crates/trusted-server-js/lib/test/core/request.test.ts", + "crates/trusted-server-js/lib/test/core/trace.test.ts", + "crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json", + "crates/trusted-server-js/lib/test/integrations/aps/render.test.ts", + "crates/trusted-server-js/lib/test/integrations/creative/click.test.ts", + "crates/trusted-server-js/lib/test/integrations/creative/helpers.ts", + "crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts", + "crates/trusted-server-js/lib/test/integrations/creative/image.test.ts", + "crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts", + "crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts", + "crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts", + "crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt/script_guard.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts", + "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts", + "crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts", + "crates/trusted-server-js/lib/test/integrations/osano/index.test.ts", + "crates/trusted-server-js/lib/test/integrations/permutive/segments.test.ts", + "crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts", + "crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts", + "crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts", + "crates/trusted-server-js/lib/test/integrations/sourcepoint/script_guard.test.ts", + "crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs", + "crates/trusted-server-js/lib/test/shared/async.test.ts", + "crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts", + "crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts", + "crates/trusted-server-js/lib/test/shared/scheduler.test.ts", + "crates/trusted-server-js/lib/tsconfig.json", + "crates/trusted-server-js/lib/vite.config.ts", + "crates/trusted-server-js/lib/vitest.config.ts" + ], + "includeRoots": [ + "crates/trusted-server-js/lib", + "crates/trusted-server-integration-tests/browser/tests" + ], + "mappings": [ + { + "exact": [ + "crates/trusted-server-js/lib/.gitignore", + "crates/trusted-server-js/lib/.prettierignore", + "crates/trusted-server-js/lib/.prettierrc.json", + "crates/trusted-server-js/lib/eslint.config.js", + "crates/trusted-server-js/lib/package-lock.json", + "crates/trusted-server-js/lib/package.json", + "crates/trusted-server-js/lib/tsconfig.json", + "crates/trusted-server-js/lib/vite.config.ts", + "crates/trusted-server-js/lib/vitest.config.ts" + ], + "ids": ["RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/build-all.mjs", + "crates/trusted-server-js/lib/src/index.ts" + ], + "ids": ["RCJ-CORE-01", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/build-prebid-external.mjs", + "crates/trusted-server-js/lib/test/build-prebid-external.test.mjs", + "crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs" + ], + "ids": ["RCJ-PREBID-01", "RCJ-PREBID-02", "RCJ-PREBID-03", "RCJ-QUAL-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/core/", + "ids": ["RCJ-CORE-01"] + }, + { + "exact": ["crates/trusted-server-js/lib/src/core/log.ts"], + "ids": ["RCJ-CORE-02"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/src/core/config.ts", + "crates/trusted-server-js/lib/src/core/index.ts", + "crates/trusted-server-js/lib/src/core/registry.ts", + "crates/trusted-server-js/lib/src/core/request.ts" + ], + "ids": ["RCJ-CORE-02"] + }, + { + "exact": ["crates/trusted-server-js/lib/src/core/trace.ts"], + "ids": ["RCJ-TRACE-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/shared/", + "ids": ["RCJ-CORE-01", "RCJ-INT-02"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/aps/", + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/creative/", + "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/gpt/", + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-GPT-04"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/", + "ids": ["RCJ-DIAG-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/prebid/", + "ids": [ + "RCJ-PREBID-01", + "RCJ-PREBID-02", + "RCJ-PREBID-03", + "RCJ-PREBID-04" + ] + }, + { + "prefixes": [ + "crates/trusted-server-js/lib/src/integrations/datadome/", + "crates/trusted-server-js/lib/src/integrations/didomi/", + "crates/trusted-server-js/lib/src/integrations/google_tag_manager/", + "crates/trusted-server-js/lib/src/integrations/lockr/", + "crates/trusted-server-js/lib/src/integrations/osano/", + "crates/trusted-server-js/lib/src/integrations/permutive/", + "crates/trusted-server-js/lib/src/integrations/sourcepoint/", + "crates/trusted-server-js/lib/src/integrations/testlight/" + ], + "ids": ["RCJ-INT-01", "RCJ-INT-02"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/core/", + "ids": ["RCJ-CORE-01", "RCJ-CORE-02", "RCJ-QUAL-01"] + }, + { + "exact": ["crates/trusted-server-js/lib/test/core/trace.test.ts"], + "ids": ["RCJ-TRACE-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/shared/", + "ids": ["RCJ-INT-02", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json" + ], + "ids": ["RCJ-APS-01", "RCJ-APS-03", "RCJ-QUAL-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/aps/", + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/creative/", + "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/gpt/", + "ids": [ + "RCJ-BOOT-01", + "RCJ-GPT-01", + "RCJ-GPT-02", + "RCJ-GPT-03", + "RCJ-GPT-04" + ] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/", + "ids": ["RCJ-DIAG-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/prebid/", + "ids": [ + "RCJ-PREBID-01", + "RCJ-PREBID-02", + "RCJ-PREBID-03", + "RCJ-PREBID-04" + ] + }, + { + "prefixes": [ + "crates/trusted-server-js/lib/test/integrations/datadome/", + "crates/trusted-server-js/lib/test/integrations/didomi/", + "crates/trusted-server-js/lib/test/integrations/google_tag_manager/", + "crates/trusted-server-js/lib/test/integrations/lockr/", + "crates/trusted-server-js/lib/test/integrations/osano/", + "crates/trusted-server-js/lib/test/integrations/permutive/", + "crates/trusted-server-js/lib/test/integrations/sourcepoint/" + ], + "ids": ["RCJ-INT-01", "RCJ-INT-02", "RCJ-QUAL-01"] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/gpt_bootstrap.js"], + "ids": ["RCJ-BOOT-01", "RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03"] + }, + { + "exact": [ + "crates/trusted-server-core/src/integrations/gpt_diagnostics.rs", + "crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js" + ], + "ids": ["RCJ-DIAG-01"] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/gpt.rs"], + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-GPT-04"] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/prebid.rs"], + "ids": [ + "RCJ-PREBID-01", + "RCJ-PREBID-02", + "RCJ-PREBID-03", + "RCJ-PREBID-04" + ] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/aps.rs"], + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "exact": [ + "crates/trusted-server-core/src/integrations/datadome.rs", + "crates/trusted-server-core/src/integrations/datadome/protection.rs", + "crates/trusted-server-core/src/integrations/datadome/protection_scope.rs", + "crates/trusted-server-core/src/integrations/didomi.rs", + "crates/trusted-server-core/src/integrations/google_tag_manager.rs", + "crates/trusted-server-core/src/integrations/lockr.rs", + "crates/trusted-server-core/src/integrations/mod.rs", + "crates/trusted-server-core/src/integrations/osano.rs", + "crates/trusted-server-core/src/integrations/permutive.rs", + "crates/trusted-server-core/src/integrations/sourcepoint.rs", + "crates/trusted-server-core/src/integrations/testlight.rs" + ], + "ids": ["RCJ-INT-01", "RCJ-INT-02"] + }, + { + "exact": ["crates/trusted-server-core/src/trace_cookie.rs"], + "ids": ["RCJ-TRACE-01"] + }, + { + "exact": ["crates/trusted-server-core/src/tsjs.rs"], + "ids": ["RCJ-CORE-01", "RCJ-CORE-02", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts" + ], + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-DIAG-01"] + }, + { + "prefix": "crates/trusted-server-integration-tests/browser/tests/", + "ids": ["RCJ-CORE-01", "RCJ-INT-01", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts" + ], + "ids": ["RCJ-DIAG-01"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/nextjs/navigation.spec.ts" + ], + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts" + ], + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts" + ], + "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"] + } + ] +} +``` + +### 0.6 Approved review remediation + +The initial role-correct implementation exposed material first-display transfer and +request-time work that its own post-change capture could not legitimately approve. +The first mechanical remediation reduced the `[core, render_runtime, creative, +gpt]` response to roughly 395 kB raw, but paired evidence at candidate +`8ef8a40df` still measured 2,163.6 ms p90 against a 473.3 ms current-`main` +baseline: about 4.57×, where the release gate permits 1.10×. Co-bundling the +same full ownership graph saves only duplicated module bytes and cannot close that +gap. Both captures remain immutable evidence of reviewed intermediate states, not +release baselines. + +Before merge, the implementation therefore replaces the oversized parser-blocking +runtime with the bounded first-display agent and atomic takeover in §5.2. It also +precomputes finite TSJS transport identities outside request handling and passes +both the byte-accounting and network-shaped browser gates in §5.12. The persistent +runtime remains architecturally complete after takeover; the load-time fix neither +deletes resilience behavior nor weakens the gate. A gate captured from an oversized +candidate cannot authorize that same candidate. + +This remediation does not reopen the hard-cutover decision. `pub_id`, numeric APS +identifiers, unknown fields, old routes, and old browser APIs remain rejected rather +than aliased. It also does not create an APS runner artifact or cache design: the +runner stays live, unversioned, unvendored, unpinned, and uncached by Trusted Server. +Reserved APS browser routes remain intentionally anonymous and are dispatched before +publisher `[[handlers]]`; operators place admission control, rate limiting, or +request shielding at the platform boundary. + +DataDome and the other integrations in `RCJ-INT-01` remain in scope only where their +current-main TSJS implementation is affected by runtime composition or where the +retired audit identifies an explicitly retained TSJS gap. This is preservation +behind the common runtime, not permission to import unrelated retired-branch work or +redesign their server-side behavior. The unrelated server-side ad-template +cache-control proposal is excluded from this design. + +## 1. Problem statement and evidence + +APS demand is integrated server-side, but APS creatives do not render reliably. +Four serial fixes—the `bid.meta` carrier, decoupled shim, `hb_adid` fallback, and +the historical PUC/collapsed-shell fix—each repaired one edge while leaving other +independent failure points. The common failure is architectural: identity and +state are copied across loosely coordinated server, GPT, Prebid, PUC, and iframe +code, and many failures are swallowed. + +The TSJS library has the same structural problem: two large GPT/Prebid modules, +duplicated ES5 and TypeScript behavior, imports in separately built IIFEs that do +not share module state, global expandos, multiple GPT wrappers, and asynchronous +work with no common owner. + +### 1.1 Supported flows + +| Flow | Auction source | Render owner | APS route | +| --------- | ----------------------------------------------- | ------------------------ | ------------------------------------------------------- | +| SSAT | `tsjs.boot.auctionProjection` | GPT integration | GAM Universal Creative → TS bridge → APS renderer | +| Prebid | Trusted Server Prebid adapter | Prebid + GPT integration | GAM Universal Creative → TS bridge → APS renderer | +| Page bids | `/_ts/page-bids` | GPT integration | GAM Universal Creative → TS bridge → APS renderer | +| Direct | `/auction` | render service | TS-owned iframe → APS renderer | +| Fallback | opt-in child of an attributable empty GAM cycle | render service | direct APS or direct ADM, according to the returned bid | + +### 1.2 Known failure surfaces + +| Area | Failure | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Admission | A configured mediator can discard direct-provider bids; scripts can be rejected by policy; strict dimensions and APS response validation can drop bids without a useful local reason. | +| Identity | PBS Cache UUID, upstream APS bid id, Prebid `adId`, GAM `hb_adid`, DOM id, and server slot id are different identities and have been conflated. GAM targeting values are capped at 40 characters. | +| GPT | `display()` under disabled initial load does not request; event listeners can be installed too late; multiple refresh wrappers and concurrent requests race; SafeFrame obscures frame ancestry. | +| Bridge | A `Prebid Request` can be duplicated, replayed, sent by a wrong frame, or arrive after navigation. A bare bid id is not enough to establish ownership. | +| Renderer | The opaque sandbox cannot observe HTTP failure; descriptor validation exists in Rust, TypeScript, and embedded ES5; CSP or runner loading can fail after the iframe loads. | +| Direct auction | One fetch can contain several slots, but current cancellation and result handling are not batch-aware; failures collapse to an empty array. | +| Bootstrap | Server bootstrap and bundle initialization can both believe they own runtime setup; a hung bundle can race the no-bundle fallback. | +| Lifecycle | There is no shared definition of attempt, ownership, supersession, terminal completion, or disposal. | + +## 2. Required behavior + +### 2.1 Outcome contract + +Each render attempt has one of four terminal outcomes: + +```ts +type RenderFailureReason = + | 'auction_timeout' + | 'auction_disabled' + | 'consent_denied' + | 'slot_not_eligible' + | 'provider_timeout' + | 'provider_error' + | 'invalid_provider_response' + | 'mediation_failed' + | 'winner_not_renderable' + | 'internal_error' + | 'network_error' + | 'http_error' + | 'invalid_response' + | 'slot_unresolved' + | 'descriptor_invalid' + | 'invalid_dimensions' + | 'dimensions_out_of_range' + | 'no_render_source' + | 'registry_full' + | 'capability_registry_full' + | 'external_queue_full' + | 'external_ready_timeout' + | 'external_artifact_incompatible' + | 'prebid_admission_failed' + | 'prebid_contract_violation' + | 'prebid_selection_timeout' + | 'reservation_collision' + | 'identity_generation_failed' + | 'cycle_unattributable' + | 'slot_quarantined' + | 'gpt_request_failed' + | 'gpt_request_timeout' + | 'gpt_completion_timeout' + | 'reconciliation_capacity' + | 'gam_empty' + | 'bridge_claim_timeout' + | 'bridge_id_mismatch' + | 'owner_registration_timeout' + | 'owner_insertion_timeout' + | 'renderer_document_no_load' + | 'runner_no_load' + | 'runner_failed' + | 'adm_document_no_load' + | 'abi_mismatch' + | 'bundle_partial' + +type RenderOutcome = + | { outcome: 'accepted' } + | { outcome: 'no_bid' } + | { outcome: 'failed'; reason: RenderFailureReason } + | { + outcome: 'cancelled' + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed' + } +``` + +`accepted` means the path-specific completion authority reported success: + +- APS: the sandboxed renderer document accepted the descriptor and the queued APS + `prebid/creative/render` event invoked its success callback. APS owns the runner and + its promise that it invokes this callback only after committing the nested creative + iframe; Trusted Server cannot independently prove that promise for mutable upstream + bytes. Loading the runner script alone is not acceptance. +- Direct ADM: the TS-owned iframe fired its first `load` before timeout. +- PUC ADM: the TS-authored PUC owner reported its owned iframe's first + `load` over the bound owner channel. + +It does not claim that pixels were viewable. `slotRenderEnded{isEmpty:false}` is +evidence that GAM injected a creative, not evidence that APS completed. + +`no_bid` is reserved for an explicit, successfully parsed server auction decision +with no valid winner for that slot. An attributable GPT +`slotRenderEnded{isEmpty:true}` is +`failed{reason:'gam_empty'}` so an opt-in fallback can name its exact parent cause. +Network, timeout, HTTP, parse, descriptor, bridge, and renderer failures are not +converted to `no_bid`. + +Every attempt owns one terminal latch. All competing callbacks, ports, iframe +events, timers, aborts, and navigation disposal race through that latch. The first +valid terminal transition wins, disposes attempt resources, and makes every later +signal inert. + +### 2.2 Identity model + +The implementation keeps these identities distinct: + +| Identity | Purpose | Rules | +| ----------------------- | -------------------------------- | -------------------------------------------------------------- | +| server slot id | auction and publisher projection | exact, case-sensitive, 1–256 UTF-8 bytes, no NUL/control | +| programmatic slot id | direct-auction registration | validated `code`; same bound; exact request/result identity | +| DOM/container alias | locating a page element | separate collision-detecting index; ambiguous aliases fail | +| upstream bid id | provider provenance | 1–64 UTF-8 bytes, no NUL/control, unique in provider response | +| candidate id | mediator round-trip | server-minted opaque 12-character token; never an ordering key | +| PBS Cache UUID | cache transport lookup | preserved byte-for-byte as `cacheId`; never bridge authority | +| native Prebid `adId` | non-TS Prebid renderer lookup | untouched for native bids; never entered in the TS store | +| renderer reservation id | every TS-owned PUC capability | `r1_` plus 22 base64url characters; exact `hb_adid`/TS `adId` | +| attempt id | in-page lifecycle ownership | `a1_` plus 22 base64url characters; navigation-unique | +| lifecycle ticket | cross-window capability | `t1_` plus 22 base64url characters; one-use and attempt-bound | +| renderer nonce | renderer-document capability | `n1_` plus 22 base64url characters; one-use and attempt-bound | +| GPT trace slot token | diagnostic physical-object join | adapter-minted canonical `gt1_` form; runtime-local, nonreused | +| GPT trace cycle ordinal | diagnostic impression join | 1..2^32-1 per physical object; valid only with its slot token | + +Every TS-owned PUC source introduced by this design—APS or inline ADM—receives a renderer reservation +id, and that id is copied exactly to GAM `hb_adid`. For the Trusted Server Prebid +adapter bid, it also replaces the TS bid's generated `adId` before targeting; +native Prebid bids are untouched. The server creates the id from 16 CSPRNG bytes +encoded as unpadded base64url and prefixed with `r1_`; it retries a response-local +collision at most eight times, then fails the bid with +`identity_generation_failed`. The browser rejects a collision with any live/ +tombstoned reservation as `reservation_collision`. A PBS Cache UUID and the +upstream/provider bid id retain their current transport/provenance purposes and are +never fallback credentials for an APS or ADM reservation. + +Attempt ids require no unbounded issued-id set. At `NavigationSession` creation the +browser obtains eight CSPRNG bytes with `crypto.getRandomValues` and keeps one +unsigned 64-bit attempt ordinal as two 32-bit words. For each new attempt it +increments the ordinal, concatenates the navigation prefix with the big-endian +ordinal, base64url-encodes those 16 bytes without padding, and prefixes `a1_`. The +fixed navigation prefix plus never-reused ordinal guarantees navigation-local +uniqueness. Prefix-generation failure or ordinal exhaustion refuses the new attempt +with `identity_generation_failed`; the ordinal never wraps. The active-attempt index +contains at most one attempt per admitted slot and is therefore capped by +`MAX_ACTIVE_SLOT_RECORDS = 256`; terminal settlement removes the strong entry, while +generation plus the nonreused ordinal makes stale callbacks inert without retaining +old ids. + +One runtime-owned capability registry stores lifecycle tickets and their tombstones +with a shared capacity of 320. Before minting it prunes entries whose fixed +three-second lifetime has expired; unexpired entries are never evicted. A ticket is +16 fresh CSPRNG bytes encoded as the fixed `t1_` form and checked against every live/ +tombstoned ticket. A collision retries at most eight total draws, then fails the +attempt with `identity_generation_failed`. Capacity exhaustion before a successful +draw refuses the outer PUC response and fails the attempt with +`capability_registry_full`. Consumption/disposal replaces the live entry with a +tombstone carrying the same original expiry; it never extends the lifetime. + +Renderer nonces use the same eight-draw CSPRNG/collision rule in a separate live +registry capped at 256, at most one `n1_` value per active attempt. They need no +tombstone: the exact frame, port, attempt id, and generation remain mandatory, and +attempt disposal closes the channel and removes the live nonce before any later +attempt can act. Nonce capacity fails the attempt with `capability_registry_full`; +collision exhaustion is `identity_generation_failed`. Neither registry falls back +to timestamps, `Math.random`, truncation, or eviction. + +### 2.3 Slot registry and bounded reservations + +One runtime-scoped slot service owns the registry: + +- `WeakMap` for GPT object identity; +- exact registered-slot-id index covering server and programmatic registrations; +- exact GPT ad-unit-code index; +- separate DOM alias index that rejects collisions; +- active render reservation map plus consumed/stale tombstones; +- request-intent and active-cycle state per slot. + +`MAX_ACTIVE_SLOT_RECORDS` is 256 across server-projected and programmatically +registered records in one `NavigationSession`. The immutable server projection is +validated against that total before the kernel commits; an oversized projection is +an `abi_mismatch` boot failure. `addAdUnits` reserves capacity for its whole input +before mutation and rejects the whole call with +`AdUnitRegistrationError{code:'registry_capacity'}` when the remaining capacity is +insufficient. Navigation disposal synchronously removes every record and secondary +index owned by that navigation; reservations/tombstones retain only their separate +bounded lifecycle below. + +Registration rejects a missing, empty, or greater-than-256-UTF-8-byte server or +programmatic slot id before indexing and assigns each valid slot a monotonically +increasing, navigation-local ordinal. An exact registered-slot-id collision is +rejected rather than overwritten. GPT ad-unit-code and DOM-alias indexes retain +collision state: a lookup must resolve exactly one record, and zero or multiple +matches fail `slot_unresolved`; registration order is never used to choose among +collisions. + +The reservation map and tombstones share a capacity of 320. An SSAT/page-bid render +reservation has a fixed 15-minute lifetime measured by the runtime's monotonic clock +from browser registration; consumption does not extend it. A Prebid bid awaiting +client-side selection instead receives a ten-second admission lease. Exact selection +atomically promotes that lease to a render reservation with a new fixed 15-minute +lifetime measured from promotion; this occurs before targeting can expose the id and +is the only expiry replacement. An admission lease is suppress-only and cannot +satisfy a PUC claim; a request carrying that id before selection is refused, +tombstones the lease, and records `prebid_contract_violation`. Unselected, aborted, +or selection-timed-out entries +become tombstones only through their original ten-second lease expiry. Expired +entries are pruned. +Unexpired entries are never evicted because eviction would allow a late creative +request to escape TS ownership. At capacity, registration fails with +`registry_full`. A consumed, stale, or disposed reservation remains a tombstone +until its original expiry and never produces a second response. While live, each +admission lease or render reservation records its exact slot, render source, +immutable `WinnerContext{selectedCpm}`, navigation generation, expiry, and state. +`selectedCpm` is copied from the fully validated selected projected bid and is +finite and nonnegative. Prebid admission verifies that the frozen bid's `cpm` is +exactly this stored value, and selection promotion preserves the same context +rather than reconstructing it from Prebid. A successful PUC claim transfers the +context into the `RenderAttempt` before replacing the live entry with a tombstone. +The exact frozen successful claim result is also a one-shot internal capability: +`RenderAttempt` consumes that object to receive its already-bound render source and +winner context from the service's bounded internal record. The claim object exposes +neither field, so callers cannot pass, reconstruct, or swap them separately. +Consumption additionally requires the branded reservation service, its live runtime, +the original current attempt and navigation generation, and a time strictly before +the reservation's fixed expiry. Disposal, expiry, or loss of exact attempt authority +invalidates the claim capability. +The tombstone discards the render source and winner context and retains only the id, +original expiry, terminal state, and minimum suppression metadata. Neither the +renderer descriptor nor any capability crossing a browser-context boundary contains +CPM; the one-shot claim object remains internal to the same runtime. + +Ids are unique across all live/tombstoned entries, so lookup identifies one entry +and then requires its exact active slot, cycle, and generation. The first compatible +PUC claim acquires its source and winner context; a live or tombstoned TS id is +suppressed before detailed validation. + +Direct `/auction` APS/ADM rendering does not round-trip through a PUC reservation. +Its exact winner join creates the `RenderAttempt` with an immutable +`WinnerContext{selectedCpm}` copied from that same validated projected winner before +rendering. Thus the redesigned direct and PUC APS/ADM paths have the same CPM +authority without inventing a bridge capability for direct rendering. Existing PBS +Cache price expansion remains outside this contract and is regression-tested at its +current-main behavior. + +The current `__tsRenderGeneration`, `__tsRenderBid`, and function-sentinel +expandos are removed. + +### 2.4 GPT request-cycle ownership + +GPT events describe physical requests and are not promises. The runtime therefore +tracks request intent separately from physical cycles: + +1. A TS operation records an intent before calling `display()` or `refresh()`. +2. A TS operation never treats `display()` as request-capable while initial load is + disabled. It uses `display()` only to register the slot, then invokes exactly one + `refresh([slot], {changeCorrelator:false})`; the request intent and three-second + start deadline attach only to that refresh. If the adapter cannot invoke refresh + or the call throws, the attempt fails `gpt_request_failed`. A publisher-owned + `display()` remains publisher activity and starts no TS attempt or fallback. +3. A physical cycle opens only on `slotRequested` and closes on the corresponding + `slotRenderEnded`. +4. One TS-owned cycle may be outstanding per slot, with at most one queued + replacement. +5. Attribution requires exactly one live compatible intent. Overlap or a later + request-capable intent that makes ownership ambiguous fails the affected TS + cycle with `cycle_unattributable`; it is never guessed from timing. +6. `responseIdentifier` deduplicates completion but is not an ownership token. +7. A cycle is re-armed only by counted completion or safe TS-owned + destroy/redefine—never by a timeout or navigation disposal that merely hopes GPT + has finished. + +`slotRequested` and `slotRenderEnded` listeners are installed unconditionally +before any TS display/refresh call. SRA opens one logical cycle per participating +slot. Publisher-initiated GPT activity remains publisher-owned and cannot trigger +TS fallback. + +An operation waiting for GPT or Prebid readiness has its own fixed ten-second +deadline measured from enqueue and fails `external_ready_timeout`; public +`requestAds.timeoutMs` never shortens or extends it. After a request-capable +`display()` or `refresh()` is +invoked, `slotRequested` must arrive within three seconds or the attempt fails +`gpt_request_timeout`. Its matching `slotRenderEnded` must arrive within ten seconds +of that same request invocation or the attempt fails `gpt_completion_timeout`. +A timeout tombstones the reservation, closes owned ports, +and settles the attempt, but does not pretend the physical GPT cycle completed. + +At `gpt_request_timeout`, no attributable physical cycle exists. The adapter +immediately invokes the transactional TS-owned destroy/redefine contract in §5.7 +and permanently retires the old object. Failure defines no replacement and leaves +the path quarantined; later TS work fails `gpt_request_failed`. A publisher-owned +object enters page-lifetime quarantine and cannot +accept new TS work until the publisher explicitly destroys that object or the page +reloads; no later `slotRequested` or `slotRenderEnded` may release that quarantine +because it cannot be attributed to the timed-out invocation. At +`gpt_completion_timeout`, the already-open exact physical cycle stays retired or +quarantined until its matching real completion, safe TS-owned destroy/redefine, +publisher destruction, or reload. Late GPT events only drain an already attributable +completion-timeout cycle; they cannot revive an attempt, start fallback, or re-arm a +request-timeout quarantine. + +Navigation disposal does not manufacture a GPT completion. If the open slot is +TS-owned, the adapter invokes the §5.7 transaction; a replacement is defined only +when the current navigation still needs it and exact destruction succeeded. The +retired object remains in the runtime `WeakMap` until its late completion drains and +can never be matched to a replacement. Destroy failure leaves no second object and +quarantines later TS work. If it is publisher-owned, the physical cycle +is quarantined: new TS work for that GPT object fails `slot_quarantined` until the +matching `slotRenderEnded`, publisher destruction, or full page reload. There is no +timeout-based re-arm. In particular, an old completion after navigation but before +the replacement's completion settles only the retired/quarantined cycle. + +### 2.5 SPA and concurrent work + +- `RuntimeSession` survives SPA navigations and owns the global lifetime plus + injected adapter/service disposers. Runtime-scoped slot and reservation services + own the slot-object map, bridge listener, reservations/tombstones, and physical + GPT cycle state; kernel code knows them only through interfaces. +- `NavigationSession` owns route-specific slot aliases, request intents, auction + batches, attempts, timers, targeting history, and one internal immutable current + auction-projection snapshot. +- A new navigation atomically replaces the prior `NavigationSession`; disposal + cancels its live attempts and prevents late callbacks from mutating the new one. +- The initial session seeds its internal projection from the recursively frozen + `tsjs.boot.auctionProjection`. A later SPA session begins with no current + projection. Its page-bids controller accepts only one exact, fully validated + `BrowserAuctionProjectionV1` for the current navigation generation, deep-copies + and freezes it, transactionally registers all projected slots against the shared + 256-slot cap, then commits it to the session. A stale, duplicate, malformed, or + over-cap response commits no slot, targeting, bid, or projection and cannot retain + the prior navigation's data. Programmatic registrations admitted before that + response count against the same transaction. The immutable public boot object is + document-generation input and is never rewritten into a mutable current-state + carrier. +- An `AuctionBatch` owns one `/auction` fetch and one child attempt per requested + slot. Supersession cancels children individually. The shared fetch is aborted + only when every child is terminal, the caller aborts all children, the batch + response deadline expires, or navigation disposes. +- After a parsed response is processed, every still-live child receives the exact + server decision for its slot; missing, duplicate, or inconsistent decisions are + `invalid_response`, never inferred as `no_bid`. + +### 2.6 Fallback + +`SlotOperation` owns the public per-slot result and one primary `RenderAttempt`. +Fallback is opt-in and, when eligible, becomes a second child attempt. It may start +only after an attributable TS-owned GAM cycle terminates empty. Publisher-owned, +ambiguous, timed-out, quarantined, or stale cycles never trigger fallback. Each +child has its own immutable terminal result and local history. The operation +settles once: with the primary result when no fallback runs, or with the fallback +child result and `path:'fallback'` after the primary `gam_empty`. A child never +overwrites its parent or sibling. + +## 3. APS wire and server contracts + +### 3.1 One descriptor + +The only APS render descriptor is: + +```ts +interface ApsRendererV1 { + type: 'aps' + version: 1 + accountId: string + bidId: string + creativeId?: string + tagType: 'iframe' | 'script' + creativeUrl: string + width: number + height: number + aaxResponse: string +} + +interface AdmRenderSourceV1 { + type: 'adm' + version: 1 + adm: string + width: number + height: number +} + +interface BaselinePbsCacheSourceV1 { + type: 'pbs_cache' + version: 1 + cacheId: string + cacheHost: string + cachePath: string + width: number + height: number +} + +type OwnedRenderSourceV1 = ApsRendererV1 | AdmRenderSourceV1 +type BidRenderSourceV1 = OwnedRenderSourceV1 | BaselinePbsCacheSourceV1 +``` + +`ApsRendererV1` and `AdmRenderSourceV1` are the redesigned, reservation-owned +sources. A selected internal Rust APS/ADM bid carries exactly one corresponding enum +member; APS markup is not smuggled through `adm`, `meta`, or debug fields. Each +tagged object rejects unknown keys. Limits are defined once and shared by the Rust +producer, TypeScript parser, and embedded renderer validator: + +- nonempty `accountId` and optional nonempty `creativeId`, each at most 1,024 + UTF-8 bytes; +- nonempty `bidId` of at most 64 UTF-8 bytes; +- numeric, finite, integral `width` and `height`, each in the inclusive shared + `RENDER_DIMENSION_MIN = 1` through `RENDER_DIMENSION_MAX = 4096` CSS-pixel range; +- `creativeUrl` at most 4,096 UTF-8 bytes, HTTPS, no credentials, and not the + publisher origin; +- canonical standard-base64 `aaxResponse`, decoded size at most 256 KiB; +- exactly one decoded seat and one decoded bid; +- decoded bid id, dimensions, `creativeurl`, and `tagtype` exactly match the + duplicated descriptor fields; +- finite, nonnegative decoded price. + +A checked-in schema/corpus is the cross-language conformance source. Rust, +TypeScript, and the ES5 renderer validator run the same positive and adversarial +vectors. CI fails when generated ES5/schema output is stale. Semantic validation +that cannot be represented in JSON Schema remains in small handwritten validators +covered by the same corpus. + +For `adm`, markup is nonempty and at most 512 KiB. `BaselinePbsCacheSourceV1` is only +a hard-cutover carrier for the current-main cache coordinates +occupied `hb_adid`, `hb_cache_host`, and `hb_cache_path`. It introduces no cache +policy, URL construction, response validation, price authority, dimensions, +deadline, error taxonomy, direct-cache feature, or PUC protocol. The GPT integration +delegates it to the preserved current-main cache implementation behind a thin +generation/disposal boundary, and the current-main black-box corpus is the authority +for its behavior. A PBS bid with accepted ADM uses the ADM source even when cache +coordinates coexist; that documents the current ADM-over-cache +precedence. `pbs_cache` is used only when accepted ADM is absent. It retains its +native cache UUID identity and never enters the `r1_` reservation registry. + +### 3.2 Per-slot auction decisions + +Every server auction entry point produces exactly one decision for every requested +slot, in request order: + +```ts +type AuctionSlotFailureReason = + | 'auction_disabled' + | 'consent_denied' + | 'slot_not_eligible' + | 'provider_timeout' + | 'provider_error' + | 'invalid_provider_response' + | 'mediation_failed' + | 'winner_not_renderable' + | 'identity_generation_failed' + | 'internal_error' + +type SlotAuctionDecisionV1 = + | { slot: string; outcome: 'winner'; candidateId: string } + | { slot: string; outcome: 'no_bid' } + | { slot: string; outcome: 'failed'; reason: AuctionSlotFailureReason } + +interface AuctionDecisionSetV1 { + version: 1 + auctionId: string + results: SlotAuctionDecisionV1[] +} + +interface BrowserProjectedBidBaseV1 { + candidateId: string + slot: string + provider: string + upstreamBidId: string + cpm: number + currency: 'USD' + targeting: Record +} + +type BrowserProjectedBidV1 = + | (BrowserProjectedBidBaseV1 & { + rendererReservationId: string + renderSource: OwnedRenderSourceV1 + }) + | (BrowserProjectedBidBaseV1 & { + renderSource: BaselinePbsCacheSourceV1 + }) + +interface BrowserAuctionProjectionV1 { + version: 1 + auction: AuctionDecisionSetV1 + slots: Array<{ + slot: string + gamUnitPath: string + divId: string + formats: Array + targeting: Record + }> + bids: BrowserProjectedBidV1[] +} +``` + +`BrowserAuctionProjectionV1` is exact, deny-unknown, and bounded before any slot, +reservation, targeting, or bid mutation. Its canonical UTF-8 JSON is at most +`MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024`; `auction.results`, `slots`, +and `bids` each contain at most 256 entries; and all objects are plain own-data objects +with no accessors. Canonical serialization uses the interface field order shown, +request order for results, the same order for slots, matching result order for bids, +lexically sorted targeting keys, and no insignificant whitespace. `auctionId` matches +`^[A-Za-z0-9._:-]{1,128}$`; candidate ids use +the exact 12-character base64url form from §3.4 and are unique; result slots are +unique, follow the §2.2 bound, and contain no NUL or ASCII control; every winner has +exactly one bid with the same slot/candidate and non-winners have none. An APS/ADM +bid has one `rendererReservationId` using the exact unique `r1_` form from §2.2; a +`pbs_cache` bid has no such field and retains the current cache UUID identity. Provider matches +`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`; and upstream bid ids are 1–64 UTF-8 bytes with +no NUL or ASCII control. CPM is a finite nonnegative number and currency is exactly +`USD`. + +For initial HTML and `/_ts/page-bids`, `slots.length` equals +`auction.results.length` exactly. Entry `slots[i].slot` equals +`auction.results[i].slot`; slot ids are unique; and the entire projection is rejected +if any placement is missing, duplicated, extra, or out of order. `gamUnitPath` and +`divId` are nonempty, contain no NUL or ASCII control, and are each at most 256 UTF-8 +bytes. `formats` contains 1–64 exact two-number tuples and every width and height is +an integer in 1–4096. Placement `targeting` uses the same exact key/value grammar and +32-entry cap as bid targeting and cannot contain `hb_adid`. + +The direct `/auction` response does not expose this browser projection shape. Its +internal use of the canonical decision/bid serializer supplies `slots:[]` because +there is no server-rendered GAM placement to bind; the wire response remains the +exact OpenRTB response plus decision extension. Rust canonicalization therefore +accepts either full ordered placement coverage or the direct-only empty placement +vector, while the browser boot/page-bids parser accepts only full ordered coverage. +No browser consumer interprets an empty placement vector for a nonempty decision set. + +Each bid's `targeting` member is a plain own-data object with at most 32 entries. A key +matches `^[A-Za-z0-9_]{1,20}$`, is unique and case-sensitive, and cannot be +`hb_adid`, which the runtime alone synthesizes from the reservation. A value is +nonempty, contains at most 40 Unicode scalar values and 160 UTF-8 bytes, and contains +no NUL or ASCII control. The producer applies the same rules. A winning candidate +that cannot be projected becomes `winner_not_renderable`. + +Aggregate overflow has one exact server outcome. The producer first constructs and +measures the complete canonical projection. If it exceeds 8 MiB, it transactionally +converts every `winner` result in that auction to +`failed{reason:'winner_not_renderable'}`, emits no projected winner bids, and retains +the existing no-bid/failed results in request order. For `/auction`, the corresponding +TS winner bids are likewise absent from `seatbid`; no unmatched decision or bid is +emitted. The reduced projection is guaranteed to fit from the 256-result/id bounds. +Initial HTML, page-bids, and direct response production use this same all-winners +rule, never a completion-order or first-fit subset. The aggregate measurement includes +the complete ordered placement vector for browser projections. Boot rejects any independently +malformed or oversized value as `abi_mismatch`; page-bids and direct response +admission reject it transactionally as `invalid_response` with no partial slot, +reservation, targeting, or bid state. + +Wrong type, nonfinite, fractional, zero, or negative render dimensions are +`invalid_dimensions`; an otherwise integral dimension outside 1–4096 is +`dimensions_out_of_range`. This exact distinction and range apply in the Rust +producer, TypeScript projection/source parser, ES5 APS renderer validator, +programmatic banner sizes, PUC/renderer DOM construction, and every +CSS/attribute layout assertion. No adapter may clamp an accepted dimension. + +Each provider response is normalized internally to exactly one +`ProviderSlotOutcome`—candidate, no-bid, or typed failure—for every slot dispatched +to that provider. A successful response that omits a dispatched slot is a provider +no-bid; launch, transport, timeout, HTTP, parsing, validation, and mediation errors +are failures for the affected dispatched slots. Final aggregation is deterministic: + +1. Pre-dispatch gating returns `auction_disabled`, `consent_denied`, or + `slot_not_eligible` directly. A slot with zero eligible providers is exactly + `failed{reason:'slot_not_eligible'}`, never a no-bid. Provider currency rejection + is `invalid_provider_response`; there is no separate render-layer currency reason. +2. A selected deliverable candidate is `winner`, even if another provider failed. +3. Failure to mint a unique renderer reservation for the selected candidate is + `failed{reason:'identity_generation_failed'}`. Any other failure to validate or + project the selected candidate is `failed{reason:'winner_not_renderable'}`. +4. With no winner, the slot is `no_bid` only when at least one provider was + dispatched and every eligible/dispatched provider + completed successfully with no candidate. +5. With no winner and any provider or mediation failure, the slot is `failed` with + the first applicable reason in this closed priority order: `internal_error`, + `mediation_failed`, `invalid_provider_response`, `provider_error`, + `provider_timeout`, `consent_denied`, `auction_disabled`, `slot_not_eligible`. + `winner_not_renderable` and `identity_generation_failed` are selected directly by + rule 3 and do not participate in multi-provider priority. Completion order is + irrelevant. + +`/auction` keeps ordinary OpenRTB winners in `seatbid` and places the decision set +at `ext.trusted_server.slot_results`. Every TS winner bid has this exact nested +extension; unknown keys inside `trusted_server` are invalid: + +```ts +interface TrustedServerOpenRtbBidExtV1 { + candidate_id: string + slot_id: string + render_source: BidRenderSourceV1 +} +``` + +For an APS/ADM source, the bid's standard `id` is the server-minted renderer +reservation id from §2.2. A current-main `pbs_cache` source retains its cache-id +identity instead and never enters the reservation service. The provider's upstream +id remains provenance and, for APS, the descriptor's `bidId`. The bid's standard `impid` must equal the request impression id mapped to +`slot_id`. A winner decision joins by exact `slot`, `candidateId`, `impid`, and +`slot_id` to exactly one bid. A no-bid or failed decision joins none. Missing, +duplicate, extra, or mismatched joins make the entire response invalid to TSJS. +TSJS renders only `render_source`; it never infers a source from standard OpenRTB +fields. If a bid also carries standard `adm`, it is permitted only for an ADM source +and must equal `render_source.adm` byte-for-byte; an `adm` on APS/cache or a mismatch +is invalid. `/_ts/page-bids` returns `BrowserAuctionProjectionV1`, and initial HTML +stores that same value at `tsjs.boot.auctionProjection`. They do not carry a second +legacy `{slots,bids}` interpretation. The deprecated `/__ts/page-bids` endpoint and +its JS fallback are deleted at cutover. + +### 3.3 APS response admission + +APS response handling is deterministic and reports typed local drop reasons: + +- upstream bid id is required, bounded to 64 UTF-8 bytes, and unique within the + provider response; +- malformed `contextual`, missing `creativeurl`, invalid tag type, invalid URL, + invalid dimensions, disallowed script, and malformed price are rejected per bid + where safe; one bad bid does not discard unrelated valid bids; +- dimensions must exactly match one requested size; values are never clamped; +- script creatives remain default-off and require an explicit security-approved + setting; iframe creatives remain supported by default; +- the validated AAX projection used in `aaxResponse` is derived from the accepted + bid, not re-parsed from a later lossy structure. + +Drop reasons must remain visible in the existing debug/log surfaces for all auction +entry points. This is not a new external telemetry contract. + +APS configuration accepts only canonical string `account_id`. Integer account IDs, +`pub_id`-only configuration, and mixed `account_id` + `pub_id` shapes are rejected at +the hard cutover; unknown APS configuration fields are not silently ignored. + +Deployment is ordered so the old serving binary receives the canonical configuration +first: replace `pub_id` with quoted string `account_id`, quote numeric identifiers, +remove legacy and unknown APS keys, run `ts config validate`, and push while the old +binary that accepts `account_id` is still active. Only then deploy the new binary. +No alias or coercion is added to make an out-of-order deployment succeed. + +### 3.4 Mediation provenance + +This work preserves the configured mediator's existing candidate selection and +timeout fallback behavior. It changes only the unsafe reconstruction boundary for +renderer-bearing source candidates: + +1. Candidate provenance is `(provider_name, upstream_bid_id)`. Missing, duplicate, + or oversized upstream ids are rejected. +2. Every candidate receives a response-unique, opaque, server-minted 12-character + base64url `candidate_id` from 9 CSPRNG bytes. Generation retries a + response-local collision at most eight times, then fails the affected slot with + `internal_error`; the id is never an ordering key. +3. The mediation request carries it only at + `ext.trusted_server.candidate_id`. A mediator-selected source candidate must echo + exactly one known id. Missing, unknown, or duplicate echoes are + `mediation_failed` and cannot borrow render data from another candidate. +4. The resolved candidate takes only the mediator-selected price and existing + selection metadata from the mediator. Provider, upstream id, render source, + dimensions, currency, and notifications come from the stored source candidate. + Mediator-native render sources are rejected as out of scope. +5. Direct no-mediator selection keeps the repository's current highest-CPM rule; + an exact CPM tie is resolved deterministically by provider name and upstream bid + id. An APS bid explicitly declaring a non-USD currency is invalid. This design + adds no auction currency or winner-selection configuration requirement. + +### 3.5 Publisher projection and GAM targeting + +The publisher bid projection carries `renderSource: BidRenderSourceV1` intact on +every path, plus the exact `candidateId` and, for APS/ADM only, +`rendererReservationId`. Initial HTML +stores the document-generation input at +`tsjs.boot.auctionProjection: BrowserAuctionProjectionV1`. The initial +`NavigationSession` seeds its internal current projection from that immutable boot +value; an SPA page-bids response replaces only the new session's internal projection +through the transaction in §2.5. It never mutates `tsjs.boot`. A winner decision +must join exactly one projected bid and a no-bid/failed decision must join none. +Every decision also joins its exact ordered `slots` placement. Static placement +targeting is applied first, bid targeting overrides a duplicate static key, and the +runtime alone synthesizes `hb_adid` from `rendererReservationId` for APS/ADM or the +native cache UUID for `pbs_cache`; neither server targeting object may provide that +key. +Targeting uses the APS/ADM reservation id or the current-main PBS Cache UUID according to +the discriminated source and never truncates a value to fit GAM. +If the chosen value cannot satisfy the 40-character targeting limit, the bid is +rejected before targeting with an explicit local reason. + +Targeting cleanup is owner-and-value checked, not value-only. One runtime-owned +journal stack per physical GPT slot and targeting key records an internal owner id, +the exact installed string, and the predecessor value/owner for every TS write. +The sole GPT adapter observes each live slot's `setTargeting` and `clearTargeting` +calls and uses a closure-private reentrancy marker for TS-originated writes. Before +forwarding any publisher-originated mutation it invalidates the affected key's TS +restoration chain—or every chain for clear-all—regardless of whether the publisher +writes the same string. This bookkeeping never changes the publisher call's +arguments, return, throw, or order. +Before a write, a mismatch between the actual GPT value and the current TS frame +means publisher code changed the key; the runtime drops its restoration chain and +preserves that publisher value. Otherwise the new attempt pushes a distinct owner +frame before setting the value, even when its string equals the predecessor's. + +Supersession, empty render, terminal failure, and navigation disposal mutate GPT +only when the disposing frame is current owner and the actual string still equals +its installed string. A current provisional frame then restores its immediate live +predecessor, or the original publisher value/absence. Disposing an older frame below +a newer owner performs no GPT write and rebases the successor to the removed frame's +predecessor. Acceptance promotes the new attempt's frames into its +`CommittedRenderArtifact`; disposal of the prior accepted artifact uses that same +non-top rebase. Thus two generations installing an identical string remain distinct: +newer success cannot be cleared by older disposal, newer failure can reveal the +still-live older value, and a publisher mutation is never overwritten. + +Publishing a TS-owned PUC bid is one ordered transaction. The browser first +validates the winner, tagged source, slot join, and server-minted reservation id and +prepares all targeting/bid objects without exposing them. It then inserts the +reservation as live in the bounded store. Store capacity or collision therefore +fails before a creative can observe the id. + +For SSAT/page-bids, only after successful insertion may it expose that same id as +GAM `hb_adid`, publish other targeting, record GPT intent, and invoke a +request-capable GPT operation—in that order. Any failure before request invocation +tombstones the reservation, compare-restores targeting, and settles the attempt. + +The composition resolves each projected `divId` to the exact element first, then to +one unambiguous responsive/hydrated prefix match; container-shell aliases are not +treated as creative roots and ambiguity fails `slot_unresolved`. If GPT already owns +exactly one live slot for the resolved element, the slot service adopts that publisher +object and publishes with `refresh`. Otherwise the sole GPT adapter performs a +transactional `defineSlot`/`addService`/adoption and publishes with `display`. +Staleness destroys the unadopted candidate, and no path may leave a second physical +slot. Initial boot and every successfully committed page-bids replacement use this +same publisher. `pushState`, `replaceState`, and `popstate` share pathname-plus-query +identity; identical routes are suppressed, a current failed/rejected response rolls +back to the last committed path so the same route can retry, and an older response +cannot roll back or publish over a newer navigation generation. + +For the Trusted Server Prebid adapter, the supported artifact is the content-addressed +external bundle built from exactly lockfile-resolved Prebid.js 10.26.0. The external +artifact contains no Trusted Server auction, admission, render, or refresh behavior. +The TS-owned `PrebidAdapter` exposes one internal version-pinned +`admitTrustedBid(preparedBid)` boundary: + +```ts +interface PreparedTrustedBid { + readonly auctionId: string + readonly adUnitCode: string + readonly bid: Readonly<{ + requestId: string + adId: string + cpm: number + width: number + height: number + ad: '' + ttl: 300 + creativeId: string + netRevenue: true + currency: 'USD' + bidderCode: string + meta: Readonly<{ + advertiserDomains: readonly string[] + tsAuctionId: string + tsBidId: string + tsAdmHash?: string + }> + }> +} + +interface PrebidAdapter { + admitTrustedBid( + preparedBid: Readonly + ): 'admitted' | 'not_admitted' +} +``` + +All strings and numbers already satisfy their canonical auction/projection bounds; +`adId` is the exact admitted `r1_` reservation, and no renderer descriptor, ADM, cache +coordinate, or other capability crosses this boundary. The empty `ad` is deliberate: +the TS bridge resolves the already-stored tagged render source only after the PUC +claim. The adapter owns the bound Prebid object, registered `trustedServer` bidder +adapter, and the exact 10.26.0 response-admission callback; neither the external +artifact stamp nor publisher code exposes this method. + +All validation, reservation insertion, exact replacement of the TS bid's `adId`, and +frozen bid-object construction complete before the call; no targeting or other +publisher-visible mutation precedes it. The boundary returns exactly +`admitted | not_admitted`, and the 10.26.0 artifact fixture proves `not_admitted` +leaves no bid/event/targeting state. `not_admitted` tombstones the reservation and settles +`failed{reason:'prebid_admission_failed'}`. A throw settles the same failure; detected +partial publication tombstones the id, suppresses every later PUC request, settles +`failed{reason:'prebid_contract_violation'}`, and fails the artifact-conformance +gate. Runtime handling is fail-closed even though the same violation blocks future +release. A Prebid version change requires a reviewed contract-fixture update. + +`admitted` moves the reservation into an `awaiting_prebid_selection` state; it does +not create a render attempt or transfer permanent ownership. The Prebid adapter's +early synchronous `auctionEnd` listener uses the supported artifact's exact +auction-id/ad-unit winner query before publisher targeting callbacks. It promotes +only the exact selected TS `adId` to a render attempt and atomically tombstones every +other TS reservation admitted for that auction/ad unit as unselected. A ten-second +watchdog from admission performs the same tombstoning and records +`prebid_selection_timeout` if no matching `auctionEnd` arrives. Navigation or auction +abort tombstones the whole admitted set immediately. Subsequent GPT/render failure +follows the normal terminal lifecycle for the selected reservation. A fast Universal +Creative request can never race ahead of reservation lookup, and a losing bid cannot +hold capacity until the 15-minute tombstone expiry as a live entry. + +### 3.6 Static renderer and APS runner proxy endpoints + +`/integrations/aps/renderer/v1` and `/integrations/aps/runner.js` are always-reserved +Trusted Server routes. When APS is enabled, `GET` returns the local static renderer +or proxies the APS-hosted creative runner respectively. When APS is disabled, `GET` +returns a local `404 no-store`; neither route ever falls through to a publisher +origin. The family is dispatched before publisher auth, EC, and generic integration +filters. All adapters expose the same method, routing, security-header, and failure +semantics. Unsupported methods return local `405` with `Allow: GET`; unknown +renderer versions and the abandoned `/integrations/aps/runner/v1.js` shape return a +local `404 no-store`. + +Because the two routes are loaded by browser documents, they are intentionally +anonymous. A configured `[[handlers]]` pattern that matches +`/integrations/aps/*` does not apply and must never be represented as protecting the +renderer or runner. Operator-required admission control, rate limiting, and request +shielding live at the deployment platform. + +The renderer v1 body and headers are immutable and served with a long-lived immutable +cache policy; a renderer-body or CSP semantic change requires a new renderer route +version. The document is static and contains no descriptor data. Its iframe +`sandbox` attribute and response CSP `sandbox` directive contain exactly this token +set, serialized in this order: + +```text +allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation +``` + +They omit `allow-same-origin`, `allow-top-navigation`, downloads, modals, +presentation, orientation lock, and storage-access escape. The exact renderer v1 CSP +header is: + +```text +default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' 'self' https:; connect-src https:; frame-src https: data: blob:; img-src https: data: blob:; media-src https: data: blob:; style-src 'unsafe-inline' https:; font-src https: data:; worker-src https: blob:; form-action https:; +``` + +The broad HTTPS resource directives are confined below the opaque outer sandbox and +are required for APS and bidder resources; `'self'` permits the local runner proxy in +HTTP-based hermetic adapters as well as production HTTPS. The response also has +exactly `Content-Type: text/html; charset=utf-8`, +`Cache-Control: public, max-age=31536000, immutable`, +`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. It deliberately +omits both `X-Frame-Options` and a CSP `frame-ancestors` directive so publisher and GAM +creative ancestors can embed it; the iframe/CSP sandbox, nonce, source-bound port, +and descriptor validation are the embedding boundary. It does not forward publisher +credentials or authorization in TS-owned fetches. Runner-created APS creative +resources may use APS-origin cookies under ordinary browser policy. No cookie is +authority. Any header or CSP change requires renderer v2; policy reporting is a +separate observability design. + +The runner endpoint is a thin runtime transport proxy, not an artifact store. Its +only upstream is the fixed APS URL +`https://client.aps.amazon-adsystem.com/prebid-creative.js`; no request field, +configuration value, query parameter, or header can select another target. Trusted +Server issues a credential-free, referrer-free `GET`, does not forward browser +cookies, authorization, client IP, or publisher headers, requests +`Accept-Encoding: identity`, and disables redirect following in the platform client. + +The platform HTTP abstraction exposes an internal `ProxyResponseEvidenceV1` policy +used only by this route. It preserves upstream status and security-relevant evidence +for every occurrence of `Content-Type`, `Content-Encoding`, and `Content-Length` +before the generic proxy removes headers or consumes the body. An adapter that +exposes duplicate fields separately returns every raw value. An adapter runtime that +combines duplicates may return its exact combined value only when the combination is +visible and cannot be mistaken for a valid singleton; the closed grammars below +reject any comma/list form for all three headers. It is forbidden to split a combined +value or normalize it into an apparently valid singleton. All other upstream headers +are irrelevant because the successful response drops them. + +The policy requests identity encoding, prevents redirect following, and returns the +identity body as a bounded stream or bounded buffer without transforming bytes. +Cloudflare inspects the initial Workers `Headers` values before the generic adapter +strips encoding/length; concatenated duplicate values remain combined and therefore +fail the singleton grammars. If the runtime erases encoding evidence or decodes a +non-identity response without exposing that fact, the evidence is `unavailable` and +the proxy rejects it. Fastly and Axum apply the same evidence/no-decompression +contract. No adapter may reconstruct erased evidence. + +The entire upstream operation—from dispatch through the final response-body byte—has +a five-second monotonic deadline. Deadline expiry cancels the platform pending +request, discards every collected byte, and makes any unavoidable late platform +continuation self-discard without constructing a response. It returns the same local +failure as any other proxy error and cannot outlive the ten-second APS-completion +deadline. Spin does not use its current eager `spin_sdk::http::send` path for this +policy: it calls the WASI HTTP outgoing handler with supported request options and +polls the response/body stream against a monotonic-clock pollable for the total +deadline. Failure to set the transport timeouts or cancel/drop the pending resources +is `unavailable` evidence and prevents APS from being enabled on that build. + +The proxy accepts only status `200`. `Content-Encoding` must be absent or exactly one +case-insensitive `identity` token; lists and every other coding are rejected. Exactly +one parseable `Content-Type` header is required. Its case-insensitive essence must be +`application/javascript` or `text/javascript`, with either no parameters or only one +case-insensitive `charset=utf-8` parameter; duplicate or unknown parameters and every +other media type or charset are rejected. The exact body cap is +`APS_RUNNER_MAX_RESPONSE_BYTES = 8 MiB` on every adapter. If exactly one canonical +decimal `Content-Length` matching `^(0|[1-9][0-9]*)$` is present, it is rejected +before collection when above the cap and must equal the final identity-body byte +count. Missing length is allowed; duplicate, malformed, mismatched, or conflicting +values fail. Collection stops and cancels the request as soon as streamed or buffered +bytes exceed the cap. The complete body must decode as UTF-8 without replacement; +validation never transforms the bytes. + +A successful response relays the upstream body bytes unchanged while replacing +transport headers with +`Content-Type: application/javascript; charset=utf-8`, +`Access-Control-Allow-Origin: *`, +`Cross-Origin-Resource-Policy: cross-origin`, +`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. Upstream +cookies and all other response headers are dropped. Upstream fetch, status, media +type, content encoding, redirect, length, size, or deadline failure returns a local +empty `502 no-store`; vendor bodies are never exposed in logs or error responses. +The adversarial parity corpus executes through each real adapter transport, including +Cloudflare and Spin wasm, rather than injecting already-normalized core responses. An +adapter that cannot preserve the raw evidence, common cap, or total deadline cannot +enable APS and blocks the release. + +No APS runner bytes, digest, vendor-version record, redistribution license, update +script, generated artifact, or offline fallback is stored in Trusted Server source or +release artifacts. The runner route is live, unversioned, unvendored, unpinned, and +uncached by Trusted Server because it represents a mutable APS-owned dependency, not +immutable TS-owned bytes. A successful relay adds no Trusted Server `Cache-Control` +requirement. Platform shielding may bound upstream exposure operationally, but it +cannot turn the runner into a repository or release artifact. + +Proxying does not make the runner trusted TS code or prove that it rendered. APS +remains the runtime owner of those executable bytes and its resolve/reject semantics +are a narrow external trust dependency. The outer opaque iframe, validated +descriptor, one-shot lifecycle port, and completion deadline contain execution and +reject missing, late, or misbound signals; they cannot determine whether APS told the +truth when it invoked `resolve`. The proxy never rewrites, inspects, or repairs the +JavaScript body. + +The renderer accepts one parent-provided, nonce-bound descriptor plus the publisher +origin captured by the kernel before iframe creation. Because its opaque origin has +`location.origin === "null"`, it validates the supplied origin's shape and uses it +only to repeat the descriptor's not-publisher-origin check. It clears the nonce from +its URL and implements the TS side of `ApsRunnerContractV1`. Before runner load, it +creates a one-shot Promise and queues exactly: + +```ts +new CustomEvent('prebid/creative/render', { + detail: { + aaxResponse: renderer.aaxResponse, + seatBidId: renderer.bidId, + source: 'internal', + resolve, + reject, + }, +}) +``` + +`resolve` and `reject` are the Promise's one-shot functions and are the only +non-serializable fields. The renderer resolves `/integrations/aps/runner.js` against +its own absolute Trusted Server document URL and loads only that route. It creates the +script with `crossOrigin='anonymous'` and `referrerPolicy='no-referrer'`; it does not +set SRI because the proxy relays a live APS-owned artifact rather than immutable +TS-owned bytes. Under the APS conformance contract, the runner consumes the queued +event and promises to call `resolve` only after its asynchronous handler commits the +nested creative iframe, and to call `reject` for validation, load, or render failure. +Promise resolution sends one completed result; rejection, proxy/CORS error, runner +error, or script-load error sends one failed result over the transferred lifecycle +port. Runner `load` is intermediate progress only. The static renderer owns no APS +completion timer. Proxy/CORS/script-load failure maps to `runner_no_load`; callback +rejection maps to `runner_failed`. + +A locally authored fictional fixture implements the exact proxy and +queue/resolve/reject behavior; it is not a copy, transformation, or derivative of the +APS body. Real-GAM tests exercise the live proxied APS dependency in all three +browsers and are a release prerequisite. The APS runner is allowed to change +upstream. Load failure, explicit rejection, and silence fail closed through the +existing APS-completion deadline. A changed or compromised APS runner can invoke +`resolve` prematurely or incorrectly; this is an accepted external-dependency risk +that the outer renderer cannot detect. Real-browser DOM/network conformance reduces +but does not eliminate it. V1 never loads the APS URL directly from the browser, +executes a stored fallback, treats script `load` as completion, or uses a reusable +global `postMessage` acknowledgement. + +## 4. Render lifecycle protocol + +The **ActiveRenderOwner** is one logical owner with non-overlapping agent and +persistent epochs. On an eligible initial page, the agent epoch instantiates the +§4 dispatcher, reservation/ticket/nonce registries, terminal latches, provisional +GPT adapter/listeners, and APS/ADM channels for only the immutable projected batch. +Every “kernel” or “runtime” action in this section is performed by that active epoch. +At §5.2.1 takeover, all attempts and ports are terminal, tombstones/counters/facts +cross in the exact handoff, live physical/committed objects cross only in the +one-use capsule, and fresh persistent dispatcher/GPT listeners become the sole +active epoch. On a no-agent page, persistent core is the first and only epoch. + +The agent records the correctness-required GPT `slotRequested` and +`slotRenderEnded` facts before issuing the initial request. When diagnostics are +enabled it also records all six bounded §5.8 observations, trace tokens/cycles, and +overflow counters into the handoff; persistent diagnostics adopts and replays those +facts before live delivery. No diagnostic listener is counted twice and loss of a +diagnostic fact cannot change lifecycle authority. Any §4 wording that assigns the +initial dispatcher/channel exclusively to “core” means the current +`ActiveRenderOwner`, not necessarily the post-paint persistent artifact. + +### 4.1 State machine + +```text +created + -> no_bid | waiting_for_gam_and_claim | rendering_direct | failed | cancelled +waiting_for_gam_and_claim + -> waiting_for_owner | failed | cancelled +waiting_for_owner + -> waiting_for_insertion | failed | cancelled +waiting_for_insertion + -> waiting_for_document | waiting_for_adm | failed | cancelled +rendering_direct + -> waiting_for_document | waiting_for_adm | failed | cancelled +waiting_for_document + -> waiting_for_aps_completion | failed | cancelled +waiting_for_aps_completion + -> accepted | failed | cancelled +waiting_for_adm + -> accepted | failed | cancelled +``` + +`no_bid` is terminal and is valid only as `created -> no_bid`: it records the exact, +successfully parsed server decision that the slot has no winner before any render path +starts. It is invalid from every later state; GAM empty, transport, timeout, parse, +descriptor, and renderer failures retain their explicit failure outcome. +`failed` and `cancelled` remain valid from `created` so an auction child can settle +when its exact server decision fails or its caller/batch/navigation cancels before a +render path begins. + +Transitions are methods on `RenderAttempt`, not ad-hoc flag mutation. Each method +checks the expected state and terminal latch. Timers are created at the transition +whose deadline they enforce and are cleared by the transition that settles them. +`waiting_for_document` accepts only an APS source; `waiting_for_adm` accepts only an +ADM source. A redesigned direct path stages only a `direct_iframe` artifact, while an +owner-controlled PUC path stages only a `puc` artifact. +The current-main PBS Cache path remains outside this new attempt-state expansion and is +covered by §4.5 parity tests. +Construction owns an already-issued attempt scope: every rejection after scope +issuance best-effort disposes that exact scope so session indexes cannot retain a +failed construction or block a same-slot retry. + +An accepted transition first atomically promotes durable DOM/targeting ownership +from the attempt into one `CommittedRenderArtifact` owned by the exact slot and +navigation. The attempt disposer removes only uncommitted resources; promotion +detaches the committed iframe, targeting snapshot, and physical-slot metadata before +the terminal latch disposes the attempt. Direct TS iframes are removed by artifact +replacement or navigation disposal. PUC content remains owned by its physical GPT +slot: for a TS-owned slot the artifact may dispose it only through safe GPT +destroy/redefine, while publisher-owned slot DOM remains publisher-controlled and +the artifact releases only TS metadata and compare-restorable targeting. Before a +slot publishes another accepted artifact it disposes the prior artifact. Navigation +disposes artifacts according to those same ownership/quarantine rules. Claim, +registration, owner-control, and renderer-document ports/listeners that are no +longer needed close after terminal settlement and are never promoted. +Artifact disposal is synchronous and exact-once. A disposer that throws, returns a +Promise/thenable, or otherwise violates the synchronous contract fails closed and +cannot authorize publication of a replacement or later republication of the disposed +artifact object. + +### 4.2 Universal Creative claim + +The supported GAM creative selects Prebid Universal Creative 1.17.2 outside the +Trusted Server source tree, never `latest` or a publisher-selectable version. No PUC +bytes, checksum, or distributable artifact is vendored into this repository. Its +cross-domain request is a JSON +string decoding to exactly +`{message:"Prebid Request",adId,adServerDomain}` and carries exactly one transferred +response port. All three values are strings; `adId` and `adServerDomain` are +nonempty. Object-form or extended payloads are rejected. Universal Creative owns +this shape, so it cannot carry a TS nonce. Hermetic unit and browser tests exercise a +locally authored contract harness limited to that public message/helper behavior; +the pre-production real-GAM conformance gate exercises the actual PUC release. The +protected page's frozen test API exposes `pucRelease`, and the gate accepts only the +exact value `1.17.2`; a missing, publisher-selected, or `latest` value blocks cutover. + +The bridge is one capture-phase dispatcher installed as the first reversible core +effect in the synchronous activation barrier, before any integration-module +activation and before any TS-owned GPT or Prebid script injection. This guarantees +it precedes native non-capture listeners installed later by TS while leaving no +dispatcher active during asynchronous preparation; +publisher capture listeners that already ran are inside the publisher trust +boundary. The runtime's dispatcher owns both the initial-request branch below and +the owner-registration branch in §4.3; no second global listener exists. It performs +this order: + +1. Perform only minimal, side-effect-free recognition. For a JSON string or a + clone-safe plain object, inspect own data properties only and extract string + `message`, `adId`, and an optional string `lifecycleTicket`. Do not read accessors + or traverse prototypes. Malformed or unrecognizable data is ignored. +2. Route `message === 'TS Render Owner Register'` to §4.3. If + `message !== 'Prebid Request'`, ignore it. For `Prebid Request`, look up the + extracted `adId` in the global reservation/tombstone store before exact parsing, + port, or slot-local checks. +3. For a non-TS id, do not suppress native Prebid. For a live or tombstoned TS id, + immediately call `stopImmediatePropagation()` so an extended/object-form request, + wrong port count, stolen capability, or replay cannot fall through. +4. Only after suppression, require the supported exact JSON-string shape and exactly + one transferred port. A recognized TS id with invalid shape/port is generically + refused when a usable port exists; all available ports are closed. It never + reaches native Prebid. +5. The first exact live claim during the compatible active GPT cycle acquires the + authoritative PUC `WindowProxy` from `MessageEvent.source` and stores that source + with its response port. This is the only source acquisition step; SafeFrame + ancestry is neither inspected nor guessed. Later owner messages must come from + this exact source. The unguessable reservation id, exact active slot/generation, + and one-time consumption authorize the first claim. Same-realm publisher code is + already inside the documented trust boundary. +6. Join the buffered claim with an attributable nonempty `slotRenderEnded`. A claim + that arrives first is bounded by the owning GPT-cycle/attempt deadline, discloses + no render data, and holds only its source and port. A nonempty GAM result that + arrives first starts a three-second claim deadline. Only when both conditions are + true does the runtime atomically revalidate and consume the reservation. +7. Empty GAM, navigation disposal, supersession, an incompatible cycle, or the + attempt deadline closes a buffered port, tombstones the reservation, and settles + the attempt. A second claim is generically refused; it never replaces the first. + +The successful outer response is an exact JSON string: + +```ts +{ + message: 'Prebid Response' + adId: string + renderer: string + rendererVersion: '3' + tsOwner: { + version: 1 + status: 'ready' + kind: 'aps' | 'adm' + lifecycleTicket: string + } +} +``` + +`renderer` is the checked-in TS dynamic-owner program. `lifecycleTicket` is +`t1_` plus 22 unpadded base64url characters from 16 CSPRNG bytes, bound to the +attempt, generation, source, and reservation, with a fixed three-second TTL from +posting the outer response. No descriptor or ADM appears in the outer response. A +recognized TS id that cannot be served receives the same response shape with +`tsOwner:{version:1,status:'refused'}` and no other `tsOwner` keys; the dynamic owner +rejects immediately, causing PUC to emit its ordinary `adRenderFailed`. If no usable +response port exists, the listener can only suppress and close available ports. +The claim deadline expiry is `bridge_claim_timeout`. + +### 4.3 Owner-control registration + +PUC executes a dynamic renderer in a hidden `__pb_renderer__` iframe, so a global +message posted by that hidden frame cannot satisfy source binding. The TS owner must +instead call PUC's supplied `h.sendMessage(type,payload,onResponse)` helper. PUC +adds `adId`, serializes the request, sends it from the original PUC frame, and +creates the response channel. + +The owner calls: + +```ts +h.sendMessage( + 'TS Render Owner Register', + { version: 1, lifecycleTicket }, + onRegistrationResponse +) +``` + +The kernel therefore receives exact JSON +`{message:"TS Render Owner Register",adId,version:1,lifecycleTicket}` from the +captured PUC `WindowProxy` plus exactly one helper-created response port. It +atomically consumes a live ticket and checks the exact `adId`, source, attempt, and +generation. Success posts exact JSON +`{message:"TS Render Owner Registered",adId,version:1,lifecycleTicket}` on that +response port and transfers exactly one newly-created owner-control port. Refusal +posts exact JSON `{message:"TS Render Owner Refused",adId,version:1}` with no +transferred port. These are the only registration responses. + +This message is received by the same capture-phase dispatcher from §4.2. Its +registration branch first looks up the minimally extracted `lifecycleTicket` in the +runtime ticket/tombstone map. An unknown ticket is ignored for native/publisher +listeners. For a live or tombstoned TS ticket it immediately calls +`stopImmediatePropagation()` before exact JSON-shape or port validation, then checks +the captured source, exact `adId`, ticket, attempt, generation, and exactly one port. +A recognized invalid/replayed request is generically refused when a usable port +exists and all available ports are closed. Ticket settlement, consumption, or +attempt disposal replaces its live entry with a tombstone through the original fixed +ticket expiry; expiry prunes either live or tombstoned state. The dispatcher itself +is runtime-owned; attempt disposal performs that registry transition and removes +attempt handlers, not the global dispatcher. + +The owner starts a three-second watchdog before invoking `h.sendMessage`, calls the +helper's returned stop-listening disposer after the first response, and rejects on +timeout, refusal, malformed data, or a port count other than one. The kernel owns +the opposite control port and the owner owns the transferred port. Replay, wrong +source, wrong port count, stale generation, or expiry cannot bind a channel. Kernel +and owner each have a terminal latch; late registration, acknowledgement, +settlement, or watchdog callbacks close their ports and remain inert. + +### 4.4 APS document and runner acknowledgement + +After registration, the kernel posts this exact control message and transfers +exactly one renderer-document port: + +```ts +{ + message: 'TS APS Start' + version: 1 + lifecycleTicket: string + rendererUrl: string + envelope: { + version: 1 + nonce: string + publisherOrigin: string + renderer: ApsRendererV1 + } +} +``` + +The kernel owns the opposite document port. The owner creates exactly one iframe at +the versioned renderer URL with the nonce in its fragment, reports exact +`{message:"TS Owner Inserted",version:1,lifecycleTicket}` on the control port, and +on iframe load transfers the envelope and document port once to that exact +`contentWindow`. Direct APS uses the same document channel and envelope but has no +PUC owner-control channel. The nonce is 128-bit CSPRNG, attempt-bound, and one-use. + +The enforceable direct-path binding is to one TS-created native iframe element, its +unchanged `src` attribute, its browsing-context `WindowProxy`, and the one-use port; +it is not browser attestation of the active opaque `Document`. Code executing in an +embedding ancestor realm with DOM/navigation authority is trusted for this one +navigation-integrity property. Such code can assign +`iframe.contentWindow.location` without changing the iframe `src`, while the same +`WindowProxy` survives and the opaque active document's URL and origin remain +unreadable to the kernel. If it does so before handoff, that replacement document +can receive the descriptor, nonce, and port and can forge the page-local document +and completion messages. Native element creation plus exact parent/source/`src` +checks still reject publisher-supplied frames, node replacement, removal, detectable +`src` mutation, unrelated contexts, and stale ports; they make no claim about the +undetectable ancestor-navigation case. APS has no synthetic notification or other +trusted remote side effect derived from page-local completion. + +Removing that trust boundary requires a separately operated renderer origin, adding +`allow-same-origin` only for that cross-origin document, and using exact +`targetOrigin`/`event.origin` checks. Adding `allow-same-origin` to the current +publisher-origin renderer would defeat containment when combined with scripts, so +that is not an acceptable implementation of this design and a dedicated-origin +variant requires a separate architecture decision. + +The static document sends only these exact document-port messages: + +- `{message:"TS APS Document Accepted",version:1,nonce}` after nonce and descriptor + validation; +- `{message:"TS APS Runner Loaded",version:1,nonce}` when the runner script loads, + as nonterminal progress; +- `{message:"TS APS Render Completed",version:1,nonce}` when the queued APS render + event invokes its one-shot success callback; +- `{message:"TS APS Render Failed",version:1,nonce,reason}` where `reason` is + `descriptor_invalid | runner_no_load | runner_failed`. + +Insertion has a one-second deadline, document acceptance has a three-second +deadline from iframe insertion, and the kernel is the sole owner of the ten-second +APS-completion deadline beginning at document acceptance. Callback silence at that +deadline maps to `runner_failed`; the static renderer never starts a competing +completion timer. Script load never accepts. Failure, timeout, port error, +supersession, or navigation disposal settles once. On a direct path the kernel +removes its exact pending iframe. On a PUC path the iframe is remote DOM owned only +by the dynamic owner; the kernel never claims it can remove that node and instead +posts exactly one owner-control settlement: + +```ts +type OwnerSettlementV1 = + | { + message: 'TS Owner Settled' + version: 1 + lifecycleTicket: string + outcome: 'accepted' + } + | { + message: 'TS Owner Settled' + version: 1 + lifecycleTicket: string + outcome: 'failed' + reason: RenderFailureReason + } + | { + message: 'TS Owner Settled' + version: 1 + lifecycleTicket: string + outcome: 'cancelled' + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed' + } +``` + +The PUC owner owns the remote node, its DOM handlers, and its side of the control +port. An accepted settlement promotes that exact iframe as committed, removes its +temporary handlers, closes the control port, and resolves the renderer Promise once. +A failed or cancelled settlement removes the exact uncommitted iframe, removes its +handlers, closes the port, and rejects once so PUC emits its ordinary render-failure +event. The same cleanup applies to the PUC ADM owner in §4.5. + +When registration accepts the transferred control port, before waiting for an APS or +ADM start message, the owner arms one fail-closed 20-second settlement/channel +watchdog. Start does not extend or rearm it. The deadline is longer than every +kernel-owned insertion/document/render deadline and also covers registration-to- +start loss. The owner cancels it on settlement. A malformed control message, +`messageerror`, local owner disposal, or watchdog expiry performs the failed/ +cancelled cleanup above and rejects once; a silently closed or lost control channel +is therefore bounded. This watchdog is remote resource cleanup only and cannot +report acceptance to the kernel or change its already-terminal outcome. A +settlement-post throw is isolated in the kernel because the remote watchdog owns +this failure path. + +A caller `AbortSignal` remains attempt-owned after owner registration. If it wins +the terminal latch, the kernel closes its renderer-document channel and sends the +exact cancelled/`caller_aborted` settlement; direct rendering also removes the +kernel-owned iframe. The PUC owner performs the remote cleanup just specified. A +later insert, load, document message, APS callback, settlement, or watchdog is inert. + +The winning descriptor dimensions are also the exact layout contract across all +three nested documents. Before inserting its renderer iframe, the PUC owner sets its +own document root and body to zero margin/padding with hidden overflow, then creates +one block iframe with matching positive width/height attributes and CSS pixels, zero +border, and no scrollbars. The static renderer document has the same zero +margin/padding and hidden-overflow root/body contract before loading the APS runner. +The runner-created descendant creative is expected to occupy the same viewport. A +300×250 winner therefore has 300×250 `clientWidth`, `clientHeight`, `scrollWidth`, +and `scrollHeight` in the PUC owner and renderer documents, and a 300×250 descendant +viewport, with no default eight-pixel body margin, clipping, or overflow. Equivalent +assertions run for every boundary fixture dimension. This is layout correctness, not +render completion; the callback contract above remains the acceptance authority. + +### 4.5 ADM ownership and cache non-regression + +Direct and PUC ADM use one TS-authored iframe constructor and this exact ordered +sandbox value: + +```text +allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation +``` + +It omits `allow-same-origin`, downloads, modals, presentation, pointer lock, and +storage-access escape. The iframe has `referrerPolicy='no-referrer'`, exact positive +source dimensions in both attributes and CSS pixels, zero border/margin, hidden +overflow, `display:block`, `scrolling='no'`, title `Ad content`, and aria-label +`Advertisement`. + +The constructor creates the iframe detached, installs one-shot `load`/`error` +handlers and disposal state, builds the complete ADM document, assigns exactly one +`srcdoc`, and only then appends the iframe once. It never appends an empty iframe, +sets `src`, or performs a TS-authored replacement navigation. A `load` is accepted +only when the exact frame is still the pending frame for the current attempt and +navigation generation, the intended `srcdoc` was assigned before append, and the +terminal latch is open. Initial `about:blank`, pre-assignment, removed-frame, +replaced-frame, stale-generation, post-disposal, duplicate, and error events cannot +accept. Error, removal before acceptance, or the five-second deadline fails +`adm_document_no_load`. Real-browser tests force initial-blank, replacement, +removal, error, timeout, supersession, and stale-load orderings. + +For PUC ADM, the trusted TS-authored owner—not bidder creative code—uses the +same registration protocol. The kernel sends exact +`{message:"TS ADM Start",version:1,lifecycleTicket,source:AdmRenderSourceV1}` on the +owner-control port with no transferred port. The owner reports +`TS Owner Inserted` only after the shared constructor appends exactly one owned +iframe, and reports exact +`{message:"TS ADM Loaded",version:1,lifecycleTicket}` or +`{message:"TS ADM Failed",version:1,lifecycleTicket}`. The kernel answers with one +`OwnerSettlementV1`; only that settlement resolves or rejects the PUC renderer +Promise. Insertion has a one-second deadline and load has a five-second deadline. +Direct ADM uses the same constructor and accepts through its own terminal latch on +the intended load. No secret or capability is injected into bidder-controlled +markup. + +PBS Cache is not routed through this new owner protocol. The GPT integration retains +the current-main cache request/parse/macro/PUC-response/collapsed-resize behavior +behind the runtime's generation check and disposer only. Cache success/failure, +identity, price selection, response shapes, and timing are not redefined here. The +black-box parity corpus runs the exact current-main fixtures before and after cutover and +fails any observable change; APS/ADM reservation ids are never accepted as cache +UUIDs and cache UUIDs never claim APS/ADM work. + +### 4.6 Channel ownership and parsing + +| Channel | Creator | Retained endpoint | Transferred endpoint | Lifetime | +| --------------------- | ------------------------- | ------------------- | ----------------------------------------- | ------------------------------------------------- | +| outer PUC response | PUC `prebidMessenger` | original PUC frame | kernel global listener | one ready/refused response or claim disposal | +| registration response | PUC `h.sendMessage` | original PUC helper | kernel global listener | one registered/refused response or owner watchdog | +| owner control | kernel after registration | kernel attempt | hidden TS dynamic owner | insertion through final owner settlement | +| renderer document | kernel before APS start | kernel attempt | exact static APS renderer `contentWindow` | document acceptance through APS completion | + +Global window messages are JSON strings with the exact keys specified above. Port +payloads are structured-clone objects with the exact keys specified above. Every +parser rejects accessors, wrong prototypes, unknown keys, wrong literal/version, +wrong port counts, oversized strings, and already-consumed capabilities before +performing a state transition. The disposer clears handlers, closes both locally +owned ports where possible, and makes queued callbacks generation-inert. + +The shared protocol corpus fixes these bounds and encodings: + +- before `JSON.parse`, an inbound global-dispatcher string is at most 4,096 UTF-8 + bytes; a larger value is unrecognizable and causes no property access or state + lookup; +- a TS `adId` is exactly the 25-character `r1_` reservation form; a lifecycle ticket, + renderer nonce, and attempt id are exactly the respective 25-character `t1_`, + `n1_`, and `a1_` forms from §2.2; +- `adServerDomain` is nonempty and at most 2,048 UTF-8 bytes. It is retained only for + exact PUC-shape conformance and is never a fetch target or authority; +- `publisherOrigin` and `rendererUrl` are at most 2,048 UTF-8 bytes. The former must + serialize an exact HTTP(S) origin with no path/query/fragment; the latter must equal + the current generation's absolute `/integrations/aps/renderer/v1` URL with no query + or fragment, after which the owner appends the exact `n1_` nonce fragment; +- a navigation/refresh generation is a nonnegative safe integer; and +- the generated dynamic-owner `renderer` program is at most 64 KiB UTF-8 and the + complete successful outer-response JSON is at most 72 KiB. Build tests enforce + both; refusal responses contain no renderer. + +Structured-clone port payloads use their exact field-level limits: APS descriptor +256 KiB decoded AAX; ADM 512 KiB; `creativeUrl` 4,096 UTF-8 bytes; +`publisherOrigin`, `rendererUrl`, and +`adServerDomain` 2,048 UTF-8 bytes; server slot id 1–256 UTF-8 bytes with no NUL or +ASCII control; and the fixed +capability forms above. No generic unbounded string remains. Boundary-minus-one, +boundary, boundary-plus-one, multi-byte UTF-8, duplicate-key, and malformed-encoding +cases run through the producer plus both the global dispatcher and port parsers. + +### 4.7 Notifications + +APS has no `nurl` or `burl`; none is synthesized. Existing notifications on other +bid formats remain nonblocking and exactly-once per accepted lifecycle transition. +Notification transport failure cannot change a render outcome. Redesigning or +measuring notification delivery is outside this spec. + +## 5. TSJS target architecture + +### 5.1 Layers + +```text +kernel/ boot, phase registry, queue, sessions, disposal, logging +adapters/ googletag, prebid, messaging +services/ slots, auction batches, render lifecycle, consent +integrations/ gpt, prebid, aps, creative, and existing publisher integrations +composition/ small production core plus test-only composition seams +``` + +- Kernel imports no adapter, service, or integration. +- Adapters import kernel contracts only and are the sole readers/writers of GPT, + Prebid, and cross-window messaging globals. +- Services import kernel and adapter interfaces. +- Integrations compose services and never import another integration. +- The production core entry imports only the kernel, immutable boot/projection + contracts, queue/logger, and the minimum direct-auction functions that the public + API requires before any integration exists. It does not import every adapter, + service, integration runtime, diagnostics UI, no-op implementation, or test hook. +- Each provider IIFE contains only the concrete adapter/service implementation it + owns; a consumer slice does not inline that implementation again. Every module + registers one inert factory. The kernel constructs one `RuntimeSession`, invokes + those factories transactionally, and retains their exact frozen interfaces in a + closure-private capability broker. An integration consumes only capabilities that + the embedded release catalog allows for that integration; it never imports another + integration or reads a public service locator. +- The capability broker admits one provider per exact key, rejects undeclared keys, + validates frozen exact own-data-property interface objects with no accessors, + unknown keys, or custom prototype, removes a provider on disposal, and + is never exposed on `window.tsjs` or `_internal`. A takeover provider must exist + as a staged result of its provider's preparation before any takeover consumer + prepares, and provider-before-consumer order is enforced by the catalog. A + deferred consumer may bind only an already committed takeover provider. +- Production and test composition entries are separate. Production output cannot + retain `*ForTest` accessors, injectable no-op adapters, corpus helpers, or fake + scheduler branches merely because tests need them. +- Layering is enforced by ESLint restricted paths. +- A custom scope-aware lint rule rejects GPT/Prebid global access outside adapters, + including same-file aliases of `window`, `globalThis`, `self`, `googletag`, or + `pbjs`. + +### 5.2 One runtime across IIFE bundles + +#### 5.2.1 Two non-overlapping ownership epochs + +The browser lifecycle has two ordered ownership epochs, never two independently +live runtimes: + +```text +bootstrap installing + -> first-display agent -> transferring -> persistent runtime + | \-> failed -----------------> fallback + \-> persistent runtime (no eligible server-projected batch) + \-> failed ---------------------------------------> fallback +``` + +The **first-display agent** is a release-owned provisional owner, not the legacy +`gpt_bootstrap.js` and not a reduced public runtime. It is one parser-blocking, +server-composed artifact containing only the fixed slices selected for the +immutable server projection and parser-time obligations on that page. It may: + +- validate the boot manifest and initial projection; +- install the one provisional GPT/message/guard interception needed before the + initial action; +- define, target, request, and settle only the server-projected initial GPT batch; +- render an APS or ADM winner through the exact §4 protocols, including an + attributable empty-GAM fallback for that batch; and +- record the first-action, terminal, and protected-paint marks. + +It cannot publish `TsjsApi`, expose a capability broker, accept programmatic ad +units or direct-auction calls, process SPA/page-bids work outside the immutable +initial batch, load deferred modules, present diagnostics, refresh an accepted ad, +or start later navigation/reconciliation. Publisher callbacks remain in the one +bootstrap-owned ingress Array until persistent-runtime or fallback commit. The old +bootstrap renderer, legacy API, and any second fallback runtime remain deleted. + +The server selects the agent only when every member of the projected initial batch +is representable by the first-display slices: no bid, GPT-mediated ADM, or +GPT-mediated APS/PUC (including its attributable empty-GAM fallback). PBS Cache, +programmatic/direct `/auction`, an unknown source, or any initial behavior outside +that closed set selects direct persistent boot instead; this design adds no new cache +implementation or cache-path phase split. A page with no projected initial batch also +uses direct persistent boot. Parser-time obligations join the agent only when that +agent is already selected; otherwise their takeover modules activate in the one +parser-blocking runtime artifact before upstream publisher activity. This is a +server-owned manifest choice derived from frozen +configuration and projection, not a publisher switch, experiment, or compatibility +path. A programmatic `requestAds` invoked on such a page becomes usable only after +the persistent runtime commits. The protected load-time claim applies to the +server-projected agent path; subsequent programmatic work remains correct but is not +relabeled as that measurement. + +The agent artifact has one base entry plus only these build-catalogued slices. A +slice absent from this table cannot enter the artifact. “Initial” means the exact +immutable batch; it does not authorize later work from the same product: + +| Slice id | Include iff | Bounded obligation before transfer | +| ---------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | +| `first_display` | an eligible initial batch exists | manifest/projection validation, provisional lifetime, timing, queue ingress, transfer coordinator | +| `aps_initial` | the initial GPT batch can contain APS | reservation, PUC owner protocol, APS document channel | +| `creative_initial` | an enabled creative guard has a parser-time obligation | current guard defaults and initial creative observation only | +| `datadome_initial` | DataDome is enabled | initial script/preload route guard | +| `didomi_initial` | Didomi is enabled | initial configured SDK-path installation | +| `google_tag_manager_initial` | Google Tag Manager is enabled | initial script/preload/beacon/fetch guards | +| `gpt_initial` | GPT owns or may receive the initial batch | sole provisional GPT adapter, listeners, slot targeting/request, handoff capture | +| `lockr_initial` | Lockr is enabled | initial script guard and bounded readiness needed before SDK use | +| `osano_initial` | Osano is enabled | initial consent mirrors required by the initial batch | +| `permutive_initial` | Permutive is enabled | initial guard/readiness and normalized segments | +| `sourcepoint_initial` | Sourcepoint is enabled | initial SDK guard and GPP mirror | +| `prebid_initial` | Prebid participates in the initial batch | artifact admission, publisher queue, bidder/user-ID/EID setup, initial TS bid/PUC path | +| `testlight_initial` | Testlight is enabled | capture preexisting callbacks before publisher replacement/drain | + +Every slice registers into the bootstrap's release-private first-display sink from +the expected parser-inserted artifact and `document.currentScript`. The build fixes +their order, interfaces, and allowed imports; there is no public service locator or +third-party extension surface. The agent rejects an unknown, duplicate, omitted, +misordered, wrong-release, accessor-backed, or late slice before effects. Agent +activation is one synchronous transaction with reverse-order rollback, the same +inert-prepare/effectful-activate discipline used by the persistent catalog, and the +same ten-second bootstrap deadline. All agent-owned timers, listeners, wrappers, +observers, ports, nodes, and provisional GPT objects have named exact-once +disposers. + +The persistent artifact may be requested only after every attempt in the protected +initial batch is terminal and the §5.2 paint gate has recorded +`tsjs:first-display-paint`. On an agent page, no persistent/deferred TSJS preload, +fetch, preparation, or evaluation may begin earlier. The bootstrap creates the one +authenticated classic same-origin runtime script using the manifest URL, expected +element identity, `document.currentScript`, CSP nonce, and Trusted Types rules +defined below. Independent ordinary deferred modules remain later than persistent +runtime commit. + +Recording protected paint also **seals first-display TS admission** before the +persistent request begins. From that boundary until persistent or fallback commit, +the agent accepts no new TS bidder invocation, admission lease, render reservation, +attempt, direct-auction request, refresh, or navigation work. A later invocation of +the Trusted Server Prebid bidder fails before minting authority, invokes its bidder +completion exactly once with no bid, and records the internal terminal reason +`prebid_admission_failed`; it is never held for or replayed into the persistent +runtime. Native publisher Prebid/GPT calls and non-TS bidder traffic remain immediate +pass-through and may update the agent's bounded observational handoff facts, but +cannot create TS authority. Because the immutable initial batch is the only TS work +the agent may ever admit and every member is terminal before sealing, the seal must +leave zero live TS admission lease, live render reservation, unconsumed ticket, +attempt, port, or request-capable callback. Consumed/expired/stale entries that §2.3 +has already converted to unexpired terminal tombstones are required suppress-only +state, not live authority: they remain until their original expiry and cross only as +the bounded terminal tombstones below. Discovery of any other live TS entry is +`bundle_partial`, not transferable state. + +If the initial batch has no accepted artifact—every slot is `no_bid`, failed, or +cancelled—the same terminal/paint gate applies and takeover may proceed with an empty +committed-artifact set. The candidate performance fixture continues to require an +actual request action; an empty batch cannot manufacture `tsjs:first-display` or be +included in the timing distribution. + +The runtime bundle first performs an effect-inert **static takeover preparation** +against only immutable boot configuration and a frozen `TakeoverOutlineV1`: exact +release/generation, projection digest, selected slice ids, counts, and the +capabilities/object kinds that final adoption must support. The outline contains no +slot outcome, mutable publisher/GPT state, artifact object, wrapper, observer, or +time-sensitive expiry. Preparation constructs generic inert persistent owners and +validates capacity; it cannot snapshot or depend on live agent state. + +The agent increments one unsigned 32-bit `mutationRevision` after every admitted +publisher GPT/Prebid call, GPT event, DOM mutation/rebind, targeting or ownership +change, parser-guard observation, consent/segment update, and terminal/tombstone +change while runtime bytes download and prepare. It continues passing through and +observing native publisher/external activity normally, subject to the sealed TS +admission boundary above; no call is held, replayed, or allowed to act through a +prepared persistent owner. Revision exhaustion fails takeover instead of wrapping. +When static preparation is ready, the agent enters the synchronous task below, +closes its own work ingress, records the final revision, drains all already-running +synchronous mutations, and only then mints the final immutable +`FirstDisplayHandoffV1` plus one-use capsule. Because JavaScript is run-to-completion, +no GPT/Prebid/DOM/publisher task can mutate between that final snapshot and owner +activation. The persistent owner validates the snapshot and revision during the same +task; any mutation callback that arrives afterward sees only the new epoch. + +`FirstDisplayHandoffV1` is an exact-shaped, recursively frozen data tree containing: + +- release/generation identity and the canonical initial projection digest; +- each slot's canonical id, aliases, DOM id, GAM path, normalized formats, + TS/publisher ownership, request-cycle outcome, and installed targeting snapshot; +- every terminal attempt and reservation/ticket tombstone still inside its original + bounded expiry, without descriptor, ADM, capability, or creative payload bytes; +- committed-artifact kind and ownership metadata; parser-time integration snapshot + data needed for its persistent owner; the ordered bounded GPT-diagnostics fact + buffer and its overflow count when diagnostics are active; and +- the exact once-only first-display timing/paint facts; +- the navigation attempt-prefix and next attempt ordinal, slot-registration-order + next ordinal, reservation/ticket monotonic-clock epoch and remaining expiries; +- the next global GPT trace-slot ordinal; for each adopted object, its token, next + cycle ordinal, `unknownPriorCycle`, and all retained open/completed/retired cycle + records and quarantines permitted by the existing ten-record cap; and +- the next trace sequence, per-slot impression counters, retained trace bindings, + and the final `mutationRevision`. + +It contains no function, accessor, custom prototype, Promise, listener, timer, +observer, `MessagePort`, `WindowProxy`, network handle, or mutable collection. A +release-private one-use `FirstDisplayOwnershipCapsuleV1` accompanies it only during +the same synchronous takeover call. The capsule may carry the exact already- +committed GPT slot and DOM artifact object identities that cannot be reconstructed +from data. It carries no live port, timer, listener, observer, in-flight attempt, or +callable publisher surface. The capsule is generation/release bound, can be consumed +once by the authenticated prepared runtime, and is cleared by agent rollback, +fallback, or successful adoption. It is never stored on `window.tsjs`, `_internal`, +boot data, diagnostics, a log, or an analytics event. + +The handoff contains at most the existing 256 initial slots/outcomes. Every `next` +counter is strictly above every value ever minted in that generation, including a +retired/pruned value absent from retained rows; adoption never derives a high-water +mark from visible rows. Each reservation/ticket entry is an unexpired terminal +tombstone and retains only the opaque value and +expiry required to suppress replay; no live authority, descriptor, ADM, or creative +payload survives. Each copied string/targeting collection retains its source grammar +and capacity. The canonical non-diagnostics data-tree encoding is at most the +existing 8 MiB boot-projection cap; the normalized diagnostics-fact subsection has +its separate 512 KiB cap from §5.8, so the complete canonical handoff is at most +8.5 MiB. The capsule has +at most one physical GPT identity and one committed artifact identity per slot. +Overflow or any nonterminal attempt/port makes takeover preparation fail; it cannot +truncate, evict a live fact, or silently lose replay suppression. + +Takeover is one non-yielding JavaScript task after static preparation succeeds: + +1. Revalidate the current generation, exact runtime script, outline, terminal batch, + paint gate, and prepared runtime; close agent work ingress and record the final + mutation revision. +2. Mint and validate the final handoff/capsule from current state. Synchronously + quiesce agent handlers and compare-restore every provisional wrapper. The + bootstrap callback Array remains the one append-only ingress until step 5; no + publisher callback is invoked in this interval. +3. Detach committed artifacts from agent disposal, then dispose every remaining + agent listener, timer, observer, port, readiness waiter, registry entry, and + uncommitted node in reverse order. +4. Activate fresh persistent wrappers/listeners/observers and owners in catalog + order, adopting capsule objects and handoff facts. A parser guard transfers only + bounded data such as installed configuration and seen-node identifiers, never its + wrapper/listener/observer. The new owner performs one bounded post-commit rescan + so records discarded while the old observer disconnects cannot be lost. + `adoptInitialDisplay:true` forbids parsing the projection as new + work, redefining an adopted GPT slot, reinstalling accepted targeting, issuing + `display`/`refresh`, creating a render iframe, or emitting any first-display mark. +5. Revalidate that the generation/revision did not change, commit the complete + `TsjsApi`, permanently close both private registration sinks, + transfer committed artifacts to persistent slot/navigation ownership, run + persistent `afterCommit` work, and drain the single bootstrap queue exactly once. + +No task or microtask can observe the synchronous listener/wrapper transition. +Momentary stack-local objects during that task do not constitute a second live +owner; at every task boundary exactly one of agent, persistent runtime, or fallback +owns ingress and side effects. Successful takeover leaves no agent timer, listener, +port, observer, wrapper, registry, request authority, or strong reference except the +committed objects now owned by the persistent runtime. Physical GPT identity and +publisher handoff remain exact because the object itself, rather than a guessed +path/DOM lookup, moves through the one-use capsule. + +Runtime download, authentication, or effect-inert preparation failure leaves the +agent in control only until the bounded persistent-load deadline. It then settles +the terminal fallback while preserving already committed ad DOM as inert publisher- +visible output. A failure after step 2 rolls back partial persistent effects, does +not resurrect the agent, and commits that same terminal shell. It never replays the +projection, requests GAM again, removes an accepted publisher-owned ad, or constructs +a degraded runtime. Failed takeover therefore sacrifices later TSJS behavior, not +the correctness or exactly-once status of the completed first display. + +During this post-paint load window the bootstrap Array remains the only TSJS +publisher-work ingress. Callable pushes are appended and run against neither owner; +they are drained once only after persistent or fallback commit. The provisional +transport intentionally has no `requestAds` or `addAdUnits`, so an attempted direct +call before commit is ordinary use-before-ready and creates no accepted operation or +Promise. On successful takeover, queued callbacks run against the complete kernel. +On authentication, load, preparation, activation, or ten-second-deadline failure, +fallback first classifies and freezes the exact `fallbackReason` under §5.3 and +freezes `initialDisplayCommitted` to whether the completed protected batch contained +at least one `accepted` result, then drains those same callbacks. Wrong +release/source/manifest/ABI identity is `abi_mismatch`; transport/load/deadline, +preparation/activation failure, or a live TS entry at the seal is `bundle_partial`. +Every `requestAds` made by a drained or later callback is a new post-paint call and +settles under the fallback membership rules below with `reason:fallbackReason`; it +does not re-report, remove, or replay the completed initial display. Every such +`addAdUnits` throws `TsjsUnavailableError{code:'runtime_unavailable', +reason:fallbackReason}` before mutation, and `_internal.reason` is that same frozen +value. No queued callback or API call remains pending, and no post-paint transient +failure retries the runtime artifact in that document generation. + +Every installed effect is a repository-owned primitive with a synchronous, +nonthrowing, identity-checked disposer. Rollback attempts physical removal/restoration +for every effect even after an earlier disposer reports failure. The mandatory +security/correctness postcondition is generation-latched inertness: a publisher who +replaced a global after agent installation may prevent literal restoration, but the +old wrapper/listener/observer cannot authorize, request, render, or mutate TS state. +Tests require literal removal where the platform operation succeeds and zero +surviving authority in every case; “no second listener/wrapper survives” means no +live TSJS authority, not control over publisher replacements. + +All “critical” persistent-module language below means the atomic **takeover** +transaction on an agent page and the ordinary bootstrap transaction on a page where +the agent is omitted. It does not authorize those module bytes to enter the +first-display artifact. Where the older topology below describes core as creating +the first protected attempt or waiting ten seconds before the first phase release, +the agent path instead follows the terminal/paint/takeover sequence above; the +ten-second no-attempt guard remains only for a direct-to-runtime page with no +server-projected agent batch. + +Each shipped integration remains a separately built IIFE with imports inlined, so +module singletons cannot be the shared-runtime mechanism. `tsjs-core` installs the +only runtime and keeps the capability broker in its composition closure. During +boot or takeover, `_registerIntegration` collects exact release-bound takeover modules from the +same server-composed script. After kernel commit it accepts only a currently loading, +manifest-declared deferred module from the exact core-created script element. It is +permanently refusing for unknown, duplicate, wrong-release, wrong-phase, publisher- +invoked, replaced-node, or already-terminal registrations. `tsjs._internal` exposes +only the frozen status described in §5.4, never the broker or phase loader. + +Every integration module registers through: + +```ts +interface IntegrationRegistrationV1 { + readonly abi: 1 + readonly id: string + readonly phase: 'takeover' | 'deferred' + readonly releaseId: string + readonly prepare: ( + ctx: Readonly + ) => PreparedIntegrationV1 | Promise +} + +tsjs._registerIntegration({ abi: 1, id, phase, releaseId, prepare }) +``` + +This is a release-internal bundle handshake, not a publisher extension API. An +**integration** remains the product capability; an **integration module** is only +that integration's transactional TSJS implementation unit. The design introduces +no separately installed or third-party plugin system. + +The build first emits the bootstrap controller, first-display base, every +first-display slice, core, and every production integration module with the same +fixed release sentinel, +then computes one `releaseId`: 64 lowercase hexadecimal SHA-256 characters over a +canonical ordered release inventory containing every artifact id, role, phase, and +its sentinel-normalized +bytes. It replaces exactly one sentinel in each bundle and verifies none remains. +This avoids a self-referential hash while changing the id for any logical bundle or +ordering/role/phase change. First-display base/slice role, order, mask bit, or byte +changes therefore change the release identity. The same value is embedded in the +bootstrap controller, every agent base/slice, core, and every integration bundle. +The bootstrap controller, agent base/slices, and core use reserved artifact ids and +never appear as persistent integration entries. +Before core is injected, the server emits this exact manifest. This is the first and +only `BootManifestV1` shape; the unreleased all-required draft has no compatibility +status: + +```ts +type BootManifestIntegrationV1 = + | Readonly<{ + id: string + phase: 'takeover' + }> + | Readonly<{ + id: string + phase: 'deferred' + trigger: 'first_display_or_idle' + src: string + }> + +interface BootManifestV1 { + readonly version: 1 + readonly releaseId: string + readonly firstDisplay: null | Readonly<{ + src: string + slices: readonly string[] + }> + readonly runtimeSrc: string + readonly integrations: readonly BootManifestIntegrationV1[] +} +``` + +Integration ids match `^[a-z0-9][a-z0-9_-]{0,63}$`, are unique, and appear in the actual +server phase/injection order. Takeover entries precede deferred entries, the list +contains exactly the enabled persistent-runtime modules for that page, and there are +at most 20. `firstDisplay` is either exact `null` or the server-selected agent +artifact and its canonical ordered subset of the 13 slice ids in §5.2.1; ids are +unique and a list contains at most 13. The list and encoded mask represent exactly +the same set. Its `src` is the exact same-origin +`/static/tsjs=tsjs-first-display.min.js?m=&v=` URL. +`sliceMask` is exactly four lowercase hexadecimal digits encoding the 13 catalog +rows in order; bit 0 (`first_display`) is required and unused upper bits are zero. +The server configuration resolver enumerates the finite masks permitted by its +enabled integration set and precomputes each exact body identity outside request +handling. Since APS/GPT/Prebid participation is projection-dependent, a mask may +omit an enabled slice but cannot add a disabled one; `first_display` is always set. +The hash names the exact uncompressed base agent plus selected slice bytes in catalog +order. A mask not permitted by current trusted configuration is not served even when +its hash is otherwise valid. +`runtimeSrc` is the exact same-origin +`/static/tsjs=tsjs-unified.min.js?v=` URL emitted immediately after +the bootstrap controller only when `firstDisplay` is `null`, and otherwise loaded by +the controller after the agent's protected paint. `criticalHash` is 64 lowercase +hexadecimal characters equal to SHA-256 over the exact uncompressed UTF-8 response +bytes. Those bytes are core followed by the manifest's takeover IIFEs in manifest +order with the build's exact `;\n` separator; their embedded registrations and `releaseId` bind the +URL to the catalog, phase, order, and release. Manifest URLs are transported only as +canonical root-relative strings. During validation, the bootstrap controller and +then core resolve each once against the trusted document origin—never +`document.baseURI`—require the same +origin and exact path/query round-trip, and freeze one absolute URL per entry. All +DOM, Trusted Types, and current-script comparisons use only those canonical absolute +values. With an agent, the controller accepts only one parser-inserted +`script#trustedserver-js` whose resolved `src` equals the absolute first-display +`src`; without an agent it accepts only that element with absolute `runtimeSrc`. +The dynamically inserted runtime node uses the reserved +`trustedserver-js-runtime` id. An absent, duplicate, or mismatched expected node +fails the owning transaction. Redirect refusal is enforced by the local transport +below. +For an ordinary publisher document, the trusted document origin is the captured +exact HTTP(S) `window.location.origin`. A sandboxed `srcdoc` creative has the opaque +origin `"null"`; its server-owned parent therefore defines one own, non-enumerable, +non-configurable, non-writable data property `window.__tsCreativeOrigin` containing +the parent's exact HTTP(S) origin before any bidder markup or TSJS tag. Core accepts +that stamp only when the document origin is exactly `"null"` and rejects an absent, +accessor-backed, mutable, enumerable, credentialed, non-origin, or inherited value. +The stamp authenticates only the local content-addressed TSJS URL; it is not a +publisher API or a general cross-origin capability. Opaque creative documents have +no deferred entries, so the phase loader never creates a dynamic script in that +realm. +Every deferred `src` is an exact same-origin `/static/tsjs=tsjs-.min.js?v=` +URL generated by the server for that release; accessors, arbitrary hosts, fragments, +duplicate URLs, and mismatched ids/hashes fail manifest validation. That local +static route must return the exact release artifact directly and never redirect. +The persistent registration value must be a non-null plain object with exactly those five own +enumerable data properties; accessors, unknown/missing/inherited keys, a custom +prototype, wrong literals/types, or a non-callable `prepare` are rejected before the +factory is retained. Registration requires exact id membership, phase, `releaseId` equality, expected script +element identity, and `document.currentScript` identity. Integrations obtain stateful +capabilities from the closure-private broker; they never construct a second runtime +or replace an existing adapter, slot registry, dispatcher, or provider. + +The server emits exactly one parser-blocking TSJS artifact: the first-display agent +when selected, otherwise the persistent runtime artifact. Agent bytes are its base +followed by every selected slice in catalog order. Runtime bytes are core followed +by every `phase:'takeover'` IIFE in manifest order. Each transaction therefore +incurs one request and no integration waterfall. The server emits no parser-time tag +for the other artifact or for a deferred module. The bootstrap alone creates the +post-paint runtime node; after commit, core alone creates ordinary deferred nodes. +`defer`, `async`, preload, or a post-commit callback attached to an already +downloaded monolith does not satisfy this contract. + +The static transport is exact and shared by Fastly, Axum, Cloudflare, and Spin. +Only `GET` and `HEAD` for +`/static/tsjs=tsjs-first-display.min.js?m=&v=`, +`/static/tsjs=tsjs-unified.min.js?v=` and +`/static/tsjs=tsjs-.min.js?v=` are admitted. The +first-display query has exactly canonical `m` then `v`; the other routes have +exactly one `v` and no other field. `firstDisplayHash` and `criticalHash` use +the exact composition rules above; `moduleHash` is SHA-256 over that deferred +artifact's exact uncompressed UTF-8 bytes. The handler derives the enabled ordered +first-display set from the validated mask plus trusted configuration, or the +takeover/deferred catalog entry, recomputes the hash, and returns the current +artifact only on an exact match. HTML composition chooses only a precomputed +permitted mask from the immutable projection; the later static request never needs +request-local projection state or a dynamic artifact cache. `HEAD` returns +the same status and metadata as `GET` with an empty body. An unconditional success +is `200`, `Content-Type: application/javascript; charset=utf-8`, and +`X-Content-Type-Options: nosniff`; the existing strong-ETag/static-cache behavior, +including a valid conditional `304`, is preserved without adding a new cache +requirement. A missing/malformed/stale hash, unknown or disabled id, wrong method, +unsupported method, legacy filename, or extra query field receives the adapter-local `404 no-store` and +never falls through to publisher origin. Redirects are forbidden. Compression may +change transfer bytes but not the uncompressed bytes named by `v`. + +This hard cutover serves only artifacts embedded in the active binary. It does not +add an N/N-1 asset store or retain a previous release's TSJS routes. A page carrying +an old manifest across deployment can receive the typed artifact/deferred failure +defined here and must reload; the retained prior _binary_ in §8 is solely the whole- +deployment rollback artifact. After rollback, that binary again serves its own +release. This accepted stale-page break is not a cache or compatibility project. + +Upstream-library loading is orthogonal to TSJS bundling. After the bootstrap +controller, the server may emit the existing fixed/configured live GPT tag as a non- +parser-blocking fetch early enough to overlap the first-display TSJS request when GPT is +required for the first projected display. When Prebid integration is enabled, its +external artifact tag is always emitted through that early overlap path because the +current-main client readiness, bidder/user-ID/EID configuration, publisher queue, and +initial auction contract are critical. It remains +an external script, never a TSJS source input or TSJS generated artifact. Its adapter installs +and owns all request-capable actions only after the agent transaction commits, so +an early library load cannot race a TS-owned display before correctness listeners. +An optional/later upstream script follows its owning deferred module's trigger and +cannot be prefetched by this path. The APS runner is never boot-preloaded: only a +winning APS renderer document loads it through the live fixed-target proxy specified +in §§3.6 and 4.4. + +Phase assignment is a release-catalog decision, not a publisher input or browser +heuristic. Where one product supports initial and persistent behavior, the build +defines the fixed first-display slice from §5.2.1 and the catalogued takeover module +below. The server selects an exact first-display subset from trusted configuration +and the immutable initial projection; it never relabels a module id or moves an +unbounded implementation into the agent: + +- **first display** contains only the agent/slices required before the initial + request action, terminal result, and protected paint. APS/ADM protocol handling, + initial GPT/Prebid admission, and parser-time guards live here only when selected; +- **takeover** contains the complete persistent owner for enabled behavior, including + programmatic/direct auctions, ongoing lifecycle state, publisher APIs, refresh, + later navigation, reconciliation, diagnostics data, current-main behavior, and + retained audited concept gaps. On an agent page it prepares after paint and adopts initial state; + on a no-agent page it is the ordinary critical boot transaction; and +- **deferred** remains restricted to independently loadable behavior that has no + ownership or parser-time obligation at persistent-runtime commit, such as + presentation UI and a genuinely optional later lifecycle slice. A deferred + consumer binds only the already committed broker. + +A product integration may therefore have a first-display slice, one takeover module, +and a deferred module. Those are release-owned implementation units of one product, +not separately configurable products. The handoff record/capsule is the only bridge +from the provisional slice; persistent/deferred sharing uses exact frozen broker +capabilities from the one runtime. + +This is the canonical release catalog and its order. Capability lists use the exact +keys shown; `—` means none. The inclusion predicate is server-owned and +deny-unknown. A module id, phase, trigger, predicate, provider/consumer list, or +obligation absent from this table is not a production module: + +| Order | Module id | Product | Phase / trigger | Include iff | Provides | Consumes | Takeover obligation or deferred scope | +| ----: | -------------------------- | ----------- | ---------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| 1 | `render_runtime` | runtime | takeover | always | `slots.v1`, `auction.v1`, `render.v1`, `messages.v1`, `trace.v1`, `trace.presentation.v1`, `direct.v1` | `runtime.v1` | Adopt initial facts/artifacts; own every later direct render, lifecycle, dispatcher, and trace | +| 2 | `aps` | APS | takeover | APS integration enabled | `aps.v1` | `runtime.v1`, `slots.v1`, `render.v1`, `messages.v1`, `trace.v1` | Adopt initial APS tombstones/artifacts; own all later APS and PUC work | +| 3 | `creative` | creative | takeover | `creative.enabled && (clickGuard \|\| renderGuard)` | — | `runtime.v1` | Adopt parser-time guard state and own later creative behavior | +| 4 | `datadome` | DataDome | takeover | DataDome enabled | — | `runtime.v1` | Adopt the initial guard state and own later route rewriting | +| 5 | `didomi` | Didomi | takeover | Didomi enabled | — | `runtime.v1` | Adopt the configured SDK path and own later integration behavior | +| 6 | `google_tag_manager` | GTM/GA | takeover | Google Tag Manager enabled | — | `runtime.v1` | Adopt the initial guards and own later matching traffic | +| 7 | `gpt` | GPT | takeover | GPT integration enabled | `gpt.v1`, `gpt.events.v1`, `pbs_cache.baseline.v1` | `runtime.v1`, `slots.v1`, `auction.v1`, `render.v1`, `messages.v1`, `trace.v1` | Sole persistent GPT adapter/listeners; adopt initial physical slots and own handoff/reconciliation | +| 8 | `gpt_diagnostics` | diagnostics | takeover | `diagnostics.gpt.active` | `gpt_diag.v1` | `runtime.v1`, `gpt.events.v1` | Consume persistent GPT facts and commit the final data-only public API | +| 9 | `lockr` | Lockr | takeover | Lockr enabled | — | `runtime.v1` | Adopt initial guard/readiness state and own later API-host rewriting | +| 10 | `osano_consent` | Osano | takeover | Osano enabled | `osano_consent.v1` | `runtime.v1` | Adopt the initial consent mirror and own later consent-dependent work | +| 11 | `permutive_context` | Permutive | takeover | Permutive enabled | `permutive_context.v1` | `runtime.v1` | Adopt initial normalized segments and own later context | +| 12 | `sourcepoint_consent` | Sourcepoint | takeover | Sourcepoint enabled | `sourcepoint_consent.v1` | `runtime.v1` | Adopt the initial GPP mirror/guard and own later consent work | +| 13 | `prebid` | Prebid | takeover | Prebid integration enabled | `prebid.v1` | `runtime.v1`, `slots.v1`, `render.v1`, `messages.v1`, and `aps.v1` iff APS enabled | Adopt initial artifact/queue/bid state and own subsequent Prebid admission | +| 14 | `testlight` | Testlight | takeover | Testlight enabled | — | `runtime.v1` | Adopt initial callback capture and own later bridging | +| 15 | `diagnostics_presentation` | diagnostics | deferred / `first_display_or_idle` | `renderTraceOverlay \|\| diagnostics.gpt.active` | — | `runtime.v1`, `trace.presentation.v1`, and `gpt_diag.v1` iff active | DOM overlay, badges, formatting, clipboard/download interaction | +| 16 | `gpt_later` | GPT | deferred / `first_display_or_idle` | GPT enabled | — | `runtime.v1`, `slots.v1`, `auction.v1`, `render.v1`, `gpt.v1`, `trace.v1` | Post-first-display refresh, SPA navigation, and later reconciliation only | +| 17 | `osano_lifecycle` | Osano | deferred / `first_display_or_idle` | Osano enabled | — | `runtime.v1`, `osano_consent.v1` | Later retry/event/focus/visibility/clear maintenance | +| 18 | `permutive_lifecycle` | Permutive | deferred / `first_display_or_idle` | Permutive enabled | — | `runtime.v1`, `permutive_context.v1` | Later SDK/segment refresh maintenance | +| 19 | `prebid_later` | Prebid | deferred / `first_display_or_idle` | Prebid and GPT enabled | — | `runtime.v1`, `slots.v1`, `gpt.v1`, `prebid.v1` | Synthetic refresh and GAM-path exclusion; never initial admission | +| 20 | `sourcepoint_lifecycle` | Sourcepoint | deferred / `first_display_or_idle` | Sourcepoint enabled | — | `runtime.v1`, `sourcepoint_consent.v1` | Later retry/visibility/focus/update/safe-clear maintenance | + +`runtime.v1` is the kernel's only built-in capability: generation, disposal, +clock/scheduler, queue, logger, validated boot data, capability access, and phase +loading. All other keys have exactly the provider above. Optional consumption is +allowed only where the table says `iff active`; absence in that case is never +silently substituted. The maximal manifest therefore has 14 takeover plus six +deferred entries: `MAX_TAKEOVER_MODULES = 14` and `MAX_MANIFEST_MODULES = 20`. +The serializer, parser, registry, callback staging, tests, and fuzz/capacity fixtures +derive 13/14/15 and 19/20/21 boundaries from this table rather than retaining a +hand-written 16. The kernel diagnostics ingress has no integration-module +subscription surface or subscription-capacity constant. GPT diagnostics consumes +the separately bounded `gpt.events.v1` capability, while deferred diagnostics +presentation alone consumes `trace.presentation.v1`. `attachPresentation` is absent +from `trace.v1`, so APS, GPT, and `gpt_later` cannot obtain presentation authority +even though they publish or consume trace facts. Public diagnostic subscriber limits +remain separate. + +The release catalog records, for every module id, its product integration, phase, +trigger, provided/consumed capability keys, and whether parser-time activation is a +proved current-main or retained-gap obligation. The build rejects dependency cycles, a deferred +provider consumed by another module, two providers for one key, a phase override +from server or publisher data, provider-after-consumer manifest order, and any +takeover entry that imports a catalogued deferred source area. GPT, the APS runner, +the external Prebid artifact, PUC, and all +other upstream script bytes remain remote/live and are never copied into a TSJS +bundle; only Trusted Server-owned adapters, contracts, and lifecycle code may be in +these artifacts. + +Both persistent phases use the same module-transaction rules. Registration stores +code but does not execute it. During the takeover transaction, core calls `prepare(ctx)` in +manifest order. Each deferred transaction calls its own `prepare(ctx)` independently +after that module's accepted registration and `load` checkpoint, without awaiting or +ordering against a deferred sibling. `prepare(ctx)` may be synchronous or asynchronous; +its only legal effects are validating frozen configuration, +obtaining declared capability interfaces, allocating private inert data/closures, +and registering private-memory disposers. Preparation cannot read or write ad-tech +globals, attach a listener/observer/wrapper, touch live DOM, inject a script, start a +timer/fetch, schedule detached work, invoke publisher code, or call a stateful +adapter/service method. The one Promise returned to and awaited by the phase owner, +including its ordinary `await`/settlement continuations, is permitted; no +continuation may be detached from that Promise or survive its settlement/abort. +Preparation returns exactly one prepared module with a synchronous `activate(ctx)` +function and its declared frozen capability interfaces. Core validates and stages a +provider's interfaces immediately after that provider prepares, so later consumers +can receive them in their preparation context; the interfaces remain effect-inert +until provider activation and are removed during rollback. Preparation code cannot +call a staged stateful capability. Takeover activation order is the same +provider-before-consumer order, so no consumer becomes live first. + +After every takeover module prepares, core enters one synchronous takeover +activation barrier in manifest order. `activate(ctx)` may install only synchronously +compare-restorable wrappers, listeners, observers, guards, provider live-state +transitions, and service subscriptions. It registers the disposer before each mutation and may stage bounded +post-commit work through `ctx.afterCommit(fn)`, but cannot inject/load a script, +start network/timers, schedule work, drain a publisher queue, or invoke publisher +callbacks directly. The persistent message-dispatcher and GPT-adapter provider +modules occupy the catalogued provider positions before their consumers, and their +correctness listeners are activated there rather than left live during asynchronous +preparation. If any takeover activation throws, core synchronously +runs every activated and prepared disposer once in reverse order before committing +fallback. Since the barrier never yields and activation cannot call publisher code, +no publisher task can observe a partial persistent generation. + +The takeover activation barrier checks its monotonic deadline immediately +before and after every `activate` call and once more before kernel handoff. Elapsed +time greater than or equal to 10,000 ms synchronously unwinds and commits fallback +even when the timer task has not run. JavaScript cannot preempt an activation +function that never returns; a malicious/nonreturning same-realm module can freeze +the page and is an accepted platform limitation, not a second-runtime recovery case. + +After all takeover activations succeed, core commits the kernel API, runs takeover +`afterCommit` callbacks in manifest order, and only then drains the bootstrap queue. +Those callbacks may synchronously start required upstream scripts, timers, readiness +work, and current-main DOM scans; publisher code they intentionally invoke therefore +sees the complete kernel. A callback throw is isolated to its module, runs that +module's remaining disposers, records a bounded local runtime failure, and makes +affected operations fail through their existing typed readiness/render result; it +cannot roll back an already published kernel or create a fallback generation. + +Every deferred module uses the sole `first_display_or_idle` phase gate. Core arms a +10,000 ms **attempt-creation** guard at kernel commit on every page, including one +with no server projection, so an immediate programmatic `addAdUnits`/`requestAds` +first display is protected. The first render batch to create an attempt during that +window becomes the immutable protected first-display batch. The guard is cancelled, +and deferred work waits without another fixed cutoff until every attempt in that +batch reaches its terminal latch. Readiness plus GPT/renderer completion may +therefore exceed ten seconds without a deferred race. The guard fires only when no +server-projected or programmatic attempt was created by its boundary. Firing is the +explicit runtime decision that no _startup-protected_ display exists; it releases the +deferred phase even though publisher code may create a later first attempt. That +post-window attempt uses the same correct persistent owners but may overlap already +released later work. The design makes no absolute load-contention guarantee for a +first display initiated after 10,000 ms. + +Terminal/no-attempt is not itself permission to fetch. On a visible document, the +phase loader waits through two owned `requestAnimationFrame` callbacks, guaranteeing +one intervening paint opportunity, and records the internal paint gate only in the +second callback. If the document is hidden, it waits for the first of visibility +return through that same two-frame gate or a 2,000 ms owned idle timeout; there is no +visible paint to contend with while hidden. Only after that gate does it schedule +deferred loading in `requestIdleCallback({timeout:2_000})`. The +non-idle fallback is one owned 50 ms timer created after the paint/hidden gate, never +a zero-delay task before paint. + +The catalog has no on-demand network trigger. A takeover provider may expose a +bounded readiness facade for behavior implemented by a deferred slice. Each caller's +existing deadline begins at its original enqueue time and may expire while the phase +gate is closed. The shared module receives a separate ten-second load/transaction +deadline only when `first_display_or_idle` actually triggers; only still-live +waiters observe readiness. Expiring one waiter never aborts the shared module while +another waiter or the catalogued background load still owns it. + +Core creates a classic same-origin script element with the canonical absolute form of +the exact manifest `src`, +stores its identity before insertion, and authenticates registration with that +identity plus `document.currentScript`. It uses neither dynamic `import()` nor +`eval`. After the common paint/idle gate, it starts every included deferred module's +independent transaction in manifest order without awaiting another module. Each +dynamically created classic script is `async = true`; network, evaluation, and local +transaction completion may finish in any order. This is safe because the catalog +forbids deferred-to-deferred capability edges. A hung, failed, or slow module cannot +consume another module's deadline or delay its fetch, preparation, or activation. + +Deferred insertion preserves the publisher's script policy; it never rewrites a +Content-Security-Policy header or meta element and never adds `unsafe-inline`, +`unsafe-eval`, a source host, or a default Trusted Types policy. The parser-inserted +bootstrap and parser-inserted first-display/runtime tags remain subject to the publisher's existing CSP and are +a deployment precondition just as TSJS injection is on current main. When those +tags carry a CSP nonce, they must carry the same response-local value, and core +copies the authenticated parser-inserted element's `nonce` IDL value to the runtime +and every deferred script before +insertion. This supports nonce-only policies and preserves the trusted-root chain +under `strict-dynamic`; an absent nonce is never synthesized or copied from an +unrelated publisher element. + +`HTMLScriptElement.src` is a Trusted Types script-URL sink. When the browser exposes +`trustedTypes`, core attempts once per document runtime to create the closure-private +policy `trusted-server#tsjs-v1`. Its `createScriptURL` callback returns its canonical +absolute argument only when that value is exact membership in the frozen absolute +deferred URL set; it rejects every other string and is never exposed. If publisher CSP does +not permit that policy name or the name is unavailable, core may assign the raw +manifest string so a publisher's existing default policy or a non-enforcing browser +can process it, but it immediately verifies that the element's resolved `src` is +still byte-for-byte the expected canonical absolute URL before insertion. A +synchronous Trusted Types throw, empty value, mutation, or policy result outside the +exact manifest is `policy_blocked`; TSJS does +not try `setAttribute`, another policy name, a blob/data URL, `eval`, or a remote +fallback. Under enforcing Trusted Types with neither the named policy nor a +publisher default policy that preserves the exact URL, the affected deferred module +therefore becomes unavailable while the committed runtime stays live. Publishers +that restrict the `trusted-types` directive and require deferred TSJS behavior must +allow the fixed `trusted-server#tsjs-v1` name; this grants only the exact release +URLs already selected by the server. A nonce/source CSP rejection after insertion is +observed through the script's `error` event and is `load_error`, not +`policy_blocked`; the runtime installs no `SecurityPolicyViolationEvent` listener. +A node removed or replaced after insertion may already have initiated a request, but +its disconnected/replaced identity cannot register and settles as +`registration_rejected` (or `load_error` if fetch rejection wins). + +Full document-runtime disposal aborts pending module readiness, clears idle/ +timer/load listeners, removes an uncommitted script node, and makes late registration +inert. SPA navigation disposal cancels only waiters and state owned by that +`NavigationSession`; the runtime-owned fetch/transaction may complete for the next +session and cannot retain or act on the disposed one. +No deferred request, preload, preparation, or execution may begin before its trigger, +and a `first_display_or_idle` module must not begin before the reference fixture's +`tsjs:first-display-paint` gate. + +Each deferred entry follows +`not_triggered -> loading -> registered -> preparing -> activating -> ready`, with +any nonterminal state able to move once to `unavailable`. Registration only stores +the factory. The exact script `load` must then observe exactly that one accepted +registration before preparation begins; `load` without registration, `error`, a +second registration, or deadline/disposal wins the terminal latch and never invokes +the factory. The load/error listeners and inert script node are removed as soon as +that fetch/registration checkpoint becomes terminal; executing bytes remain owned +only by the registered factory/module closure. The bounded internal unavailable reason distinguishes `load_error`, +`load_without_registration`, `registration_rejected`, `prepare_failed`, +`activation_failed`, `after_commit_failed`, `policy_blocked`, `module_timeout`, and +`disposed` for local diagnostics/tests. It is not added to `TsjsApi` or an analytics +schema. + +Each deferred module gets one fixed ten-second deadline from its trigger through +script fetch, accepted registration, preparation, activation, and `afterCommit`. +Queued dependent operations retain the independent original deadlines above and +share the module readiness Promise only while live. With no waiting operation, the +module deadline still retires its script and state rather than leaking indefinitely. The module's +preparation and synchronous activation run against the already committed +`RuntimeSession` and broker, after which its `afterCommit` callback runs. Failure, +timeout, wrong bytes, or disposal marks only that module terminal-unavailable and +settles dependent work through its existing exact typed reason (for example +`external_ready_timeout` or `external_artifact_incompatible`). It does not roll back +the kernel, activate the no-bundle fallback, retry indefinitely, or construct a +second adapter/runtime. Once a deferred module reaches ready or unavailable, its +registration is permanently closed. A capability provider removed during module or +full-runtime disposal is never replaced within that generation; navigation disposal +clears only navigation-scoped state and does not remove the document-runtime +provider needed by the next `NavigationSession`. + +`ctx.signal` aborts pending preparation. `ctx.onDispose(fn)` is the only disposal +registration mechanism; a disposer registered after disposal runs immediately, and +one failing disposer does not prevent the rest. Each module may call +`ctx.afterCommit` at most once. A second call by a takeover module unwinds the +takeover barrier and commits `bundle_partial`; a second call by a deferred module +marks only that module unavailable. Direct-to-runtime takeover shares the bootstrap +deadline; post-paint agent takeover owns the separate deadline in §5.2.1. Deferred +modules do not start another global boot clock. + +### 5.3 Bootstrap ownership + +Bootstrap uses a generation-scoped state machine: + +```text +unclaimed -> installing -> agent -> transferring -> kernel + | \-> fallback + |-> kernel (no agent) + \-> failed --------------------> fallback +``` + +Initial namespace capture is field-wise and does not replace a publisher-created +`window.tsjs` object: `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}`. The +kernel remains externally inert and commits ownership only after all takeover +integration modules prepare and synchronously activate in order. The agent also +leaves the public runtime surface uncommitted. Deferred modules are not part of the +bootstrap/takeover transaction. Before first-display or takeover module work, bootstrap +normalizes `que` to one actual Array and defines the `tsjs.que` data +property as writable false/configurable true for the installing generation. It keeps +the ingress Array's native `push` +throughout `installing`. Thus ordinary assignment cannot redirect the queue, and +callbacks pushed at any point during the shared deadline append to the same ingress +Array instead of a one-time snapshot. A preexisting non-Array `que` contributes no +callbacks and is replaced; an Array's existing own data entries are retained in +index order. + +Kernel and fallback use the same synchronous, non-interleavable commit handoff in one +JavaScript task. Its order is exact: + +1. create an empty actual-Array final executor, install its own immediate-execution + `push`, and freeze the Array; +2. snapshot the ingress Array's callable own data entries in ascending index order; +3. clear the ingress Array and replace its `push` with a forwarder to the final + executor for publishers retaining the old reference; +4. redefine `tsjs.que` as the final executor with a writable-false, + configurable-false descriptor while installing all other complete committed + `tsjs` fields; +5. for a kernel commit, run every staged takeover `afterCommit` callback in takeover + manifest order; fallback has none; and +6. drain the snapshot FIFO. + +No browser task or microtask can interleave steps 1–6. Code intentionally invoked by +an `afterCommit` callback sees the complete API and committed queue. A callback is therefore either +in the snapshot or reaches the final executor through one of the two queue +references, never lost or invoked twice. A callback that pushes while the snapshot +drains executes immediately through the committed queue before draining continues; +one throw is isolated. Both ingress and final values satisfy +`Array.isArray(...) === true`. The old ingress identity remains a live forwarding +queue; the public `tsjs.que` identity changes exactly once at commit. + +One ten-second watchdog begins immediately before the parser-blocking artifact and +covers agent registration/activation plus initial-action start, or direct-to-runtime +registration, preparation, and synchronous takeover activation when no agent is +selected. A preparation/activation failure, ABI mismatch, or deadline aborts the +installing generation, synchronously unwinds registered disposers in reverse order, +and then commits fallback. The completed agent batch uses the bounded per-attempt +deadlines in §4; persistent loading/takeover has its own fixed ten-second deadline +starting only after protected paint. A late artifact continuation that arrives after +fallback is rejected and quarantined. Deferred loading never starts before kernel +commit, so a deferred failure cannot select or replace fallback. Every late +continuation verifies its owner generation and self-discards. + +The server-owned inline bootstrap controller is deliberately smaller than a runtime. +It installs only the queue ingress, immutable boot/manifest inputs, generation latch, +artifact error/watchdog observation, both release-internal registration sinks, +User Timing start mark, and terminal fallback commit. It owns no adapter, slot, +auction, renderer, integration feature, upstream loader, DOM scan, or publisher +callback execution before handoff. The server follows it with only first-display +configuration transports, any first-display-required live upstream tags described +in §5.2, and the one parser-blocking agent-or-runtime tag. + +The old `gpt_bootstrap.js` asset and its initial-load hooks, handoff wrappers, +hydration scheduler, slot definition, targeting, display, and refresh are deleted +rather than retained as another runtime. The minimal controller/fallback is generated +from one TypeScript source, embedded by the server, included in the release hash and +its own §5.12 budget, and pinned by a staleness test; behavior is not hand-maintained +in both ES5 and TypeScript. On an agent page, later GPT/render behaviors run only after the persistent +runtime commits. This intentionally changes the missing/partial-artifact +case: it no longer attempts a best-effort GPT render through a duplicated degraded +runtime, and instead settles every known slot through the terminal fallback below. + +The fallback is a terminal, non-rendering shell, not a reduced second runtime. Its +commit atomically records one immutable boot failure reason: + +- `abi_mismatch` for invalid manifest shape, duplicate/unknown integration id, wrong + release, invalid phase/catalog/source binding, duplicate registration, + or incompatible ABI; or +- `bundle_partial` for a missing first-display/takeover module, preparation + throw/rejection, activation/takeover throw, nonterminal TS state at the admission + seal, or the owning deadline. + +Before draining user work it installs `version:'1.0.0'`, the embedded `releaseId`, a +safe frozen `TsjsBootV1`, the final `tsjs.requestAds` input validator, the +validating-then-refusing `tsjs.addAdUnits`, the local `tsjs.log`, the +immediate-executor `tsjs.que`, a permanently refusing internal +`_registerIntegration`, and a frozen +`tsjs._internal` value containing only +`{state:'fallback',releaseId,reason,initialDisplayCommitted}`. It +constructs no runtime session, slot registry, GPT/Prebid adapter, bridge dispatcher, +timer, listener, port, or iframe. It never exposes a compatibility API. + +The safe fallback boot uses the independently embedded release and exact selected +URLs in `manifest:{version:1,releaseId,firstDisplay,runtimeSrc,integrations:[]}`. It retains the +server auction projection only when that projection passes its exact shape, full ordered +placement coverage, 256-slot bounds, +field grammars, render limits, and 8 MiB aggregate cap from §§3.1–3.2, and otherwise substitutes exactly +`{version:1,auction:{version:1,auctionId:'fallback',results:[]},slots:[],bids:[]}`. It +substitutes the creative/diagnostics disabled safe defaults from §§5.4/5.8 because +no integration module commits. It never copies an accessor or +unknown property. Fallback batch membership comes only from exact server slot ids in +that validated immutable `tsjs.boot.auctionProjection` snapshot. Explicit valid ids present in that snapshot, +and every omitted-slot snapshot entry in projection order, resolve once as +`failed{path:'primary',reason:}`. An explicit id absent from the +projection resolves `slot_unresolved`; an already-aborted signal resolves each known +member as `cancelled{reason:'caller_aborted'}`. An empty projection plus omitted slots +resolves `{slots:[]}`. Input-shape errors still reject with `RequestAdsInputError`. + +After installing those surfaces, fallback drains the preexisting callback queue FIFO +exactly once with `this === tsjs`; one callback throw does not prevent later callbacks. +Subsequent `que.push(fn)` executes a callable immediately once and ignores non-callable +values. Every module registration is refused without invoking integration code, and +every late bundle continuation self-discards. Browser tests cover each failure +checkpoint, queued and later `requestAds`, callback throws, already-aborted signals, +and late bundles; no valid call remains pending. + +### 5.4 Public surface after cutover + +There are no compatibility aliases: + +| Baseline surface | Final surface | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| scattered `window.__tsjs_*` flags/config | `tsjs.boot.*` | +| `tsjs.adSlots`/`tsjs.bids` | initial `tsjs.boot.auctionProjection` including exact ordered placements; internal navigation projection after SPA | +| `tsjs.version === '0.1.0'` | `tsjs.version === '1.0.0'` plus `tsjs.releaseId` | +| `globalThis.tscreative` | no callable equivalent; automatic creative module | +| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | +| void/callback `requestAds` | `tsjs.requestAds(options): Promise` | +| placeholder `renderAdUnit` | `tsjs.requestAds({slots:[id]})` | +| placeholder `renderAllAdUnits` | `tsjs.requestAds()` | +| generic mutable `setConfig`/`getConfig` | immutable `tsjs.boot.*` plus typed integration config | +| `tsjs.renders`/`renderLog`/`renderSeq` | `tsjs.diagnostics.renderTrace` | +| `window` event `tsjs:adRendered` | `tsjs.diagnostics.renderTrace.subscribe(listener)` | +| `tsjs.gptDiagnostics` | `tsjs.diagnostics.gpt` | +| `window.__tsjs_prebid_bundle` | exact own `pbjs.__trustedServerArtifactV1` stamp | +| integration install/patch sentinels | kernel integration registry/`WeakSet` | +| GPT slot expandos | `SlotRecord` | + +`window.tsjs.que` remains the pre-load command queue because it is the bootstrap +transport, not a legacy behavior alias. + +The complete committed public API is the following union; the pre-load +`{que,boot,_registerIntegration}` transport and closure-private controller state are +not a committed API generation: + +```ts +interface CreativeBootV1 { + readonly version: 1 + readonly enabled: boolean + readonly clickGuard: boolean + readonly renderGuard: boolean +} + +interface TsjsBootV1 { + readonly abi: 1 + readonly releaseId: string + readonly manifest: Readonly + readonly auctionProjection: Readonly + readonly creative: Readonly + readonly diagnostics: Readonly +} + +interface TsjsCommandQueue { + readonly length: 0 + push(callback: unknown): 0 +} + +interface TsjsApiBase { + readonly version: '1.0.0' + readonly releaseId: string + readonly boot: Readonly + readonly que: TsjsCommandQueue + readonly log: TsjsLog + /** Release-internal sink; true only for the exact active module load. */ + readonly _registerIntegration: (registration: unknown) => boolean + addAdUnits( + units: ProgrammaticAdUnit | readonly ProgrammaticAdUnit[] + ): AddAdUnitsResult + requestAds(options?: RequestAdsOptions): Promise +} + +interface TsjsKernelApi extends TsjsApiBase { + readonly diagnostics: Readonly + readonly _internal: Readonly<{ state: 'kernel'; releaseId: string }> +} + +interface TsjsFallbackApi extends TsjsApiBase { + readonly diagnostics?: never + readonly _internal: Readonly<{ + state: 'fallback' + releaseId: string + reason: 'abi_mismatch' | 'bundle_partial' + initialDisplayCommitted: boolean + }> +} + +type TsjsApi = TsjsKernelApi | TsjsFallbackApi +``` + +`version` is the semantic public-API generation and changes only with a reviewed API +contract; `releaseId` identifies the exact bundle set and equals +`boot.releaseId`/`boot.manifest.releaseId`. Core recursively freezes the boot value +before installing integrations. Integration-specific configuration is not a public +mutable bag: the composition root validates each server-projected, deny-unknown +config against that integration's typed schema and passes the frozen value only in +its preparation/activation contexts. `_internal` is a frozen, non-enumerable status +value; the service registry remains in the composition closure and is available to +integration modules only through those contexts during startup. + +Fallback `initialDisplayCommitted` is `true` only when takeover failed after the +agent had already accepted at least one initial artifact; the fallback still reports +later TSJS operations unavailable and owns no artifact control. It is `false` for +every pre-display/bootstrap failure. This local status does not change a render +result, remove accepted DOM, or create a recovery path. + +`_registerIntegration` is deliberately present only because separately downloaded +release-owned IIFEs need one handshake. During takeover installation it accepts the +next takeover registration from the server-composed runtime artifact. After kernel +commit it returns `true` only while an expected deferred script element created by +that kernel is the exact `document.currentScript`, and only for that element's +declared id, phase, and `releaseId`. It returns `false` without invoking supplied code +for publisher calls, unsolicited tags, wrong/replaced nodes, duplicates, terminal +modules, fallback, or disposal. It is not a general extension API, and neither it +nor `_internal` exposes readiness Promises, the broker, module state, or capability +objects. + +The final queue is the frozen actual empty Array from the commit handoff and contains +no retained callbacks. Its own `push` invokes one callable +immediately and exactly once with `this === tsjs`, returns `0`, ignores a +non-callable, and isolates/logs a throw. Pre-load callbacks are snapshotted and +drained FIFO only after the committed API is installed, so publisher callbacks never +run against a half-installed generation. Native mutators, borrowed Array mutators, +index assignment, `length` assignment, deletion, and property definition cannot +change the frozen executor or retain a callback; failure follows ordinary strict- or +sloppy-mode JavaScript semantics and `length` remains `0`. + +#### 5.4.1 Programmatic ad-unit registration + +The clean public core surface retains programmatic direct-auction registration: + +```ts +interface ProgrammaticAdUnit { + code: string + mediaTypes: { + banner: { sizes: readonly (readonly [number, number])[] } + } + bids?: readonly { + bidder: string + params?: Readonly> + }[] +} + +interface AddAdUnitsResult { + readonly registered: readonly string[] +} + +type AdUnitRegistrationErrorCode = + | 'invalid_units' + | 'invalid_unit' + | 'invalid_code' + | 'duplicate_code' + | 'slot_collision' + | 'invalid_media_types' + | 'invalid_dimensions' + | 'dimensions_out_of_range' + | 'invalid_bids' + | 'invalid_bidder' + | 'invalid_params' + | 'request_body_too_large' + | 'registry_capacity' + +class AdUnitRegistrationError extends Error { + readonly code: AdUnitRegistrationErrorCode + readonly unitIndex?: number +} + +class TsjsUnavailableError extends Error { + readonly code: 'runtime_unavailable' + readonly releaseId: string + readonly reason: 'abi_mismatch' | 'bundle_partial' +} +``` + +Registration is synchronous, all-or-nothing, and navigation-scoped. `code` becomes +the exact slot id and must satisfy the server slot-id UTF-8 bound from §4.6. The +argument is one unit or a nonempty array of at most 256 plain data objects. Codes are +nonempty and unique in the call; banner sizes are nonempty integral number pairs in +the shared 1–4096 renderer range; bidder names are nonempty and at most 64 UTF-8 +bytes; params are plain JSON-compatible data with the same request-body cap as +`/auction`. Accessors, +unknown media types, duplicate codes, or a collision with any server-projected or +already registered slot reject the whole call with a typed +`AdUnitRegistrationError` before state changes. There is no merge-by-code behavior. +The outer shape/count maps to `invalid_units`; a non-plain unit or unknown/accessor +unit field to `invalid_unit`; code shape, in-call duplicate, and registry collision to +`invalid_code`, `duplicate_code`, and `slot_collision`; media/banner shape to +`invalid_media_types`; a nonnumeric/nonfinite/fractional/nonpositive dimension to +`invalid_dimensions`; an integral dimension outside 1–4096 to +`dimensions_out_of_range`; bid-array and bidder-name shape to +`invalid_bids`/`invalid_bidder`; non-JSON, cyclic, accessor-bearing, or otherwise +unserializable params to `invalid_params`; and encoded body overflow to +`request_body_too_large`. `unitIndex` is the lowest failing input index when the +failure belongs to a unit and is absent for outer/capacity errors. Validation order +is the order just listed, then capacity reservation; repeated runs return the same +code/index without reading publisher accessors. + +Successful units receive registration ordinals after the immutable server projection +and participate in later `requestAds` snapshots. They use the direct auction/render +path unless an explicit future design gives them a GPT mapping; registration alone +never defines, displays, refreshes, or targets GPT. The old placeholder-writing +methods are deleted rather than aliased. `tsjs.log` retains the existing bounded +level/method surface, while runtime configuration is immutable boot data or typed +integration-owned configuration. Fallback validates `addAdUnits` input and then throws +`TsjsUnavailableError` with the committed boot failure; it constructs no registry. + +The retained logger has this exact hard-cutover surface: + +```ts +type TsjsLogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug' + +interface TsjsLog { + setLevel(level: TsjsLogLevel): void + getLevel(): TsjsLogLevel + error(...values: readonly unknown[]): void + warn(...values: readonly unknown[]): void + info(...values: readonly unknown[]): void + debug(...values: readonly unknown[]): void +} +``` + +The initial level is `warn`. `setLevel` accepts only the five exact strings above; +an invalid runtime value throws `TypeError` without changing the current level. +Missing/throwing console methods are swallowed at the logger boundary, log failures +never change ad behavior, and the logger does not retain argument arrays. The +fallback exposes the same logger and level behavior. + +### 5.5 Direct auction API + +```ts +interface RequestAdsOptions { + slots?: readonly string[] + timeoutMs?: number + signal?: AbortSignal +} + +type RequestAdsInputErrorCode = + | 'invalid_options' + | 'invalid_slots' + | 'empty_slots' + | 'duplicate_slot' + | 'invalid_timeout' + | 'invalid_signal' + +class RequestAdsInputError extends Error { + readonly code: RequestAdsInputErrorCode +} + +type RequestAdsSlotResult = + | { slot: string; path: 'primary' | 'fallback'; outcome: 'accepted' } + | { slot: string; path: 'primary' | 'fallback'; outcome: 'no_bid' } + | { + slot: string + path: 'primary' | 'fallback' + outcome: 'failed' + reason: RenderFailureReason + } + | { + slot: string + path: 'primary' | 'fallback' + outcome: 'cancelled' + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed' + } + +interface RequestAdsResult { + slots: RequestAdsSlotResult[] +} +``` + +Every `RequestAdsOptions.slots` entry is an exact, case-sensitive registered slot id: +either a server slot id from §2.2 or a programmatic `code` admitted by §5.4.1. The +public API never interprets an entry as a GPT ad-unit path, DOM id, or DOM alias. An +explicit id absent from the invocation snapshot resolves individually as +`failed{reason:'slot_unresolved'}` while valid siblings proceed. Internal GPT-path or +DOM-alias lookup that has zero or multiple matches also fails the affected slot as +`slot_unresolved`; it never selects the first registration. + +When `slots` is omitted, `requestAds` synchronously snapshots every server-projected +and programmatic slot registered in the current `NavigationSession`, ordered by its +navigation-local registration ordinal. That immutable snapshot is the batch +membership and result order; a slot registered after invocation is excluded. When +`slots` is present, its validated input order is the result order. The `slot` field in +every result is always the exact registered slot id. + +When `timeoutMs` is omitted, the shared auction-response deadline is exactly 10,000 +milliseconds. An explicit value replaces that default and must satisfy the bounds +below; renderer/GPT path deadlines remain independently fixed by their lifecycle +transitions. + +Omitted/`undefined` options are valid. Otherwise the argument must be a non-null +plain object whose prototype is this realm's `Object.prototype` or `null`, containing +only `slots`, `timeoutMs`, and `signal` as own enumerable data properties. Accessors +or unknown keys are `invalid_options`; non-array slots, non-string/empty/>256-byte +slot values, and more than 256 values are `invalid_slots`; an explicitly empty array +is `empty_slots`; an exact duplicate is `duplicate_slot`; a noninteger timeout +outside `100..30_000` is `invalid_timeout`; and a value that fails the platform +`AbortSignal.prototype.aborted` brand getter is `invalid_signal`. These reject +before attempts with `RequestAdsInputError`. Once an attempt is created, the +returned promise resolves with per-slot terminal results and does not reject for +auction or render failures. Unknown requested slots fail individually while valid +siblings proceed. Omitted slots with an empty registry resolve an empty `slots` +array. A registration collision cannot alter the snapshot or result ordering. + +The response deadline governs only the shared auction fetch. Once a response parses, +path-specific renderer deadlines take over. The caller signal remains active until +all child attempts settle. + +### 5.6 Adapters and external readiness + +GPT and Prebid adapters expose `present | pending | timed_out | incompatible`. +`timed_out` and `incompatible` are not permanent global failures: a later valid +external replacement can satisfy later operations. +Each queued operation owns its own deadline and disposal; an expired operation is +removed instead of running unexpectedly when the external library appears later. +Each adapter queue holds at most 64 live operations, drains FIFO, and fails only the +overflowing operation with `external_queue_full`. The operation deadline is exactly +ten seconds from enqueue and is independent of the auction-fetch response deadline; +expiry is `external_ready_timeout`. Readiness at the boundary races through the +operation's terminal latch, so exactly one of dispatch or timeout wins. + +The GPT adapter owns early event subscription, command-queue interaction, +`display`, `refresh`, targeting, and service-state inspection. The Prebid adapter +owns commands, event subscription, bid-response registration, and Universal +Creative integration. Tests use adapter fakes rather than mutable global objects. + +The decoupled Prebid artifact remains pure Prebid.js and retains its independent +five-second queue-drain watchdog. The generated wrapper arms that watchdog as its +first statement, before stamp inspection or module initialization. At 5,000 ms it +looks up the then-current real Prebid object and calls its idempotent `processQueue()` +at most once for that wrapper, whether or not the TS integration installed, so +publisher callbacks are not held hostage by stamp conflict, a missing TS bundle, or +a partial/duplicate artifact. Duplicate wrappers may reach the idempotent API, but +black-box tests require each queued publisher callback to execute once. A later TS integration module may call the idempotent API again. +The module first verifies the real Prebid API, then installs transactionally. It does +not install the synthetic-refresh policy, clear targeting, or mutate publisher bids +when only the injected `{que,cmd}` stub exists. The artifact manifest carries both +module stems and runtime bidder codes, including aliases. + +The artifact exposes this frozen plain-data runtime stamp; the separately emitted +build manifest contains the same fields plus filename/integrity metadata: + +```ts +interface ExternalPrebidArtifactV1 { + readonly abi: 1 + readonly artifactReleaseId: string + readonly prebidVersion: '10.26.0' + readonly moduleStems: readonly string[] + readonly bidderCodes: readonly string[] + readonly bidderAliases: readonly { + readonly code: string + readonly moduleStem: string + }[] + readonly userIdModules: readonly { + readonly moduleName: string + readonly configNames: readonly string[] + readonly eidSources: readonly string[] + }[] +} +``` + +After arming the watchdog and before executing embedded Prebid module factories, the +wrapper inspects the current `window.pbjs` and its own descriptor for +`__trustedServerArtifactV1`. If that object already has the required real API plus a +valid recursively frozen stamp, an exact same-release/content duplicate reuses it and +skips module initialization/redefinition; a different valid release refuses only the +new wrapper and likewise leaves the already-working object untouched. A different +artifact can become active only by replacing the whole `window.pbjs` object. + +Otherwise the wrapper initializes its embedded pure Prebid modules. It then attempts +to define one own non-enumerable, non-writable, non-configurable data property whose +value is the recursively frozen `ExternalPrebidArtifactV1` only when the descriptor +is absent. Define failure is caught locally. An accessor, inherited-only value, +invalid stamp, or hostile/different non-configurable value is left untouched and +records at most one bounded console warning when possible. None of these paths +throws, cancels the already-armed watchdog, or prevents publisher Prebid from +initializing; they make only TS readiness incompatible. + +This inert build description is the artifact's only Trusted Server handshake. Apart +from the independent Prebid queue self-start watchdog specified above, the generated +wrapper performs no auction, admission, render, targeting, or refresh behavior. The +Prebid adapter reads the property only through `Object.getOwnPropertyDescriptor`, +rejects an accessor or inherited value, and captures both the `pbjs` object and stamp +identities in one `PrebidArtifactBinding`. Every operation rechecks both identities; +replacement of `window.pbjs` invalidates only that binding and later readiness may +bind the new object if it carries a valid stamp. No `window.__tsjs_*` stamp or +fallback lookup exists. + +The artifact build contains exactly one 64-zero-character release sentinel in that +runtime stamp. It hashes the emitted JavaScript after normalizing that one field back +to the sentinel, writes the resulting 64 lowercase hexadecimal SHA-256 characters +into the field, and verifies that no sentinel remains. The separately emitted build +manifest records that same `artifactReleaseId` plus the ordinary SHA-256/SRI of the +final bytes. Thus the embedded id has a non-self-referential preimage; it is +diagnostic artifact identity, not a requirement to match the TSJS `releaseId`. + +`prebidVersion` must be exactly `10.26.0`; changing it requires the reviewed +artifact-contract fixture update in §3.5. Module/code/config names are nonempty, +unique in their array, and at most 128 +UTF-8 bytes; EID sources are lowercase, nonempty, unique per module, and at most 256 +UTF-8 bytes. The manifest admits at most 256 module stems, 512 bidder codes, 512 +alias rows, and 128 user-ID modules with at most 64 config names and 64 EID sources +each. Every alias code appears in `bidderCodes`, every alias module appears in +`moduleStems`, and each configured `client_side_bidder` must appear in +`bidderCodes`. Arrays are lexically sorted so build/runtime fixtures compare exact +content. + +A missing stamp, wrong `abi`, invalid release/version, malformed/oversized member, +missing configured bidder, missing required user-ID module/EID mapping, or a real API +missing a required method makes the current readiness operation +`external_artifact_incompatible`. It records one bounded local diagnostic and does +not install TS refresh interception or mutate publisher state. An older unstamped +artifact remains ordinary publisher Prebid: its own 5,000 ms watchdog drains its +queue exactly once, and the later TS module does not replay publisher callbacks. +Replacement by a valid artifact can satisfy later operations. Compatibility requires +both `abi:1` and exact Prebid 10.26.0; external artifacts are not pinned to a TSJS +release id. + +### 5.7 GPT correctness retained during decomposition + +- Subscribe to `slotRequested` and `slotRenderEnded` before any TS request. +- Pass `changeCorrelator: false` for TS refreshes unless the explicit configuration + says otherwise. +- Call `enableSingleRequest()` only before services are enabled; never reconfigure a + publisher-owned GPT service after `enableServices()`. +- Restore the intended initial-load behavior represented by issue #922/PR #997 and + pin it with tests. +- Responsive-size ambiguity fails `slot_unresolved` and never silently skips or + chooses an arbitrary container. +- A TS fallback slot is defined on the resolved inner div, never its outer + `-container`. An exact later publisher `defineSlot` receives that same live slot + even when its path/formats differ, with a local mismatch warning, because defining + a second physical slot would violate the one-placement invariant. A + hydration-renamed alias is accepted only when the original element is gone and + exactly one live, unclaimed TS fallback shares the configured prefix, exact GAM + path, and normalized formats. Ambiguity remains native and cannot transfer TS + ownership. +- Successful handoff synchronously transfers ownership, removes the slot from the + TS destroy set, suppresses exactly the publisher's duplicate initial `display`, + and under disabled initial load suppresses exactly its duplicate first refresh. + A global refresh expands the live GPT slot list, filters only one-shot suppressed + slots, and forwards unrelated slots with the original options. +- Publisher calls are not held until TS targeting is ready. A publisher-owned + display/refresh remains publisher work, and its failures cannot start TS fallback. +- Every TS-owned GPT destroy/redefine uses one adapter transaction. It first marks + the exact old object and cycle retired, then calls `destroySlots([old])` and + requires a successful return before attempting `defineSlot` for a replacement. + A throw/false destroy leaves the old identity retired and its path/aliases + quarantined, defines no second physical slot, and makes current or next TS work + fail `gpt_request_failed` until publisher destruction or reload. If destroy + succeeds but replacement definition fails, the slot stays unbound and the same + failure is returned; bindings and ownership commit only after one replacement is + successfully defined. A stale generation after either call disposes any newly + created TS-owned replacement and cannot bind it. Request-timeout, completion- + timeout recovery, navigation replacement, and DOM reconciliation all call this + transaction rather than open-coding destroy/redefine. +- Runtime-owned DOM reconciliation detects when a framework replaces the element of + a TS-owned live slot. It debounces changes, retires/destroys only the orphaned + TS-owned GPT object, resolves the unique current element, and rebinds within the + current navigation. One `MutationObserver` per `NavigationSession` watches + `childList` changes under `document.documentElement`; it is disconnected on + navigation disposal. Once an exact owned element becomes disconnected, that slot + opens a 5,000 ms reconciliation window on the monotonic clock. The first resolution + pass runs after 250 ms without another relevant mutation; if it is unresolved or + ambiguous, exactly one final pass runs at the 5,000 ms boundary. A pass that finds + one unique current element may win the slot's terminal reconciliation latch only + after the destroy/redefine transaction commits its replacement. Destroy or define + failure settles current work as `gpt_request_failed`; it is not counted as a + successful rebind. The window expiry racing a successful final pass goes through + the same latch: success wins only if the unique replacement was committed first; + otherwise the slot records `slot_unresolved` and runs the failed-reconciliation + disposer. That disposer + cancels the slot's active TS request cycle, tombstones its live render reservation, + compare-restores only targeting still equal to TS-installed values, clears every + exact/alias binding to the orphan, and asks the GPT adapter to destroy that exact + still-TS-owned object. Before the destroy call it marks the object/cycle retired in + weak identity state, so a throw or later GPT callback is quarantined and cannot + re-enter selection, fallback, trace attribution, or targeting. It then releases + the timer, candidate set, and all strong references. Successful destruction + settles a nonterminal current attempt as `failed{reason:'slot_unresolved'}`; + throw/false destruction settles it as `failed{reason:'gpt_request_failed'}` and + adds one bounded local warning. Neither outcome restores ownership. + + A second disconnect after one successful rebind may open one final window; two + successful rebinds is the per-slot, per-navigation maximum. A further disconnect + immediately runs that same disposer with + `failed{reason:'reconciliation_capacity'}` and cannot rebind again before the next + `NavigationSession`. Navigation disposal uses the same physical-object/targeting/ + cycle cleanup without emitting a new failure. Reconciliation never destroys a + transferred or otherwise publisher-owned slot, and ownership transfer racing any + pass wins the latch, cancels TS reconciliation state, removes TS destroy ownership, + and leaves physical/targeting cleanup to the publisher. + +- The Prebid refresh policy preserves the exact `excluded_gam_ad_unit_path_suffixes` + behavior: path matching is literal/case-sensitive suffix matching; missing, + non-string, or throwing `getAdUnitPath()` fails open; stale TS/Prebid keys are + cleared from every target; only eligible slots enter the synthetic auction; and + the complete target slot list plus original options still reaches GPT. +- Use one adapter-level refresh interception and one slot-service request path; + remove the three independent integration wrappers without removing handoff or + exclusion semantics. +- Preserve the current-main collapsed-shell resize as a guarded exception tied to the + current attempt. It runs only after a TS PUC response is posted and only when the + source is the exact connected iframe, width/height attributes and computed size + are still at most one pixel, dimensions are finite/positive, the frame/wrapper are + ordinary non-fixed/non-sticky display shells, and no anchor container is present. + Only that iframe and its still-collapsed immediate wrapper may be resized. + +### 5.8 Local diagnostics + +The kernel owns one bounded, failure-isolated diagnostics ingress. Render attempts +and the GPT adapter call its closure-bound `publish(candidate: unknown): boolean` +only after their correctness transition. An active ingress accepts only a data tree +whose root is an ordinary or null-prototype record and whose descendants are null, +booleans, finite numbers, strings, dense arrays, or ordinary/null-prototype records. +Records must contain only own enumerable string data properties. Arrays must contain +only their exact own `length` plus dense own data elements `0..length-1`; extra +properties are invalid. Symbols, accessors, functions, `undefined`, bigint, +non-finite numbers, sparse arrays, custom prototypes, cycles, repeated object +references, and any proxy/trap failure are invalid. Runtime-local GPT slot identity +therefore crosses this boundary as a bounded string token, never as retained object +identity. + +The executable ingress limits are: + +```ts +const MAX_DIAGNOSTICS_OBSERVATION_DEPTH = 16 +const MAX_DIAGNOSTICS_OBSERVATION_NODES = 512 +const MAX_DIAGNOSTICS_PROPERTY_NAME_BYTES = 128 +const MAX_DIAGNOSTICS_STRING_BYTES = 4096 +``` + +The root has depth zero. Every encountered root, property value, or array element, +including a primitive, consumes one node, so a flat record with 511 scalar values is +the widest accepted record and one with 512 is rejected. Property-name and string +limits count UTF-8 bytes independently. At or below every limit, ingress builds a +fresh tree from own data descriptors, uses null-prototype objects for copied records, +deep-freezes the copy, and retains no producer-owned array or record. A limit, +descriptor, copy, encoding, or freeze failure returns `false` without invoking the +reducer and never throws into the source operation. + +After a successful snapshot, ingress invokes the closure-private core trace reducer +exactly once, synchronously. Reducer or local error-reporter failure is caught and +cannot alter the source transition; because transport acceptance already succeeded, +`publish` returns `true`. The ingress has no integration-module or publisher +subscription API, listener identity, pending-delivery queue, scheduler/timer, +overflow callback, or subscription-capacity constant. Its exact returned facade is +frozen and contains only `publish` and `dispose`. `dispose` is idempotent, clears +retained owner callbacks, and makes the bound `publish` return `false`; a retained +facade from a disposed/replaced runtime epoch is likewise inert. Navigation-scoped +producers must pass their owner-generation check before publication, and the reducer +also ignores a semantically stale fact without treating diagnostics as authority. + +The core-ingress representation of one physical GPT slot is the exact branded data +token `GptSlotTokenV1`. The sole GPT adapter owns its mint and no publisher input can +select it: + +```ts +type GptSlotTokenV1 = string & { readonly __brand: 'GptSlotTokenV1' } +type GptTraceCycleOrdinalV1 = number & { + readonly __brand: 'GptTraceCycleOrdinalV1' +} +const MAX_GPT_SLOT_TOKEN_ORDINAL = 4_294_967_295 +const MAX_GPT_SLOT_TOKEN_BYTES = 11 +const MAX_GPT_TRACE_CYCLE_ORDINAL = 4_294_967_295 +const MAX_GPT_TRACE_CYCLES_PER_SLOT = 10 +// Exact wire grammar: /^gt1_(?:[1-9a-z][0-9a-z]{0,6})$/ plus decoded value <= 0xffffffff. +``` + +The adapter starts a runtime-local unsigned ordinal at one and emits `gt1_` plus its +lower-case canonical base-36 form, with no leading zero. It increments only after a +successful mint, stores the string beside the adapter's private opaque identity in +the `WeakMap` entry for that exact GPT slot object, and copies it into any runtime +`SlotRecord` that adopts that object as its exact optional own-data field +`readonly traceToken?: GptSlotTokenV1`. Re-observing or handing off the same physical +object returns the same string. A newly defined/replacement object receives a new +ordinal even when it has the same element id, ad-unit path, registered slot id, or +publisher owner. Ordinals are never reused within one runtime; physical destruction, +retirement, navigation disposal, map pruning, and garbage collection do not rewind +the counter. Runtime disposal clears the weak/map state and makes every old publisher +inert before a later runtime may begin again at one. + +The adapter's `gpt.events.v1` facts retain their separate frozen opaque object token +because that direct bounded stream never crosses the generic ingress and takeover +GPT diagnostics needs same-object identity. Before publishing a GPT fact to +`trace.v1`, the GPT integration creates a data-only projection that replaces the +opaque token with the exact own-data field +`slot:{token:GptSlotTokenV1,cycleOrdinal:GptTraceCycleOrdinalV1,elementId?:string}`; +no object-valued identity enters the snapshot. Neither identity is public or render +authority. + +The adapter owns a separate unsigned trace-cycle ordinal in each physical object's +`WeakMap` state. The first unambiguous `slotRequested` for that object mints one and +each later unambiguous physical request cycle increments it. It starts at one, +increments only after the sole lifecycle adapter has opened that exact cycle, never +wraps or reuses a value for the object, and survives publisher handoff. Every +projected value must be an integer from 1 through 4,294,967,295; the reducer rejects +zero, fractions, non-finite values, and larger values. A duplicate or overlapping +`slotRequested` that the §2.4 lifecycle owner cannot attribute opens no trace cycle +and emits no trace projection. Exhaustion at 4,294,967,296 latches new trace-cycle +projection unavailable for only that physical object; its GPT lifecycle and +`gpt.events.v1` stream continue unchanged. + +The adapter retains at most ten trace-cycle records per physical object, keyed by +the ordinal, with request state, optional `responseIdentifier`, and per-event seen +state. At most one cycle is open. Before opening another, it prunes the oldest +completed/retired record only when the ten-record cap is full; it never evicts an +open record. Pruning sets a permanent `unknownPriorCycle` latch for that physical +object; the latch retains no old id but participates in future ambiguity checks and +is cleared only when that object/runtime is disposed. + +A non-request GPT fact receives a cycle ordinal only from the §2.4 lifecycle owner's +exact already-attributed cycle handle, or when response identity and retained +per-event state leave exactly one eligible record and `unknownPriorCycle` cannot be +the source. It is never assigned to the newest cycle by timing, element id, or slot +token alone. A late `slotResponseReceived`, `slotRenderEnded`, `slotOnload`, +`impressionViewable`, or `slotVisibilityChanged` that could belong to both a prior +cycle and a newer started/completed cycle is ambiguous and produces no core-ingress +projection. A uniquely matched old fact keeps the old ordinal even after a newer +cycle starts. Eviction makes an otherwise unmatched later fact a diagnostics-only +drop rather than a candidate for the current cycle; no callback can recreate an +evicted ordinal. Destroy, redefine, and navigation disposal retire all retained +cycles for the affected physical object, while runtime disposal clears the weak +state. + +If the slot-token ordinal is exhausted, token/cycle construction or validation +fails, or an injected test mint collides, the adapter emits no affected core-ingress +projection and continues the GPT operation plus `gpt.events.v1` delivery unchanged. +Already minted slot tokens and unambiguous cycles on other objects remain usable. +The failure is reported at most once per failure class through the local logger and +cannot fail a display, handoff, destroy, refresh, or diagnostics callback. + +On the first accepted `slotRequested` projection, the core trace reducer resolves +the projection's bounded element id to exactly one current registered slot and keys +its 256-entry diagnostics-only physical-impression map by the exact pair +`{token,cycleOrdinal}`. The binding is +`{slotId,navigationGeneration,baselineSeq?,historySeq?,state}` where `state` is +`open`, `completed`, or `retired`. An unresolved, ambiguous, duplicate-active, +stale-generation, or over-capacity binding is dropped without evicting an open entry. +Later facts join only by the exact pair; token alone, element id, ad-unit path, and +registered slot id are never substitutes. `slotRenderEnded` stores the exact created +or enriched `historySeq` and completes the binding. Handoff of the same physical +object keeps its token but each refresh receives a distinct cycle binding; +redefine/replacement changes both the physical token and its per-object cycle +sequence. Destroy or navigation disposal retires affected bindings. A late fact for +a uniquely retained retired pair may enrich only its already-recorded, still-retained +old `historySeq`; it cannot create a row, rebind to a new cycle/generation, or mutate +new `current` state. Completed/retired bindings are pruned oldest-first before +admitting a new binding, open bindings are never evicted, and runtime disposal clears +the map. Exhaustion, collision, stability, handoff, repeated refresh, replacement, +retirement, late-event, and navigation behavior remain diagnostics-only and never +participate in GPT lifecycle authority. + +The takeover `gpt_diagnostics` module does not subscribe to that ingress. It consumes +only the separately bounded GPT-owned `gpt.events.v1` fact stream. The broker values +for `trace.v1` and `trace.presentation.v1` are different frozen exact interfaces: +`trace.v1` contains correctness fact operations and ingress publication but no +presentation attachment, while `trace.presentation.v1` contains only +`attachPresentation`. The release catalog grants the latter only to deferred +`diagnostics_presentation`; APS, GPT, `gpt_later`, publishers, and the public API +cannot obtain it. + +The private capability has this complete interface; no other own key is permitted: + +```ts +interface RenderTracePresentationSourceV1 { + current(): Readonly>> + history(): readonly Readonly[] + subscribe(listener: () => void): () => void +} + +interface RenderTracePresentationControlsV1 { + dispose(): void +} + +type RenderTracePresentationFactoryV1 = ( + source: Readonly +) => Readonly + +interface TracePresentationCapabilityV1 { + attachPresentation(factory: RenderTracePresentationFactoryV1): () => void +} +``` + +`attachPresentation(factory)` is synchronous and admits at most one live attachment. +A non-callable factory, a reentrant/duplicate call, or a call after trace-owner +disposal throws `TypeError` without disturbing an existing attachment. Callability +is checked before attachment state, so a non-callable duplicate still leaves the +first attachment untouched. The callable receives one exact frozen source with only +`current`, `history`, and `subscribe`. While attached, `current()` and `history()` +return the same frozen copies as the public trace API. After detach/owner disposal, +retained source methods are inert: `current()` returns a frozen null-prototype empty +record, `history()` returns a frozen empty array, and no runtime state is retained. + +`source.subscribe` checks listener callability first and throws `TypeError` for a +non-callable listener. It then requires the attachment to be attaching or live and +requires that no private listener is already live; either failure throws `TypeError` +without replacing or unsubscribing the first listener. Success returns an idempotent +zero-argument unsubscribe function that removes only that exact listener. A later +subscribe may succeed after it unsubscribes while the attachment remains live; a +retained subscribe call after detach/owner disposal throws `TypeError`, and a retained +unsubscribe is a no-op. The factory must end its synchronous call with one live +listener, perform its initial snapshot render in the attaching task, and return an +exact frozen own-data `{dispose}` controls object. No presentation callback runs +during attachment, so that initial snapshot precedes all live delivery. + +If the factory throws, returns malformed controls, or returns without a live +listener, attachment rolls back the candidate listener and scheduled work, invokes +an own callable candidate `dispose` once when safely available, reports any cleanup +failure locally, and rethrows so only the deferred module transaction fails. The +attachment slot is then reusable. Once attached, every trace commit updates the +data store first and coalesces presentation notification into at most one owned +zero-delay task. The callback's return value is ignored; a throw is isolated and +reported, and the next commit can schedule again. The returned detach function is +idempotent: it invalidates the attachment generation, cancels pending work, clears +the private listener, and invokes controls `dispose` exactly once. Trace-owner +disposal performs the same sequence. Late scheduler callbacks and retained source, +unsubscribe, detach, or controls references are inert. This private attachment is +not counted against the 32 live subscribers per public diagnostics surface. + +`tsjs.diagnostics.renderTrace` replaces the mutable `tsjs.renders`, `renderLog`, and +`renderSeq` globals and the `tsjs:adRendered` CustomEvent with read-only snapshot and +subscription methods. The final schema is: + +```ts +type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' +type RenderTraceServedFromV1 = + 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid' + +interface RenderTraceRecord { + readonly slotId: string + readonly path: RenderTracePathV1 + readonly rendered: boolean + readonly elementId?: string + readonly auctionId?: string + readonly bidder?: string + readonly adId?: string + readonly bidId?: string + readonly creativeId?: string + readonly admHash?: string + readonly servedFrom?: RenderTraceServedFromV1 + readonly gamEmpty?: boolean + readonly injected?: boolean + readonly visible?: boolean + readonly count: number + readonly seq: number + readonly at: number +} + +interface RenderTraceDiagnostics { + current(): Readonly>> + history(): readonly Readonly[] + subscribe(listener: (record: Readonly) => void): () => void +} + +interface TsjsDiagnostics { + readonly renderTrace: RenderTraceDiagnostics + readonly gpt?: GptDiagnosticsApi +} + +class DiagnosticsSubscriberLimitError extends Error { + readonly code: 'subscriber_capacity' + readonly surface: 'renderTrace' | 'gpt' +} +``` + +Snapshots are frozen copies, not references to the runtime store. Subscription is +FIFO; unsubscribe is idempotent; a listener registered during dispatch begins with +the next observation. `count` is the positive per-slot impression ordinal, `seq` is +the positive runtime-global observation ordinal, and `at` is the initial record's +`Date.now()` epoch milliseconds; enrichment retains all three. The initial record and +every later enrichment commit to `current`/`history` first and return to the +correctness/publisher stack without invoking public code. After commit, the +diagnostics service snapshots the current subscriber ids and enqueues one frozen +full-record copy in a 200-entry FIFO keyed by `seq`; another enrichment pending for +that `seq` replaces both its queued snapshot and captured subscriber-id set without +changing order. A listener added after the initial commit may therefore receive a +later enrichment commit, but never the earlier snapshot. Overflow drops the oldest +pending notification and increments a diagnostics-only counter. One owned +zero-delay task drains the queue in observation order. A listener registered after a +commit cannot receive that committed observation; unsubscribe before delivery +suppresses its captured id; registration during delivery starts with the next +observation. A slow/non-returning listener can block only that later diagnostics task, +never the render/GPT transition that scheduled it, and a throw is isolated from later +listeners. This asynchronous frozen delivery is the timing/detail replacement +contract for the removed `tsjs:adRendered` event; no CustomEvent or compatibility +alias is emitted. + +Each public diagnostics surface admits at most 32 live subscribers. A 33rd +subscription throws `DiagnosticsSubscriberLimitError{code:'subscriber_capacity'}` +without adding the listener; unsubscribe immediately returns capacity. The +argument must be callable or `subscribe` throws `TypeError` before the capacity +check. The production core commits and freezes one stable `tsjs.diagnostics` facade +with the kernel. Correctness facts required from the first display, bounded snapshot +state, subscriptions, and the final public diagnostics APIs are produced by the +takeover lifecycle/GPT path after adopting initial facts from the handoff. DOM +presentation, badges, overlay, formatting, and +clipboard/download interaction may attach behind that facade after its deferred +module commits. Deferred diagnostics failure leaves the facade safe and bounded and +cannot affect rendering. Fallback exposes no diagnostics namespace because it +constructs no runtime. + +- sequence numbers are runtime-global across separately built IIFEs; +- current state is keyed by exact registered slot id and therefore capped by the + 256-record navigation registry. Slot/navigation disposal synchronously prunes its + current entry; history is document-runtime scoped, capped at 200, and evicts the + oldest row before append; +- one physical impression is one history row; a later bridge/GPT/visibility signal + enriches that row in place and cannot weaken prior `rendered` or `injected` truth; +- a publisher/GAM refresh with no current TS auction has no TS attribution; +- `gam-only` means GAM reported fill without proof TS placed the creative, while + `ok` requires TS placement plus visibility; +- DOM `data-ts-*` stamps remove absent/stale fields on every update; an old badge is + removed before the new status is considered; and +- the boot-armed local overlay remains bounded, newest-first, click-to-export, + and noninteractive with the creative. Overlay/export failure cannot affect ads. + +Diagnostics enablement is resolved before core preparation and transported only +through frozen boot data: + +```ts +interface DiagnosticsBootV1 { + readonly version: 1 + readonly renderTraceOverlay: boolean + readonly gpt: { readonly active: boolean } +} +``` + +The server always emits this complete value, defaulting to +`{version:1,renderTraceOverlay:false,gpt:{active:false}}`. Both objects must be +non-null plain objects with exactly the shown own enumerable data properties; +accessors, unknown/missing keys, or wrong prototypes/literals/types are +`abi_mismatch`, not silent diagnostics disablement. The bootstrap copies the validated +data and recursively freezes that copy before agent/takeover preparation; copy/freeze +failure is also `abi_mismatch`. `gpt.active:true` requires exactly one catalogued +takeover GPT-diagnostics collector/public API in `BootManifestV1`; `false` requires +that collector to be absent. Exactly one catalogued deferred diagnostics-presentation +module is required iff `renderTraceOverlay || gpt.active`; it replays bounded facts +into the enabled overlay/badge/export-interaction model. The inverse mismatch is also +`abi_mismatch` before any GPT diagnostics listener, buffer, or presentation module +exists. + +The existing render-trace server toggle resolves +`tsjs.boot.diagnostics.renderTraceOverlay`; TSJS does not read or mutate its cookie. +GPT diagnostics remains deployment-disabled by default. When configured, one exact +`ts_console=1|true` directive on an eligible GET document navigation enables the +host session and `ts_console=0|false` disables it; values are case-sensitive and +duplicate/unrecognized directives fail closed for that response. The server owns the +host-only HttpOnly session cookie, removes the reserved directive before publisher or +origin handling, preserves unrelated path/query/fragment data, and emits only the +resolved `gpt.active` boolean. The old +`window.__tsjs_gpt_diagnostics_active` flag and browser storage bootstrap are deleted. + +The GPT diagnostics integration module preserves the behavioral contract in +`docs/superpowers/specs/2026-07-28-gpt-runtime-diagnostics-overlay-design.md` unless +this design explicitly changes ownership or activation transport. It consumes raw +facts from the sole GPT adapter rather than registering another control wrapper. + +The buffer does not retain GPT event objects or arbitrary publisher data. Before +admission, the current owner normalizes each observation to one exact ordinary-data +`FirstDisplayGptFactV1` record: + +```ts +type GptDiagnosticEventV1 = + | 'slotRequested' + | 'slotResponseReceived' + | 'slotRenderEnded' + | 'slotOnload' + | 'impressionViewable' + | 'slotVisibilityChanged' + +type GptDiagnosticDispositionV1 = 'matched' | 'unmatched' | 'ambiguous' + +type GptDiagnosticIssueReasonV1 = + | 'no_request_cycle' + | 'overlapping_request_cycles' + | 'unknown_prior_cycle' + | 'invalid_event_order' + +interface FirstDisplayGptFactV1 { + readonly version: 1 + readonly event: GptDiagnosticEventV1 + readonly token: GptSlotTokenV1 + readonly runtimeSlotNumber: number + readonly cycleOrdinal: GptTraceCycleOrdinalV1 | null + readonly disposition: GptDiagnosticDispositionV1 + readonly issueReason: GptDiagnosticIssueReasonV1 | null + readonly capturedAtMs: number + readonly elementId: string | null + readonly adUnitPath: string | null + readonly isEmpty: boolean | null + readonly renderedSize: readonly [number, number] | null + readonly isBackfill: boolean | null + readonly slotContentChanged: boolean | null + readonly visibilityPercent: number | null +} + +const MAX_FIRST_DISPLAY_GPT_FACTS = 512 +const MAX_FIRST_DISPLAY_GPT_FACT_BYTES = 1000 +const MAX_FIRST_DISPLAY_GPT_FACT_SECTION_BYTES = 524_288 +const MAX_FIRST_DISPLAY_GPT_FACT_COUNTER = 4_294_967_295 +``` + +All shown keys are required and no other own key is permitted. The GPT adapter mints +`runtimeSlotNumber` once per exact physical slot from the global unsigned 32-bit +trace-slot ordinal already transferred in `FirstDisplayHandoffV1`; it is nonzero, +never reused, and remains paired with that physical token through takeover. +`capturedAtMs` is a finite nonnegative `performance.now()` value; `elementId` and +`adUnitPath` are null or the adapter's copied own 1–256-UTF-8-byte values with no +NUL/control characters. GPT getter throws, nonstrings, empty values, and oversized +values normalize to null. Rendered dimensions are null or integral 1–4096 values; +visibility is null or finite 0–100. Event-inapplicable fields are null. A matched +request fact has its newly minted cycle ordinal; later cycle-bound facts use the exact +uniquely attributed ordinal. A matched +`slotVisibilityChanged` fact may use `cycleOrdinal:null` because visibility is +slot-level; any deliberately unmatched/ambiguous fact uses `cycleOrdinal:null`. +`disposition` is exclusively the callback-coverage dimension. `issueReason` is null +when the fact adds no issue; otherwise it is the exact independent sequence/matching +reason, so a uniquely correlated invalid-order callback remains `matched` with +`issueReason:'invalid_event_order'`. Unmatched and ambiguous facts use the applicable +`no_request_cycle`, `overlapping_request_cycles`, or `unknown_prior_cycle` reason. +The token and cycle retain the grammars/caps above. +The canonical UTF-8 encoding of the complete normalized record must be at most 1,000 +bytes. The handoff subsection is exactly +`{facts:readonly FirstDisplayGptFactV1[],overflowCount:number,dropCount:number}`; +both counters are unsigned 32-bit integers that saturate at their maximum. Its +complete canonical UTF-8 encoding, including array/object punctuation, keys, and +counters, must be at most 524,288 bytes. Invalid, oversized, accessor-backed, or +unnormalizable observations are diagnostics-only drops and never enter the buffer or +affect GPT/render authority. A valid observation that would exceed the 512-entry +FIFO evicts the oldest fact and increments `overflowCount`; any normalization or +section-byte-cap refusal increments `dropCount`. Counter saturation does not affect +admission or takeover. + +When `gpt.active` is true, the current `ActiveRenderOwner` installs the six +documented GPT observations (`slotRequested`, `slotResponseReceived`, +`slotRenderEnded`, `slotOnload`, `impressionViewable`, and +`slotVisibilityChanged`) before any TS-owned GPT request. It owns one 512-entry FIFO +pre-collector fact buffer. Overflow evicts the oldest fact and increments one +diagnostics-only counter. On a direct-to-runtime page, persistent core creates this +buffer and replays it in order when the takeover diagnostics collector activates. +On an agent page, the agent creates it before the protected batch, maps every fact to +the canonical trace token/cycle owned by that epoch, and includes the final ordered +normalized `FirstDisplayGptFactV1` records plus overflow/drop counts in +`FirstDisplayHandoffV1`. That complete at-most-524,288-byte subsection occupies the +separate 512 KiB diagnostics allowance of the handoff's 8.5 MiB total cap. +Normalization and the FIFO enforce that bound before handoff; a fact that cannot fit is a counted +diagnostics-only drop and cannot consume replay-suppression or correctness space. +During the non-yielding takeover task, the agent disconnects its six listeners; the +persistent GPT adapter adopts the exact physical slot identities from the capsule, +reconstructs its private identity map from the transferred canonical tokens, +runtime-slot numbers, ad-unit paths, cycles, and next ordinal, replays the bounded +facts into the collector in original order, and installs one fresh six-listener set +before the task ends. Replay updates the same slot/cycle facts, callback-coverage +counters, and separately reasoned issue rows that continuous ownership would have +produced; it cannot renumber a slot or reinterpret a disposition as an issue. A GPT +callback can therefore run before or after, but never inside, the +owner transition. It is processed once by one epoch and no diagnostic fact is +duplicated. After replay, live facts fan out directly and the pre-collector buffer is +released. The later presentation module consumes the already bounded collector/store; +it never registers GPT listeners or recreates the raw-fact buffer. + +When inactive, neither epoch creates a diagnostics buffer or the four +diagnostics-only listeners. The sole current GPT adapter may still own +`slotRequested` and `slotRenderEnded` listeners required for ordinary ad correctness +under §5.7; inactive zero-side-effect tests measure that baseline and require zero +diagnostics-added listeners, DOM, timers, observers, API, or network work. At every +task boundary, active and inactive pages alike have listener ownership in exactly one +epoch. + +The GPT diagnostics store retains at most 64 observed GPT slot objects, ten request +cycles per slot, and 128 callback-issue records. It evicts the +least-recently-active slot or oldest cycle/issue before insert and increments the +corresponding export counter. An evicted GPT slot can re-enter only on a future +`slotRequested`; its monotonic request number is retained in a `WeakMap`, and earlier +non-request callbacks remain unmatched. Its public API shares the 32-subscriber cap +above. Exact slot identity/binding and element replacement, physical request cycles, +callback truth, timing fields, frozen bounded export, Shadow DOM overlay, badge +layers, SPA behavior, privacy, and non-interference remain. Diagnostic records are +memory-only; neither diagnostics surface writes localStorage, sessionStorage, +IndexedDB, or uploads data. The hard-cutover API is `tsjs.diagnostics.gpt`; the old +flag/runtime expandos and `tsjs.gptDiagnostics` alias are deleted. + +GPT public subscriptions use a separate one-entry latest-snapshot notifier and never +run from a GPT callback, adapter fan-out, store mutation, or binding observer. After +each committed store/binding change, the controller builds one frozen +`GptDiagnosticsExportV1`, snapshots current subscriber ids, and schedules one owned +zero-delay task. A later change before delivery replaces that pending snapshot and +subscriber-id set; this API signals current state rather than promising one callback +per raw GPT fact. Registration after a commit cannot receive that commit unless a +later change replaces the pending snapshot; unsubscribe before delivery suppresses +the captured id. Listener throws are isolated, and a slow/non-returning listener can +block only the diagnostics task. Module disposal cancels the task, clears the one +pending snapshot and subscriber set, and delivers nothing later. `snapshot()` remains +a synchronous frozen read with no subscriber invocation. Tests apply the same +subscribe/unsubscribe/slow/throw rules as render trace plus 0/1/2-update coalescing. + +### 5.9 Creative and remaining integration preservation + +`CreativeBootV1` in §5.4 is exact plain boot data. The server always emits it. A +disabled integration is exactly +`{version:1,enabled:false,clickGuard:false,renderGuard:false}` and has no `creative` +manifest member. When enabled but its config is absent, the server emits +`{version:1,enabled:true,clickGuard:true,renderGuard:false}`; explicit configuration +replaces the two guard booleans. The `creative_initial` slice and `phase:'takeover'` +creative module are required iff `enabled && (clickGuard || renderGuard)`; an enabled +configuration with both guards false has no browser module because it has no browser work. `enabled:false` +also requires its absence. An accessor, non-plain +prototype, missing/unknown key, wrong literal/version/type, disabled non-false guard, +or manifest mismatch is an `abi_mismatch` before any creative guard installs. The recursively +frozen `tsjs.boot.creative` is the only final inspection/configuration surface. +`globalThis.tscreative`, `globalThis.tsCreativeConfig`, `installGuards`, `setConfig`, +and `getConfig` are deleted, not aliased; changing guard policy requires a new boot/ +document generation. + +The initial slice installs the selected compare-restorable guard before publisher +creative activity. It transfers only bounded configuration/seen-node facts; §5.2.1 +compare-restores the provisional wrapper/observer, installs one fresh persistent +guard, and performs the bounded post-commit rescan. The creative takeover module +prepares inertly and activates transactionally exactly once in the kernel barrier. +Activation installs directly on a no-agent page and never stacks an agent guard. It enables the click guard when +`clickGuard` is true and the image/iframe dynamic-source guards when `renderGuard` is +true, but performs no current-main DOM rewrite. Only when +`clickGuard || renderGuard` is true, activation gives a still-loading document one +owned `DOMContentLoaded` callback to perform the current-main idempotent rescan after the +initial DOM completes; an already interactive/complete document gets no listener and +performs that scan from one staged `afterCommit` callback. Its +disposer removes that listener, observers, and owned DOM state and compare-restores a +patched constructor/property/function only if the current value is still the exact +wrapper installed by this generation. An absent creative module installs no wrapper, +observer, listener, scan, or DOM state when the integration is disabled or enabled +with both guard booleans false. SPA navigation retains the document-scoped +guards and their dynamic-node behavior; failed preparation/activation or full runtime +disposal removes them once. + +Creative processing keeps its current independent policy controls. Auction +sanitization remains explicit opt-in/default-off; rewriting retains its existing +setting/default and still runs on every delivery path where that setting applies. +When rewriting injects browser guards into an independent creative document, the +server emits a complete document-local boot controller followed by exactly one +content-addressed direct persistent artifact containing core, `render_runtime`, and +`creative`—and no publisher-page integration, agent slice, or deferred module. The +tag is the sole `script#trustedserver-js` and uses the same release identity as the +page runtime. Since no projected batch exists in that document, there is no agent, +attempt, or synthetic paint trigger. If creative is disabled or both guards are +false, rewriting injects no TSJS boot or artifact. A body-less fragment receives the same pair once at its +start; a document body receives it once at the start of the body. Boot construction +failure rejects rewriting rather than emitting a script-only or unauthenticated +creative. +The runtime click guard resolves and stores one validated absolute HTTP(S) URL before +navigation, rejects `javascript:`, `data:`, `blob:`, malformed, and credentialed +targets, and uses the established `/first-party/proxy-rebuild` GET redirect path to +recover clicks from the opaque sandbox. Dynamic resource/click rewriting, iframe +sandbox attributes, font/CORS handling, body/base handling, and the direct/SSAT/cache +delivery boundaries remain covered by unit plus real-browser tests. This APS/TSJS +work neither enables sanitization nor broadens creative privileges. + +Every other enabled TSJS integration becomes a thin transactional integration module without an +internal feature rewrite. Its complete current unit suite runs unchanged against a +pre-cutover fixture and a module-composed fixture. At minimum the parity corpus +proves: + +| Integration | Required preserved behavior | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DataDome | dynamic script/preload matching and fixed first-party route rewriting preserve the upstream path | +| Didomi | configured proxy path becomes the absolute `didomiConfig.sdkPath` without clobbering unrelated publisher config | +| Google Tag Manager | script/preload rewriting plus Google Analytics `sendBeacon`/`fetch` rewriting preserve method, body, and unrelated traffic | +| Lockr | script guard plus bounded SDK readiness polling rewrites only the initialized Lockr API host | +| Osano | USP/GPP/TCF cookie mirroring retains marker ownership, timeout, readiness, retry, event, focus/visibility, clear, and non-clobber semantics | +| Permutive | script guard, bounded SDK readiness, API-host rewriting, and at-most-100 normalized local segment values continue to feed auction context | +| Sourcepoint | optional SDK guard and Sourcepoint-owned GPP cookie mirroring retain localStorage shapes, marker ownership, initial retry, visibility/focus updates, and safe clearing | +| Testlight | preexisting and later callbacks bridge once into the final TSJS queue; invalid entries and one throwing callback do not block later work | + +The shared script/beacon/DOM-insertion guards keep integration-owned matchers and +routes. A shared helper may centralize interception, but it cannot broaden one +integration's matcher, reorder another integration's first-display/takeover startup, stack interception, +or leave a timer/listener after module disposal. Maximal-bundle tests load every +server-declared integration module through its real phase/trigger and deterministic +catalog-order initiation. They allow independent deferred completion order and +assert both behavior and exactly-once disposal, not merely successful registration. + +### 5.10 Error handling and bounded state + +No empty `catch` remains in the migrated kernel, adapters, or APS/GPT/Prebid paths. +Boundary failures become typed results and a concise local warning; disposer and +late-callback failures cannot escape into publisher code. Logs redact descriptors, +AAX payloads, account ids, creative URLs, auction bodies, and capability values. + +Every collection has a named owner, capacity, and pruning rule. Tests exercise +capacity, TTL, duplicate registration, replay, timeout, navigation replacement, and +late continuation behavior with fake timers. + +### 5.11 Decomposition targets + +The implementation extracts cohesive behavior rather than mechanically splitting +by line count: + +| Current area | Target responsibility | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| production `composition/browser` | deleted as a catch-all root; a new first-display composition owns only §5.2.1 slices, while persistent core constructs kernel/session/broker, API, diagnostics facade, and direct-auction coordination | +| `first_display/**` | fixed agent coordinator plus exact initial APS/GPT/Prebid/creative/parser-time slices, handoff serializer/capsule, protected paint, and no public runtime API | +| test composition seams | separate unshipped entry containing fake/no-op adapters, schedulers, corpus hooks, and `*ForTest` accessors | +| `gpt/index.ts` | persistent GPT owner that can adopt exact initial slot/cycle identities; the separate `gpt_initial` slice owns only the immutable projected request and transfer facts | +| `prebid/index.ts` | persistent Prebid owner that adopts initial artifact/queue/admission facts; the separate `prebid_initial` slice owns only initial readiness, bidder/user-ID/EID setup, and PUC admission | +| `prebid/later.ts` | deferred synthetic refresh and GAM-path exclusion only; it owns no initial admission, artifact-readiness, bidder, user-ID, EID, or publisher-queue behavior | +| `core/request.ts` | public validation, immutable selection, and thin `AuctionBatch` coordination; path implementations live behind injected capabilities | +| `core/render.ts` | only minimum path-independent first-display DOM/lifecycle helpers; APS/ADM live with their owner, while cache stays the current-main GPT-integration implementation | +| `kernel/diagnostics.ts` | bounded data-tree snapshot ingress and one closure-private reducer callback; no integration subscriptions, pending queue, scheduler, timer, or presentation authority | +| `core/trace.ts` | bounded correctness-fact reducer/store, public snapshots/subscriptions, and separately attenuated `trace.presentation.v1`; no DOM presentation code | +| APS maps in globals | runtime-owned bounded reservation capability supplied by the APS integration module | +| diagnostics overlay/UI | deferred owner of the sole private `trace.presentation.v1` attachment; never imported by production core or correctness producers | +| duplicated `script_guard.ts` | small per-integration factory compiled into the owning module; no central production root imports every matcher | +| optional integration implementations | remain in their integration IIFEs and register inert factories; they are absent from core bytes | + +The first-display and later slices of one product integration must retain the same +observable ownership and disposal contract. Splitting files or artifacts cannot +omit a correctness-required listener from its first-display slice, duplicate an adapter, +or turn a bounded typed failure into a readiness hang. Conversely, code used only +for refresh, later navigation, diagnostics presentation, test injection, or an +optional integration cannot remain in the first-display dependency graph for convenience. + +Source may be shared at authoring time only through effect-free leaf contracts whose +metafile contribution fits the agent budget. The first-display build must not import +the persistent core, capability broker, generic slot/auction/trace registries, later +GPT/Prebid module, or any deferred entry. It may use a dedicated compact parser and +state machine generated from the same neutral schema/corpus; parity tests, rather +than a production import from the persistent implementation, prevent drift. The +persistent build likewise imports no agent coordinator or provisional singleton. + +### 5.12 TypeScript and performance gates + +Before the coordinated runtime implementation proceeds, the TSJS direct development +toolchain is upgraded to the newest stable, mutually compatible versions supported +by the repository-pinned Node major. TypeScript advances to the newest stable release +inside the latest `typescript-eslint` parser's declared support range; an unsupported +compiler/parser pairing is not accepted merely to claim a higher version. The +external artifact dependency remains exactly `prebid.js@10.26.0`, and Node type +declarations remain on the pinned Node major. Those are explicit compatibility and +artifact-contract constraints, not permission to leave the rest of the toolchain +stale. The upgrade must pass a clean `npm ci`, a peer-clean `npm ls --all`, complete +build/lint/typecheck/tests, and exact Prebid artifact verification. + +After that upgrade, the lockfile compiler is the authority. CI runs a checked-in +`typecheck` script with `strict`, `noUncheckedIndexedAccess`, +`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `noImplicitOverride`, and +`useUnknownInCatchVariables`. Production bundles contain no dynamic `import()`; +the deferred loader uses only authenticated classic same-origin script elements as +specified in §5.2. + +The checked-in pre-change fixture is immutable historical evidence and is never +regenerated or rewritten. Its original `bundles` values measured a different +artifact model: minimal contained only the old core, reference omitted the now- +mandatory render owner, and maximal contained thirteen unsplit files. Those values +cannot be used as like-for-like ceilings or any other pass/fail decision. Their raw, +gzip, Brotli, provenance, and historical deltas remain report-only diagnostic +evidence. + +The existing `roleCorrectTransfer` subtree records the first role-correct capture +from the exact clean, pushed parent after Task 18D. Review established that this was +an oversized intermediate implementation, so its provenance and bytes stay +immutable but its self-derived 5% ceilings are not release acceptance. After +mechanical critical-runtime remediation, the implementation appended a distinct +`reviewRemediationTransfer` subtree to the same JSON without changing either earlier +subtree. That second immutable checkpoint records graph de-duplication and its +historical timing failure; neither fact authorizes or blocks the final release. +Both intermediate subtrees are report-only and define no size ceiling. After +first-display-agent remediation, the candidate evidence records its clean pushed +source SHA, toolchain/compression identity, release inventory, per-artifact hashes, +and these semantic sets: + +- **minimal first display** is `first_display` plus no optional slice; +- **reference first display** is the one served agent artifact for the semantic + reference `[first_display, creative_initial, gpt_initial, prebid_initial, +datadome_initial]`; it does not include core or any persistent takeover module; +- **APS first display** is the served artifact for + `[first_display, creative_initial, aps_initial, gpt_initial]` and drives a real + fictional APS/PUC contract fixture through the first request action; +- **largest permitted first display** is the largest raw, gzip, and Brotli body + among every mask that trusted configuration can serve (the maximizing mask may + differ per encoding); the capture enumerates all permitted masks, hashes, sizes, + and slice membership rather than checking only the two named examples; +- **persistent runtime** is `[core]` plus all catalogued takeover modules for the + reference configuration, served after protected paint; and +- **maximal total** is every first-display base/slice, production core, takeover, + and deferred TSJS module in + the release, each exactly once. This gate prevents phase splitting from hiding + total growth. + +The mandatory render implementation is physically co-bundled into `tsjs-core.js` +with the sole runtime so their shared dependency graph is emitted once. The +catalogued `tsjs-render_runtime.js` transport member is a release-stamped marker: +it preserves the logical provider row, manifest ordering, inventory accounting, and +capability contract but contains no second implementation, listener, timer, port, +or runtime. Servers still compose the catalogued `[core, render_runtime]` sequence; +the marker is not a second request and does not create a compatibility path. +The evidence capture records logical provider sources separately from physical +artifact ownership (`render_runtime` is physically owned by `core`) and rejects a +provider source in every other artifact even if a stale source-owner inventory tries +to authorize the duplicate. It also freezes the twenty largest rendered-source +contributions and every repeated attribution so review can see, rather than infer, +where transfer growth and shared-source duplication remain. + +In the same blocking job, a detached worktree at the exact freshly fetched +`origin/main` SHA builds the real production page for the reference configuration +with its current artifact model and default creative behavior. `mainReferenceTransfer` +is the exact raw/gzip/Brotli sum of every Trusted Server JavaScript byte delivered or +embedded from `tsjs:bids-script` through the first responsible GPT action. The +candidate records the same semantic interval for its controller, upstream-independent +TSJS transports, and agent; neither side relabels artifact names to manufacture +membership parity. Candidate reference transfer must be at most the current-main +value for each encoding. This semantic transfer comparison is independent of, and in +addition to, the paired 1.10 timing ratio below. + +The candidate evidence is accepted only after the first-display graph contains no public +runtime API, persistent broker/registry, diagnostics, refresh, SPA/navigation, +programmatic/direct-auction work, test seam, duplicate adapter owner, or live object +that cannot be disposed/transferred by §5.2.1. These independent absolute architecture +ceilings apply to the candidate and do not derive from any candidate capture: + +| Semantic set | Raw bytes | Gzip bytes | Brotli bytes | +| ----------------------------------------- | ----------- | ---------- | ------------ | +| inline bootstrap controller/fallback | ≤ 48,000 | ≤ 16,000 | ≤ 14,000 | +| every permitted first-display agent mask | ≤ 90,000 | ≤ 30,000 | ≤ 26,000 | +| reference persistent runtime after paint | ≤ 524,288 | ≤ 163,840 | ≤ 131,072 | +| maximal total, every production role once | ≤ 1,048,576 | ≤ 327,680 | ≤ 262,144 | + +The first-display ceilings are selected from the fixed 200,000-byte/s profile; the +post-paint and maximal limits prevent phase splitting or duplicated ownership from +making total release growth unbounded. They are not substitutes for the current-main +semantic transfer or paired timing gates. Changing any ceiling requires a reviewed +design rather than recapturing candidate history. + +The build emits one canonical release inventory with each production bundle's id, +role, phase, trigger, inputs, outputs, bytes, and hash. Budget membership is derived +from that catalog rather than an obsolete exact filename list. Candidate evidence +stores the exact raw, gzip, and Brotli values for bootstrap, every permitted +first-display mask (with named minimal/reference/APS/largest summaries), persistent +runtime, and maximal. It is evidence for that candidate, not a self-created baseline. +After the cutover lands, subsequent work still compares against the then-current +`origin/main` and the same independent ceilings; no candidate-side capture becomes +permanent authority merely by being checked in. + +The inline bootstrap-controller/fallback cannot be used to hide code outside those +sets. It receives its independent ceiling above, appears +exactly once under the `bootstrap` role, and is not counted again in maximal TSJS total. Its +production metafile/import allowlist permits only boot-manifest/queue/fallback +validation, generation/disposal, timing, and local logging primitives. + +`npm run check:bundle` builds fresh candidate metrics and runs these parts in CI: + +1. the original and two intermediate candidate captures and their digests are + validated and printed as immutable history, never as pass/fail ceilings; +2. the freshly built exact current-main and candidate reference pages enforce the + semantic transfer comparison; +3. candidate bootstrap/all-permitted-masks/persistent/maximal values enforce the + independent absolute ceilings; and +4. the one-time architecture/source-ownership assertions above remain blocking. + +A local main-less invocation enforces the absolute and architecture parts and marks +the semantic comparison unavailable; CI and release evidence require the exact +fresh-main comparison and fail if it is missing, stale, or not reproducible. + +The gate also rejects an unclassified or multiply counted artifact, a missing +production artifact, a test artifact, a maximal inventory that omits any split +module, first-display reachability to a persistent/deferred source, takeover +reachability to a deferred source, a consumer that inlines a catalogued provider +implementation, overlapping agent/persistent side-effect ownership, and +production reachability to fake/no-op/test or `*ForTest` sources. It reports the +largest source contributions and repeated production attributions so later work +cannot hide growth inside a passing aggregate. Changing historical evidence, +semantic membership, the current-main comparison procedure, or an absolute ceiling +requires a separate reviewed design; it is not an implementation escape hatch. + +Boot-to-first-display uses real User Timing marks, not `__tsjsPerf` or a test-only +placeholder. The bootstrap controller records `tsjs:bids-script` immediately before +the server's first-display head sequence, so the measure includes required upstream +and agent-artifact loading. The provisional owner records +`tsjs:first-display` +exactly once immediately before the first TS-owned request action in the protected +first-display batch: the responsible GPT `display`/`refresh`. Direct `/auction` +uses the no-agent persistent runtime and records the same mark immediately before +its iframe insertion, but is a separate correctness/timing case rather than an agent +mask. A page with no render attempt during the measurement is excluded +explicitly rather than manufacturing a mark. The terminal latch for the attempt that produced that action records +`tsjs:first-display-terminal`; after the complete immutable initial projection batch +settles and the §5.2 paint gate passes, the agent records `tsjs:first-display-paint`. +Each mark is emitted at most once per document runtime. The reference fixture asserts +that no persistent or deferred TSJS request, preload, preparation, or execution precedes +`tsjs:first-display-paint`; p90 remains the exact `tsjs:bids-script` to +`tsjs:first-display` request-action measure so the historical metric does not change +meaning. + +The standalone performance job uses pinned Chromium 145.0.7632.6, the +`github-hosted:ubuntu-24.04` runner class, fixture +`tsjs-main-paired-network-v2`, five warmups per variant, and 50 measured samples per +variant. It runs automatically when a pull request changes the TSJS build/runtime, +the server controller or projection path, the browser fixture, the evidence +validator, or the workflow itself. Its existing `workflow_dispatch` and +`workflow_call` entrypoints remain available for named pre-switch and post-switch +evidence. + +The job declares a paired GPT-reference case and a candidate-only APS first-display +case. Both variants of the GPT pair use the same projection, enabled behavior, +upstream fictional stubs, page markup, creative policy, warm/cold cache state, and +browser profile. The APS case drives the fictional APS/PUC contract through its +actual first action, terminal result, and paint, and must satisfy the size, mark, +ordering, deadline, and heap contracts; current `main` has no semantically equivalent +first-class APS action, so APS cannot be assigned a fabricated relative ratio. A GPT +pass never masks an APS failure. + +The candidate-only APS case is nevertheless a blocking quantitative gate, not only +a protocol-deadline check. Its checked-in fictional GPT, GAM creative, proxy, runner, +and PUC bodies and schedules are invariant test inputs; the fictional runner invokes +the real queued `prebid/creative/render` callback 50 ms after its API call. Across the +same five warmups and 50 measured samples, APS must meet all of these ceilings: + +| APS metric | Ceiling | +| ---------------------------------------------------------------------- | ---------------------------- | +| `tsjs:bids-script` to first responsible GPT `display`/`refresh` action | p90 ≤ 900 ms | +| first action to accepted APS completion | p90 ≤ 1,500 ms | +| accepted APS completion to `tsjs:first-display-paint` | p90 ≤ 250 ms | +| `tsjs:bids-script` to `tsjs:first-display-paint` | p90 ≤ 2,500 ms | +| forced-GC `usedSize` immediately after protected paint | ≤ 3,145,728 bytes (3 MiB) | +| forced-GC `usedSize` after persistent takeover and queue drain | ≤ 3,932,160 bytes (3.75 MiB) | + +Every timing row is computed from the named real marks/action, never a test-created +substitute. Every sample—not only p90—must still satisfy the narrower applicable +correctness deadline. Changing a fictional dependency body/schedule, a ceiling, a +mark endpoint, or the network profile is a performance-contract change requiring +review rather than a way to recapture a passing baseline. These deliberately broad +absolute guardrails cover the candidate-only behavior that has no honest `main` +ratio; the paired GPT current-`main` × 1.10 comparison remains the regression gate +for shared first-action and persistent-runtime costs. + +Each run fetches `origin/main`, resolves its exact current 40-character commit SHA, +creates a detached worktree at that SHA, and builds `main` and the candidate +independently. The main-side loader feature-detects and consumes the artifact shape +that commit actually produced. While current `main` emits the legacy `tsjs-core.js`, +`tsjs-creative.js`, plus `tsjs-gpt.js` model and no release-v1 inventory/controller, +the harness concatenates and serves those real built bytes, enables the same default +creative policy, and drives their real legacy `adInit` surface. After the cutover +reaches `main`, the same loader consumes that commit's release-v1 inventory/controller +and that commit's real server-selected first-display artifact instead. It does not relabel an older phase-aware +capture as `main` or require unavailable candidate-only metadata from a legacy +commit. The candidate side consumes its generated server controller and release-v1 +first-display/takeover/deferred artifacts. One in-process `node:http` server per variant on an +ephemeral `127.0.0.1` port serves that variant's exact page and bytes. Playwright +request interception or fulfillment is outside the instrument. + +Before either variant navigates, its page receives the same checked-in Chromium CDP +`Network.emulateNetworkConditions` profile: 150 ms latency, 1.6 Mbit/s download +(200,000 bytes/second), 750 kbit/s upload (93,750 bytes/second), and zero packet +loss. The profile is not selectable by environment input. A common comparison mark +runs immediately before the external first-display script element, and the GPT fixture +records the common terminal mark at its first observable `display` or `refresh` +action. A direct-render comparison records the equivalent iframe insertion. The +interval therefore includes first-display transfer, parse, evaluation, and +runtime work through the first observable request/render action without depending +on a candidate-only mark. Candidate runs additionally prove the real +`tsjs:bids-script`, `tsjs:first-display`, and `tsjs:first-display-paint` marks and +that no persistent or deferred TSJS request, preload, preparation, or execution +precedes paint. + +The no-agent direct `/auction` correctness fixture records +`tsjs:first-display-terminal` from its terminal latch and +`tsjs:first-display-paint` through the same two-frame/hidden allowance. Ordinary +deferred loading waits for that paint or the no-attempt guard exactly as specified +for direct-to-runtime pages. It is not counted as an agent-mask sample or used to +claim the agent's ≤90 kB transfer ceiling. + +The job alternates `main` then candidate / candidate then `main` in one Chromium +process for every warmup and measured GPT pair. GPT candidate p90 must be at most +current `main` p90 × 1.10. GitHub-hosted absolute timing is not stable enough for a +tight fixed shared-regression ceiling, and a historical fixed comparison SHA is not +an honest stand-in for current `main`; neither replaces that paired gate. The APS +absolute ceilings above are separate fixture guardrails. The schema-5 artifact records +the exact main and candidate SHAs, each actual artifact model, each exact served +first-display byte count, both full distributions and p90s, the alternating order, and +the exact network profile. The workflow runs each declared pair once and never +selectively reruns, drops, or reclassifies slow samples. Budget assertions are soft +only in the Playwright sense: the run finishes all timing and heap collection and +writes the complete schema-5 evidence before failing. Validation and upload run +with `always()` so a failed gate retains its exact diagnostic artifact; neither the +test nor the validator converts an exceeded budget into success. + +The historical GPT blocking ratio remains request-action latency, so the gate and job +call it **bids-script-to-first-action**, not paint latency. The same evidence records +candidate terminal and paint distributions for GPT and APS. Every sample must remain +inside the unchanged path-specific render deadline and §5.2 paint allowance; this +prevents an agent from improving transfer latency by postponing completion or +takeover without fabricating a non-equivalent current-`main` terminal/paint ratio. + +Retained heap for the paired GPT case uses Chromium CDP forced-GC checkpoints after +boot, first render, refresh, and SPA navigation. After the display samples, the job +opens one separate fresh browser context per variant and executes the equivalent +lifecycle supported by that variant's real artifact shape. The APS case uses its two +candidate-only checkpoints in the table above. At each checkpoint the job sends +`HeapProfiler.collectGarbage` once followed immediately by `Runtime.getHeapUsage`; +the single `usedSize` is the checkpoint statistic, with no hidden averaging, +maximum selection, or rerun. Candidate must be at most current `main` × 1.10 at each +checkpoint, and both variants must remain below the immutable 4 MiB hard ceiling. +Any checkpoint over either limit fails the one declared run; the job cannot replace +only that measurement or rerun only the heap fixture. Correctness runs independently +in Chromium, Firefox, and WebKit. Correctness failures are never waived by a +performance pass. + +## 6. Security and privacy + +1. Renderer iframes omit `allow-same-origin`; cross-origin target `"*"` is permitted + only when transferring a one-use port to the exact native iframe's already-checked + browsing-context `WindowProxy`. As §4.4 states, this binds the context, not an + opaque active `Document`; embedding-ancestor code with navigation authority is + trusted for that navigation-integrity property. +2. The initial global PUC request contains the opaque renderer reservation + capability but no descriptor, ADM, lifecycle ticket, or nonce, and it establishes + no success. The first compatible claim acquires the PUC source; render authority + begins only after exact reservation/slot lookup, attributable nonempty GAM, + current generation, source binding, and atomic consumption. +3. Lifecycle tickets and nonces are CSPRNG, one-use, TTL-bounded, never logged, and + invalidated on supersession/navigation. +4. Exact-key message parsing prevents confused-deputy extensions. Unknown versions + are ignored or failed closed according to whether the message claims a TS + capability. +5. Native Prebid messages with non-TS ids continue to native listeners. Any message + carrying a live or tombstoned TS id is suppressed before later validation. +6. The first-display handoff is not a public trust boundary. Its data record is + exact-shaped, bounded, recursively frozen, and descriptor/creative-free; its + object capsule is closure-private, same-task, release/generation-bound, and + one-use. No agent capability reaches `window.tsjs`, DOM attributes, messages, + logs, diagnostics, storage, or analytics. A forged/replayed/stale capsule fails + before persistent activation and cannot claim committed DOM or a GPT object. +7. The upstream APS runner URL, every creative URL, and production renderer/proxy + routes must be HTTPS. HTTP is permitted only for loopback hermetic adapters; their + fixed local proxy route remains covered by CSP `'self'`. The runner executes only + through the fixed-target, anonymous-CORS Trusted Server proxy. The proxy relays the + APS body unchanged but never stores it in source, forwards publisher credentials, + accepts a caller-selected target, or executes a fallback. Runner-created APS-origin + resources may use their own origin cookies under browser policy. The renderer + document accepts no cookie as authority. +8. Script creatives remain opt-in because they materially broaden executable + behavior. Enabling them requires a documented security review of the fixed + renderer CSP. +9. Persistent/deferred module URLs are generated from the local immutable release inventory, + are same-origin exact content-hash paths, and are authenticated by + release/id/source plus the core-created element and `document.currentScript`. + The narrowly scoped Trusted Types policy accepts only those frozen manifest URLs, + CSP nonces are copied only from the authenticated parser-inserted tag, and policy + failure cannot select another sink or source. Publisher-created tags and calls + cannot load or register code, and no deferred URL can select an upstream script + target. +10. Static TSJS hash mismatches and unknown paths fail locally on every adapter; a + stale URL never aliases current bytes or falls through to publisher origin. +11. The design adds no persistent identifier and no external event pipeline. + +## 7. Verification and acceptance + +### 7.1 Required test layers + +| Layer | Required proof | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Rust unit | APS parsing/admission, dimensions, scripts, AAX projection, mediation provenance/order/timeouts, targeting identity, descriptor serialization, endpoint headers/body | +| Main/gap audit | current-main tests are the behavioral oracle; every retained `RCJ-*` row starts proof-pending, identifies/authors a focused contract, runs it on the untouched recorded-main worktree, and ends main-owned or demonstrated implementation-gap with recorded SHA/owner/test command/result; proof-pending/coverage-gap blocks phase exit and no retired source is built or merged | +| Cross-language corpus | every positive/adversarial descriptor has the same Rust, TS, and embedded ES5 result; stale generation fails | +| TS unit | agent/takeover ownership, exact handoff/capsule admission, takeover/deferred transactions, fallback versus isolated deferred failure, trigger/disposal races, sessions, registries, selection/cycle/batch/latch APIs, adapter readiness, GPT handoff/reconciliation, Prebid artifact/refresh, creative security, diagnostics, and every remaining integration parity corpus | +| Hermetic browser | one parser-blocking first-display request, no pre-paint persistent/deferred traffic, authenticated atomic takeover into the one persistent runtime, all render paths, PUC bridge, three-level APS sizing, direct iframe races, owner/port/runner behavior, fallback, SafeFrame-shaped nesting, GPT handoff/hydration, creative clicks, diagnostics, and duplicate/replay/wrong-source/stale cases | +| Real-GAM test network | SSAT APS-PUC, Prebid-adapter APS-PUC, page-bids APS-PUC, direct APS, direct ADM plus current-main PBS Cache regression, fallback after attributable empty GAM, SRA, refresh, SPA, handoff, hydrated DOM replacement, and collapsed shell | +| Adapter parity | exact renderer sandbox/CSP/header bytes plus runner-proxy routing, five-second deadline, closed response parsing, bounded relay, header filtering, and failures match on all adapters | +| Regression | non-APS Cache/ADM and notifications, pure external Prebid/native bids/EIDs/user IDs/refresh exclusions, publisher GPT/handoff/SRA/SPA, creative processing/click recovery, render trace/GPT diagnostics, and every remaining integration remain correct | +| Quality | full-package TypeScript/lint including tests/scripts/build code, ESLint and release-catalog dependency boundaries, production-metafile/test-hook exclusions, format, clippy, Rust adapter suites, Vitest, artifact integration, Playwright, immutable historical bundle reporting, role-correct transfer budgets, performance/heap budgets, and complete maximal inventory | + +### 7.2 Mandatory race matrix + +Tests must cover at least: + +- duplicate simultaneous `Prebid Request` for the same id; +- claim before/after attributable nonempty GAM, claim followed by empty GAM, and + navigation/supersession at each side of that two-condition join; +- replay after consumption and after tombstone expiry boundary; +- attempt-id navigation prefix failure, ordinal uniqueness and exhaustion without an + issued-id set; forced lifecycle-ticket/renderer-nonce collisions through the + eighth draw; 255/256/257 live nonces and 319/320/321 ticket/tombstone entries; + capacity versus expiry pruning; and proof that neither overflow path posts a usable + capability; +- valid id from wrong slot/source, altered id from the expected source, and a native + Prebid id; +- PUC registration before/after timeout, wrong source, zero/two ports, replay, + caller abort before/after registration/insertion/document acceptance, and owner + watchdog racing a late kernel response; every winner produces exactly one + encodable `OwnerSettlementV1` and one PUC Promise settlement; channel loss before + start and after insertion, settlement-post throw, and the 20-second remote cleanup + boundary prove the owner removes only its uncommitted iframe while accepted DOM + remains; +- renderer document success followed by runner failure/timeout; +- runner proxy stall and slow-drip across the five-second total deadline; redirect; + absent/duplicate/malformed/mismatched/over-limit `Content-Length`; + absent/accepted/parameterized/rejected `Content-Type`; absent/identity/listed/other + `Content-Encoding`; declared and streamed over-limit bodies; byte-preserving + success; stripped upstream headers; and empty, non-leaking `502 no-store` failure; +- GPT/Prebid readiness at either side of its deadline, `slotRequested` at either + side of the request-start deadline, and `slotRenderEnded` at either side of the + completion deadline, including late real completion of an attributable + completion-timeout cycle and proof that future events never release an + unattributable request-timeout quarantine; +- iframe `load`, `error`, removal, supersession, and navigation disposal ordering; +- accepted-artifact promotion racing terminal disposal, replacement of an accepted + direct iframe, TS-owned GPT destroy/redefine, and publisher-owned GPT navigation; +- two consecutive attempts installing identical targeting strings with newer + success, failure, and supersession; older-artifact disposal after newer promotion; + publisher different-value and same-value `setTargeting`, per-key clear, and + clear-all before each cleanup point; +- old-navigation completion after a new slot with the same DOM id exists, both + before and after the replacement's completion, for TS- and publisher-owned slots; +- two concurrent `/auction` calls with partial slot overlap, reversed responses, + one caller abort, full abort, and response timeout; +- initial boot projection versus SPA navigation-owned projection, proving boot + remains recursively frozen; stale/duplicate/malformed page-bids responses; + page-bids racing programmatic registration at the 255/256/257 combined-slot + boundary; auction/provider/upstream/currency/CPM/targeting grammar and + byte/count boundaries; canonical projection just below/at/above 8 MiB with the + exact all-winners-to-`winner_not_renderable` reduction; and all-or-nothing + projection/slot/targeting commit; +- `display()` under disabled initial load from TS and publisher callers; +- exact and hydration-renamed late `defineSlot` handoff, mismatch/ambiguity, + duplicate publisher display, explicit/global initial-load-disabled refresh, + ownership transfer, SPA disposal, and unrelated slots/options; +- DOM replacement before and after GPT request start, debounced TS-owned orphan + reconciliation at 249/250 ms and 4,999/5,000 ms, first/final pass success, + two-success capacity, expiry/rebind latch ordering, ambiguous replacement, + transfer racing replacement, exact orphan destruction/quarantine, targeting + compare-restore, cycle/reservation cleanup, throwing/false GPT destroy and failed + replacement definition in reconciliation/request-timeout/completion-timeout/ + navigation paths, proof no second physical slot is defined, navigation disposal, + and proof that publisher-owned slots are never destroyed; +- SRA completion ordering, duplicate `responseIdentifier`, missing completion, and + publisher/TS overlap; +- missing/stub/late/duplicate/older/partial external Prebid artifacts, artifact + queue-watchdog versus late module activation, ABI and release-identity boundaries, + every manifest collection/string capacity, malformed/unsorted/duplicate members, + sentinel-normalized versus final-byte integrity, exact 10.26.0, own data-property + binding, same-release duplicate reuse without module re-execution, different-valid- + release refusal without disturbing the active object, absent/accessor/inherited/ + invalid/hostile non-configurable stamp handling after the watchdog arms, publisher + callback delivery exactly once on every conflict path, bound `pbjs`/stamp replacement, + `PreparedTrustedBid` admission/non-publication, bidder aliases, client-side + adapter gaps, user-ID/EID manifest gaps, replacement by a later valid artifact, + absence of TS auction/admission/render/refresh behavior from the external artifact, + and native publisher queue/bid + survival; +- explicit/global Prebid refresh with normal, all-excluded, and mixed slots; + literal path case/trailing-slash mismatch; missing/non-string/throwing + `getAdUnitPath`; cleanup of excluded slots; original option identity; and the + initial-TS-refresh bypass; +- reservation capacity with an unexpired oldest entry and late request for that + entry; navigation slot capacity across repeated programmatic calls at totals + 255/256/257, mixed server/programmatic records, all-or-nothing overflow, and full + disposal/reuse on the next navigation; Prebid lease promotion; and the pinned + PBS Cache black-box corpus for ADM-over-cache precedence, cache-only fetch/parse/ + macro/PUC response, collapsed resize, failure, and stale-navigation disposal with + no new cache input, result, deadline, or identity semantics; +- release-catalog cycles, duplicate providers, undeclared capabilities, takeover + consumption of a deferred provider, phase overrides, unknown/missing/multiply + counted production artifacts, deferred/test source pulled into core, and a module + classified into the agent without its named parser-time or first-display obligation; + exact per-consumer capability projection proves `attachPresentation` is absent + from `trace.v1` and denied to APS, GPT, and `gpt_later`, while only + `diagnostics_presentation` can consume `trace.presentation.v1`; +- exactly one parser-blocking network request and manifest-order registration; + first-display/takeover module missing/wrong-release/duplicate/prepare + throw/reject/abort/activation throw at each checkpoint, late continuation after fallback, and takeover + `afterCommit` throw; 9,999/10,000/10,001 ms synchronous activation returns plus + the pre/post-call and pre-handoff monotonic checks; nonreturning activation + documented as unpreemptable; duplicate `afterCommit` registration, + catalog-derived 13/14/15 takeover-callback staging, and 19/20/21 total-manifest + capacity; publisher GPT activity and + script/creative DOM activity before/during/after a later takeover failure prove + preparation is inert, activation cannot yield, rollback is same-task, and + post-commit work sees only the full persistent kernel; queued and later + `requestAds`, callback throws, already-aborted signals, publisher/unsolicited + integration registration refusal, and proof that no second runtime, listener, + port, timer, request, script, wrapper, guard, or iframe survives fallback; +- takeover download/preparation with interleaved publisher `defineSlot`, `display`, + explicit/global `refresh`, destroy, targeting mutation, GPT events, DOM + replacement, guard observations, Prebid queue activity, consent/segment changes, + and terminal/tombstone expiry proves that static preparation reads no live state; + the final same-task snapshot sees the last mutation revision, revision exhaustion + fails closed, and a callback queued after closure reaches only the persistent epoch; +- protected-paint admission sealing immediately before/at/after the boundary proves + a late TS Prebid bidder call completes once with no bid and no minted lease, + reservation, ticket, attempt, port, or callback; native publisher GPT/Prebid and + non-TS bidder calls remain pass-through; discovery of deliberately injected live + TS authority commits `bundle_partial` rather than transferring or replaying it; +- persistent download/authentication/preparation success, failure, and the exact + 9,999/10,000/10,001 ms post-paint deadline prove callback pushes remain queued + until one commit; success drains against the full kernel, while failure freezes the + exact true/false `initialDisplayCommitted`, drains once against fallback, makes + every new `requestAds` and `addAdUnits` propagate the same classified + `abi_mismatch`/`bundle_partial` fallback reason, preserves accepted DOM, and leaves + no pending work or artifact retry; +- final handoff boundaries for attempt/slot/GPT-token/cycle/trace ordinals at + maximum-minus-one/maximum/exhaustion; pruned prior cycles with + `unknownPriorCycle`, open/retired/quarantined GPT cycles, late old-cycle facts, + monotonic expiry translation, 255/256/257 adopted slots, 8 MiB + non-diagnostics/512 KiB diagnostics/8.5 MiB total data-tree caps, and one-use + capsule replay prove that no id/sequence is reused and no event is reattributed; +- provisional creative/DataDome/GTM/Lockr/Testlight/consent guards mutate during + runtime preparation, then compare-restore and install fresh persistent effects in + one task; observer-record loss is covered by the bounded rescan, publisher- + replaced globals leave old effects generation-inert, and no function/listener/ + wrapper/observer enters the handoff or capsule; +- direct persistent boot for rewritten creative documents and direct `/auction` + proves neither path selects an agent or waits for a nonexistent projected-attempt + paint; direct timing/deferred release uses the persistent owner, while every + permitted agent mask—including GPT reference and APS—passes the exact size, + source-reachability, action, terminal, and paint assertions; +- first-display-required live GPT/Prebid fetch starting after `tsjs:bids-script` and + overlapping the first-display TSJS fetch, upstream success before/after adapter + activation, and proof that no TS-owned display/request occurs before the sole + adapter's correctness listeners; optional upstream and APS runner traffic is + absent from boot, TSJS generated-source scans contain no upstream library bytes, + and the separately generated external Prebid artifact passes its purity scan; +- `first_display_or_idle` at each side of attempt creation and batch settlement, no + initial slot, the 9,999/10,000/10,001 ms attempt-creation guard, first/second + animation frame, hidden/visible transition, idle callback, idle timeout, 50 ms + timer fallback, an explicitly post-window first display overlapping released later + work, navigation waiter disposal, and full-runtime module disposal; + concurrent readiness waiters keep their original deadlines while sharing one + module Promise/script after the gate; all deferred transactions start after the + common gate without waiting for one another, and a hung/failed first catalog entry + cannot delay another module's fetch or deadline; no persistent/deferred request/preload/evaluation + precedes `tsjs:first-display-paint`; exact script-node/currentScript/source/id/ + phase/release authentication rejects publisher tags, replaced nodes, redirects, + duplicates, and stale generations; deferred prepare/activate/afterCommit/load/ + timeout failures leave the same kernel/adapter/listener/slot/dispatcher identities + live, settle dependent work with its typed reason, leak no node/listener/timer, + and neither install fallback nor start a second runtime; +- first-display, takeover, and deferred static routes across Fastly, Axum, Cloudflare, and Spin: + exact path, one-field hash query, response-byte hash, enabled catalog membership, + MIME/nosniff headers, conditional `304`, local `404 no-store`, no publisher + fallthrough, and no redirect; stale-release URLs fail instead of receiving current + bytes; +- CSP/Trusted Types browser fixtures for same-origin allowlisting, matching nonce, + nonce-only policy, `strict-dynamic`, allowed `trusted-server#tsjs-v1`, rejected + named policy with an exact-preserving publisher default, and full policy block; + mutation by a default policy, a disallowed URL, or a synchronous policy throw + produces no insertion/request and settles only the affected deferred module as + `policy_blocked`; missing/invalid nonce or CSP source rejection after insertion is + `load_error`, while node removal/replacement may have initiated a request but + cannot register and becomes `registration_rejected` unless load failure wins; +- exact kernel/fallback `TsjsApi` own surfaces; semantic version and release-id + equality; boot deep-copy/freeze and malformed-field safe fallback; actual-Array + queue identity; pushes before/during/at activation and commit completion; retained ingress + references after swap; snapshot-versus-forward exactly-once behavior; nested push + ordering; frozen final-queue `length:0` under native/borrowed mutators, index and + length assignment, deletion, and property definition in strict and sloppy callers; + immediate post-load return values, `this`, non-callables, and callback throws; +- main bundle absence after server GPT projection, proving the old degraded bootstrap + renderer is deliberately gone: no GPT definition/targeting/display/refresh occurs, + every known slot settles with the committed fallback reason, the queue drains once, + and a late bundle cannot revive rendering; +- explicit `requestAds` selection with exact server-projected and programmatic slot + ids, an unknown id beside a valid sibling, GPT-path and DOM-alias collisions, and + omitted-slot snapshot membership/order while another slot registers after + invocation; +- programmatic `addAdUnits` single/array registration, boundary sizes/counts, + accessors/unknown keys/media, malformed params, duplicate/colliding ids, + all-or-nothing rollback, navigation disposal, registration after a `requestAds` + snapshot, dimension type and 0/1/4096/4097 boundaries, 63/64/65-byte bidder names, + direct rendering, fallback refusal, absence + of placeholder methods, logger default/all levels, invalid-level non-mutation, and + missing/throwing console methods; +- direct and PUC ADM initial `about:blank`, pre-assignment, intended `srcdoc`, error, + removal, replacement, duplicate load, supersession, disposal, stale-generation, + and deadline orderings, proving only the current intended navigation accepts; +- core diagnostics ingress has the exact frozen `{publish,dispose}` surface and no + `subscribe`, listener id, capacity, queue, scheduler, or timer; valid ordinary and + null-prototype records, dense arrays, and UTF-8 multibyte data pass at total-node + 511/512, depth 15/16, 127/128-byte property names, and 4,095/4,096-byte strings, + while total-node 513, depth 17, 129-byte names, 4,097-byte strings, sparse/extra- + property arrays, accessors, symbols, custom prototypes, functions, `undefined`, + bigint, non-finite numbers, cycles, aliases, hostile traps, and copy/freeze failure + return `false` without reducer entry; accepted snapshots share no producer record + or array, reducer/reporting throws are isolated with `true` returned, disposal is + idempotent, and retained stale-runtime publishers remain `false` and inert; +- GPT trace tokens have canonical ordinals 1/35/36/4,294,967,295, exact lower-case + base-36 grammar and 11-byte maximum, reject zero/leading-zero/upper-case/decoded- + overflow/collision inputs, and latch only new trace-token minting on ordinal + 4,294,967,296; repeated facts and publisher handoff for one physical slot retain + one token, distinct/replacement objects sharing every publisher identifier receive + distinct tokens, and token failure leaves object-identity-bearing `gpt.events.v1` + delivery and GPT behavior live; per-object trace-cycle ordinal 0/1/4,294,967,295/ + 4,294,967,296, fractional/non-finite rejection, 9/10/11 retained-cycle pruning, + and per-object rather than global exhaustion; compound `{token,cycleOrdinal}` core + binding covers unresolved/ambiguous/duplicate active facts, live-map totals + 255/256/257, completed/retired oldest-first pruning, destroy/redefine, handoff, + navigation retirement, no old-pair rebinding or new-current mutation, history-only + late enrichment, and full runtime disposal; two consecutive refresh cycles on the + same physical object produce two rows, with prior-cycle `slotResponseReceived`, + `slotRenderEnded`, `slotOnload`, `impressionViewable`, and visibility callbacks at + each side of the next cycle's start and completion joining only when uniquely + attributable and otherwise producing no trace projection or current-row mutation; + the same ambiguity remains fail-closed after the eleventh cycle prunes an old + record and sets `unknownPriorCycle`; +- render-trace record/update reordering, one-impression enrichment, weaker-signal + non-regression, 200-entry pruning, stale attribution/DOM-field/badge removal, + navigation pruning of `current`, 32/33 subscriber boundaries and capacity reuse, + 199/200/201 pending notification bounds, same-sequence coalescing, post-commit + asynchronous frozen subscription detail/timing, subscribe/unsubscribe races, + slow/throwing listeners, absence of `tsjs:adRendered`, + hidden/gam-only/ok truth, and cross-IIFE sequence order; private presentation + non-callable/reentrant/duplicate attachment, failed factory/malformed-controls/ + missing-listener rollback and later retry, same-task initial snapshot before live + delivery, non-callable source listener before state checks, second-subscribe failure + preserving the first listener, unsubscribe/resubscribe and idempotent unsubscribe, + commit-during-next-task ordering, update coalescing, ignored return and thrown + callback, detach/owner-dispose exactly once, late scheduled callbacks, retained + empty `current`/`history`, rejected retained subscribe and inert unsubscribe/detach/ + controls references, public 32-subscriber capacity unaffected, and overlay/export + failure that leaves the trace store and public subscribers live; +- GPT diagnostics activation before/after early buffered callbacks, exact raw-event + replay, exact `tsjs.boot.diagnostics` schema, query/session enable-disable and + fail-closed inputs, accessor/prototype/unknown/missing/version rejection and + manifest-activation mismatch, active six-listener versus inactive correctness-listener counts, + exact `FirstDisplayGptFactV1` key/event/type/nullability rules, physical-token/ + runtime-slot-number/ad-unit-path preservation, separate matched/unmatched/ambiguous + coverage disposition and nullable no-cycle/overlap/unknown-prior/invalid-order issue + reason, and byte-for-byte equivalent initial slot/cycle/coverage/issue export before + versus after takeover, + maximum-size 999/1,000/1,001-byte facts and 511/512/513 maximum-sized fact + admissions against the exact 512-entry FIFO, 512 KiB diagnostics subsection, and + 8.5 MiB total caps, including eviction versus byte-cap drops and saturated + overflow/drop counters, + 63/64/65 slots, 9/10/11 cycles, 127/128/129 issues, + 32/33 public subscribers, 0/1/2-update latest-snapshot coalescing, + subscribe/unsubscribe/disposal races and slow/throwing listeners, slot element + replacement, timing/frozen-export bounds, overlay disposal, inactive + zero-diagnostics-side-effect behavior, and diagnostics failure during live ads; +- creative processing across sanitize/rewrite policy combinations and every delivery + path; exact default/explicit `CreativeBootV1` validation; automatic immediate and + `DOMContentLoaded` install; disabled and enabled-with-both-guards-false + zero-side-effect behavior; idempotent rescan; + exact-wrapper disposal; absence of mutable/install globals; opaque sandbox click + recovery; absolute HTTP(S), credentials, malformed and non-network schemes; + dynamic URLs; replaced elements; and redirect/browser navigation failure; and +- every remaining integration alone and in the maximal manifest, including each + catalogued first-display/takeover/deferred split, parser-time interception when required, + missing globals, readiness/timeouts, malformed consent/storage, matcher false + positives, callback throws, startup failure, reverse-order disposal, and + cross-integration isolation; and +- every protocol string/body limit at boundary-minus-one, boundary, and + boundary-plus-one UTF-8 bytes, including multibyte, duplicate-key, malformed + encoding, exact 1/4096 renderer dimension bounds across Rust/TS/ES5/PUC DOM, + and exact capability-form cases through both dispatcher and port parsers. + +### 7.3 Real-GAM pass criteria + +The checked-in test-network fixture and hermetic fakes use fictional ids and no +production demand. Real network ids, GAM creative configuration, and secrets are +injected by the protected CI/manual environment and never checked into this +repository. +Each required flow must demonstrate the expected DOM and lifecycle result, not an +analytics row: + +- APS paths: one creative request, one bridge claim where applicable, one renderer + iframe, one APS runner load, one APS render-completion callback, one accepted + result, no duplicate render. The PUC owner, static renderer, and descendant creative + each have the exact winning viewport with zero default margin, no clipping, and no + overflow. +- Empty GAM fallback: parent settles empty/failure before exactly one child render. +- Direct ADM: exact owned iframe reaches one accepted result. Existing PBS Cache + fixtures retain their pre-cutover observable result without entering the new + APS/ADM owner protocol. +- Failure fixtures: wrong id, invalid descriptor, missing claim, missing owner, + missing document acknowledgement, and runner failure each reach the specified + terminal reason within the specified timeout. +- After SPA replacement, no old attempt mutates the current slot or targeting. +- The initial page loads one same-origin first-display TSJS artifact. Network + evidence shows no persistent/deferred TSJS request/preload before + `tsjs:first-display-paint`; takeover transfers exact physical/ad ownership without + another display, and later refresh/navigation/diagnostics behavior joins the same + persistent runtime without replacing its GPT/Prebid adapter or lifecycle owners. + +The suite records browser console, network metadata, DOM, and GPT-event evidence as +CI artifacts. Network capture excludes APS runner and creative response bodies so a +test artifact cannot become an accidental vendor-code archive. It requires no +external analytics, billing, or experiment result. + +## 8. Delivery and rollout + +This work is assembled through test-only constructors while being built, then cuts +over once through the existing APS/TSJS release mechanism. No runtime flag, +old/new selector, compatibility branch, or dual protocol is introduced in any +deployable artifact. + +Operator configuration moves before the binary cutover. Canonical APS +`account_id` is validated and pushed while the old binary still accepts it, as +specified in §3.3. No unrelated creative-opportunity configuration switch is part +of this cutover. + +1. **Integrate current main:** fetch and integrate current `origin/main`, record its + exact SHA, run the unchanged affected Rust/TS/browser suites, and start every + retained `RCJ-*` row proof-pending. Identify or author its focused contract, run + that test against a detached otherwise-untouched worktree at the recorded SHA, + and record SHA/owner/test command/result. A passing contract is main-owned; a + behavioral failure is an implementation-gap; missing test coverage is a + coverage-gap that blocks production edits until the test is authored and run. + No row may remain proof-pending or coverage-gap at phase exit. Do not fetch, + merge, rebase, or cherry-pick retired `rc/july`. +2. **Contract first:** land descriptor corpus, lifecycle types, adapter interfaces, + and failing tests without changing production behavior. +3. **Kernel and release catalog:** introduce runtime/integration-module ownership, + sessions, capability broker, slot registry, auction batch, render lifecycle, + phase metadata, and production-versus-test composition boundaries behind + test-only construction. +4. **Server APS path:** make admission, mediation, descriptor projection, targeting, + and renderer route conform to the contract. +5. **First-display extraction:** preserve the two immutable intermediate captures, + build the server-composed first-display artifact plus authenticated post-paint + takeover, move persistent/later behavior out of its graph, and make inventory, + ownership, and production-metafile gates green. Record candidate evidence from + its exact clean pushed parent; all historical/intermediate deltas remain + report-only, and fresh-current-main semantic transfer, independent absolute size, + timing, and heap gates must be green before production wiring. +6. **Browser integrations:** migrate APS, GPT, Prebid, direct auction, fallback, + local diagnostics, creative processing, every remaining affected TSJS + integration, and bootstrap to the catalogued first-display/takeover/deferred + modules while current-main regressions and retained `RCJ-*` gap contracts stay + green. +7. **Delete legacy paths:** remove expandos, duplicate bridge branches, old + `requestAds`, legacy globals, duplicated bootstrap behavior, and unused flags + from the release candidate. +8. **Pre-production:** refresh/integrate current `main` again, then pass all hermetic + suites and the protected real-GAM network + in Chromium, Firefox, and WebKit; archive its console, network, DOM, and GPT-event + evidence with the release artifact. +9. **Binary production cutover:** deploy the verified artifact through the + repository's normal release mechanism. This design adds no percentage router or + canary-selection infrastructure. Hold an exclusive production deployment window, + attest the active immutable artifact, and re-check it immediately before cutover; + any mismatch blocks and regenerates evidence. Retain that immediately prior + artifact and roll back the whole cutover on renderer errors, elevated request + failures, CSP/security errors, or non-APS regressions. +10. **Post-cutover:** monitor existing operational signals for 24 hours. The deployed + artifact already contains no temporary development selector. + +Binary rollback restores Trusted Server code but cannot restore older live APS runner +bytes. If the proxied runner becomes unavailable, incompatible, or produces suspect +completion results, the emergency containment action is to disable +`[integrations.aps]`; this stops new APS admission and makes both reserved APS routes +return their local `404 no-store` response. APS remains disabled until controlled +real-browser conformance passes again. + +This document does not prescribe new rollout telemetry. If existing operational +signals are insufficient for a deployment decision, that blocks rollout and is +resolved operationally or in a separate observability spec; it does not justify +adding a hidden analytics subsystem here. + +## 9. Decisions and rejected alternatives + +1. **Patch only the current GPT bridge:** rejected; it leaves duplicated runtime + state, SPA races, direct-auction concurrency, and bootstrap ownership unresolved. +2. **Full TSJS rewrite:** rejected; extract the kernel and migrate behavior in tested + slices so current contracts remain the oracle until cutover. +3. **Use `slotRenderEnded` as render success:** rejected; it observes GAM creative + injection, not APS runner completion. +4. **Use a bid id as the only bridge credential:** rejected; ids can collide, be + truncated, replayed, or arrive from a wrong frame. +5. **Put the lifecycle nonce in `Prebid Request`:** impossible; Universal Creative + owns that message. The nonce is minted only after a validated claim. +6. **Trust creative-document callbacks for ADM:** rejected; bidder-controlled + markup must not hold the acceptance capability. The TS-authored owner observes + its iframe. +7. **Evict live reservations at capacity:** rejected; a late request could fall + through to native Prebid or claim the wrong work. Refuse new registration. +8. **Abort a shared auction fetch when one slot is superseded:** rejected; sibling + attempts may still need the response. +9. **Keep legacy globals/API aliases:** rejected; backward compatibility is not a + requirement and dual paths would preserve the architecture defect. +10. **Add analytics or experiment infrastructure to prove rollout:** rejected as a + separate project. Rendering is proven by contract, browser, and real-GAM + conformance; release uses a pre-production gate and binary artifact rollback. +11. **Check in or release a pinned APS runner:** rejected. Trusted Server owns only + the fixed-target transport proxy and renderer contract; APS owns the live runner + bytes. +12. **Keep every enabled integration in one atomic boot barrier:** rejected. It makes + first display pay for refresh, later navigation, diagnostics UI, and optional + integrations that cannot affect that display. Atomicity remains mandatory for + the takeover transaction and for each deferred module's local transaction. +13. **Download a monolith early and merely postpone its callbacks:** rejected. It + preserves transfer/parse contention and does not solve load time; deferred code + is a separately requested release-owned artifact after its trigger. +14. **Let integrations dynamically import arbitrary modules or upstream scripts:** + rejected. Core alone inserts exact same-origin release artifacts. GPT, APS, + Prebid, PUC, and other upstream bytes remain live external dependencies and are + never vendored into TSJS source or output. +15. **Give each deferred bundle its own runtime or service locator:** rejected. A + later module can join only the committed session through catalogued frozen + capabilities and cannot replace an owner. +16. **Keep optimizing the 395 kB full-runtime first-display graph:** rejected by + measured evidence. Co-bundling removed duplicate emission but still produced a + 4.57× p90 against current `main`; no credible local minification or shared-chunk + change closes that gap under the fixed network profile. +17. **Create provider-specific independent mini-runtimes:** rejected. They reduce + bytes per path but multiply queue, adapter, message, slot, and fallback ownership. + The one bounded agent has a single exact transfer into the one persistent runtime. +18. **Relax the 1.10× gate or accept the 220 kB mechanical ceiling:** rejected. The + automated paired gate is the user-visible load-time acceptance criterion; the + 90 kB agent ceiling is an additional architecture guard, not permission to + self-baseline or waive the measured result. +19. **Merge or cherry-pick retired `rc/july` to recover TSJS work:** rejected. Current + `main` already contains most required behavior, often through later or squashed + implementations; importing the retired branch would add unrelated work and + create a second behavioral authority. The immutable snapshot is used only to + discover and test a specific missing concept. + +## 10. Risks and mitigations + +| Risk | Mitigation | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PUC behavior differs from the local contract harness | keep the harness limited to the public message/helper contract, exercise `h.sendMessage`, and gate the actual externally hosted PUC release on real GAM; do not vendor PUC bytes | +| Same-realm publisher code can interfere | explicitly trust TS-authored owner code; capability checks defend unrelated frames, replays, and stale work, not arbitrary same-realm compromise | +| A module activation never returns | activation is generated first-party code with boundary tests; elapsed returning calls fail through monotonic checks, but JavaScript cannot preempt a nonreturning same-thread function | +| Strict parsing rejects a future APS field | descriptor is versioned; outer transport remains tolerant; add a reviewed version/corpus update rather than silently accepting new semantics | +| CSP blocks a legitimate APS creative | three-browser real-GAM suite; script creatives remain opt-in; CSP changes are explicit security work | +| Hard cutover breaks stale pages | accepted compatibility stance; a stale hash fails locally and reload is required; retain the prior deployable binary for whole-release rollback, not N/N-1 routes in the active binary | +| Kernel extraction changes unrelated integrations | per-integration pre/post behavior corpus, adapter fakes, current full suites, behavioral maximal-bundle test, and exact disposal assertions | +| Optional code drifts back into the first-display artifact | release-catalog phase/dependency validation, production metafile deny paths, semantic first-display budgets, and reviewable named parser-time obligations | +| Agent and persistent runtime overlap ownership | no runtime request before protected paint; effect-inert preparation; one non-yielding quiesce/adopt/commit transaction; exact disposer inventory; browser proof of zero duplicate display/listener/timer/port/wrapper | +| Takeover cannot reconstruct a live GPT slot safely | transfer the exact physical object only in the one-use closure-private capsule; validate generation/release and adopt without define/target/display; never serialize or publish that identity | +| Persistent takeover fails after a successful first display | keep accepted DOM inert, reverse partial persistent effects, commit terminal fallback, and never replay the projection or resurrect the provisional agent | +| A deferred feature is called before its module is ready | caller deadlines start at original enqueue and may expire while gated; after the paint gate, live waiters share one independently bounded module load with no duplicate/fallback/runtime | +| First display never occurs, so deferred work starves | the owned 10-second post-kernel no-display guard becomes the trigger, followed by bounded idle scheduling or the owned timer fallback | +| A page waits past 10 seconds before its first programmatic display | accepted explicit boundary on a no-agent page: correctness still uses persistent owners, but that display may contend with already released later work and is excluded from the protected-load claim | +| A forged/replaced script registers into the runtime | exact same-origin catalog URL, release/id/phase match, core-created element identity, `document.currentScript`, single terminal registration, and generation checks | +| Publisher CSP or Trusted Types blocks a later module | preserve publisher policy; copy only the authenticated parser-inserted nonce, use exact-manifest Trusted Types URLs, and fail to the defined takeover shell or isolated deferred `policy_blocked` state without another sink/runtime | +| Phase splitting duplicates product ownership | one broker provider per capability, immutable interfaces, takeover-before-deferred dependency rule, agent capsule identity/disposal tests, and no public service locator | +| Phase splitting reduces initial bytes but grows total release size | independent immutable maximal-total raw/gzip/Brotli budget and complete release inventory; splitting alone cannot make the gate pass | +| One deferred module stalls unrelated later behavior | start every independent deferred transaction after the common gate without awaiting siblings; separate deadlines and no deferred-to-deferred capability edges prevent head-of-line blocking | +| Retired `rc/july` is mistaken for an implementation source | never fetch/merge/rebase/cherry-pick its head; validate only immutable `905984e62` as a concept checklist, map retained rows to current-main owners/gaps, and fail the plan/lint if a release or performance gate names `rc/july` | +| Diagnostics change ad behavior or overclaim a render | bounded core-only snapshot ingress, separately bounded GPT fact replay, consumer-specific private presentation capability, isolated public subscribers, honest `gam-only`/`ok` rules, inactive zero-side-effect tests, and no correctness dependency | +| Bounded registries refuse traffic under extreme churn | explicit reservation `registry_full` and slot `registry_capacity`, lifecycle pruning, capacity stress tests; never trade correctness for eviction | +| GPT event attribution remains ambiguous | adapter-minted non-reused physical-slot plus per-object cycle identity joins diagnostics only after exact current-slot and unique-cycle resolution; unresolved, stale, or multi-cycle-ambiguous facts are dropped, while lifecycle authority still fails the TS attempt deterministically and never triggers fallback from ambiguous/publisher-owned activity | +| Late async work mutates new SPA state | generation checks, owned disposers, terminal latch, and adversarial reversed-order tests | +| Browser tests report iframe load but not APS success | require the bound APS render-completion callback and inspect network/DOM evidence | +| APS runner becomes unavailable or stops the callback | load/rejection/silence fail the attempt; real-browser conformance blocks release and APS disablement is the emergency containment path | +| APS runner reports completion incorrectly | accepted external trust risk; protected conformance checks DOM/network behavior, but cannot prove future mutable bytes; suspect behavior disables APS | +| Existing operational signals are weak | do not invent telemetry in this spec; hold deployment or write a separate observability design | + +## 11. Success criteria + +The design is complete when all of the following are true: + +1. The five supported render flows have explicit owners, identity rules, deadlines, + and terminal behavior. +2. APS descriptor production and all three validators agree on the full corpus. +3. Mediation cannot detach price from provenance or attach the wrong renderer. +4. GAM receives a valid, non-truncated identity for every accepted TS renderer bid. +5. Duplicate, replayed, wrong-source, stale-navigation, and late lifecycle messages + cannot render or settle twice. +6. Direct multi-slot auctions settle every requested slot and obey child-versus-batch + cancellation. +7. The runtime has one slot registry, one bridge listener, one adapter instance per + external library, and one explicit owner for every timer/listener/port/iframe. +8. Legacy expandos, duplicate bridge branches, old globals, old `requestAds`, and + duplicated bootstrap logic are absent from the final bundle. The `pub_id` config + alias, `/__ts/page-bids`, its JS retry, and the unversioned APS renderer path are + absent from server routes, tests, and documentation. Every bootstrap failure + checkpoint commits the terminal non-rendering shell, drains work exactly once, + and cannot construct or admit a second runtime. +9. APS renderer and runner-proxy routing, security headers, bounded relay, and failure + behavior are proven through each real adapter transport, are equivalent across all + four adapters, and never fall through to publishers. APS runner bytes are neither + stored in the repository nor required to be identical across different upstream + fetches. +10. Rust, TypeScript, ESLint, Vitest, hermetic Playwright, adapter parity, and + real-GAM conformance suites pass. +11. Non-APS Cache/ADM rendering, native Prebid handling, publisher-owned GPT, refresh, + SRA, and SPA regression suites pass. +12. No analytics, persistence, billing, experimentation, or deployment-routing + artifact is added by the implementation plan. +13. `requestAds` accepts only exact server-projected or transactionally registered + programmatic slot ids, omitted selection is an immutable invocation-time + registration-order snapshot, and ambiguous internal aliases fail closed without + affecting valid siblings. +14. Direct and PUC ADM acceptance is possible only for the exact current frame's one + intended `srcdoc` navigation; initial blank, replacement, removal, stale, late, + and duplicate events cannot accept. +15. Every retained `RCJ-*` ledger entry finishes `main-owned` or demonstrated + `implementation-gap` with recorded current-main SHA, owner paths, focused test + path/command/result, one final owner, and a preserved/rebuilt/superseded + disposition. No proof-pending or coverage-gap row crosses the phase boundary. + The historical manifest has no unmapped retired TSJS concept, but no retired + source participates in the build. +16. Late GPT handoff, hydrated/ responsive DOM replacement, Prebid partial-artifact + recovery and refresh exclusions, creative security, render trace, GPT + diagnostics, and every remaining TSJS integration pass their complete parity + suites after the hard cutover. +17. PUC owner, renderer document, and descendant creative dimensions are exact and + unclipped for the winning size, while collapsed-shell correction cannot resize an + unrelated, anchor, fixed, sticky, disconnected, or already-expanded frame. +18. Programmatic ad units register transactionally into the navigation slot service, + participate in deterministic direct-auction snapshots, and render through the + same lifecycle; placeholder rendering and mutable generic runtime configuration + are absent rather than silently retained as a second path. +19. The committed `TsjsApi` kernel/fallback surfaces, semantic version, exact + release identity, queue, logger, immutable boot data, and diagnostics presence are + executable contracts; creative guards auto-install from `CreativeBootV1` with no + mutable/install global API. +20. Attempt ids require no issued-id history, and reservation/ticket/nonce registries + refuse capacity or collision exhaustion without exposing a reusable capability. +21. The external Prebid artifact remains free of TS auction/render behavior, exposes + only its exact own frozen 10.26.0 build stamp, and the TS-owned adapter admits a + fully prepared bid without partial publication. +22. A page with an eligible server projection requests exactly one parser-blocking, + server-composed first-display artifact. The immutable batch remains agent-owned + through terminal settlement and paint, and the reference fixture requests, + preloads, prepares, and executes no persistent/deferred TSJS artifact before the + real `tsjs:first-display-paint` mark. The manifest URL hash names the exact + served agent bytes and stale or malformed hashes fail locally on every adapter. +23. Agent/runtime absence, mismatch, timeout, preparation, or takeover failure + commits the terminal fallback without replaying or removing an accepted first + display. Protected paint seals new TS admission; the post-paint callback queue + drains exactly once against persistent or fallback, with exact + `initialDisplayCommitted` and no pending/retried work. A deferred module failure + leaves the same committed persistent kernel and owners alive and settles only + dependent work through its typed contract. +24. No production-core import graph contains deferred integration/service/UI code, + no-op/fake/test seams, or `*ForTest` accessors. Every production artifact appears + exactly once in the release inventory, with the bootstrap role included once and + every TSJS module included in maximal total. +25. The oversized role-correct and mechanical-remediation captures remain immutable + report-only evidence. Every permitted first-display mask, including the named + GPT-reference and APS masks, passes the 90,000/30,000/26,000 raw/gzip/Brotli + ceilings; bootstrap, persistent reference, and maximal total pass their + independent §5.12 ceilings; and the candidate's semantic pre-action transfer is + no larger than a fresh current-main build in each encoding. + Boot-to-first-display passes the automatic fixed-network-profile + candidate-versus-current-`main` timing gate, including the candidate's real-mark + and deferred-order assertions; the candidate-only APS fixture passes every named + action/completion/paint and 3/3.75 MiB heap ceiling; paired retained-heap results + remain within their ratio and hard ceiling. No gate permits disabled shaping, + selective sample reruns, + candidate self-baselining, or membership loopholes. + Handoff tests prove a final same-task data snapshot after static preparation, + monotonic high-water/cycle/trace transfer, one-use capsule, exact physical-slot + and committed-artifact adoption, zero repeated `display`/`refresh`/iframe insertion, + and no agent listener, timer, observer, port, wrapper, request authority, or + strong reference after the synchronous takeover boundary. +26. A deferred module can register only from the exact current core-created local + release script and can obtain only catalogued frozen capabilities from the one + runtime. It cannot replace an adapter, slot registry, dispatcher, provider, or + runtime, including after navigation and failure races. Trusted Types and URL + failures detected synchronously before insertion isolate as `policy_blocked`; + nonce/source CSP rejection after insertion is `load_error`, and authenticated- + node loss is `registration_rejected` unless load failure wins. No policy failure + widens CSP or selects another execution sink. + All included deferred transactions begin independently after the shared gate, so + one module's ten-second deadline cannot delay or consume another's. +27. GPT, APS runner, PUC, and other live upstream script bytes are absent from TSJS + source, TSJS generated artifacts, fixtures, and browser evidence. The separately + generated pure external Prebid artifact remains isolated under its §5.6 contract + and contains no TSJS behavior; Trusted Server's runtime bundles retain only owned + adapters, proxy/loading contracts, and lifecycle wrappers. +28. The core diagnostics ingress admits and snapshots only the exact bounded data + tree, exposes no module subscription machinery, and becomes inert on owner + disposal. `trace.presentation.v1` is a separate consumer-specific capability + available only to deferred diagnostics presentation; attachment activation, + replay/live ordering, failure rollback, coalescing, detach, owner disposal, and + late callbacks cannot affect trace correctness or the 32-live-subscriber public + limits. +29. Each physical GPT object receives one adapter-minted canonical trace token that + remains stable through handoff, is never reused within the runtime, and differs + across redefine/replacement even when publisher identifiers match. Each + unambiguous physical request admitted to trace projection receives a distinct + nonreused per-object cycle ordinal, and trace facts join only through that + compound identity; ambiguous requests are omitted fail-closed. Token/cycle mint, + ambiguity, collision, map capacity, retirement, late events, navigation, or + disposal failure can drop only diagnostic projection; it cannot conflate old/new + impressions or affect GPT behavior and the independently bounded object-identity + `gpt.events.v1` stream. + +## 12. Open implementation decisions + +These choices may be resolved in the implementation plan without changing the +architecture: + +- exact source-file boundaries inside `kernel/`, `adapters/`, and `services/`; +- whether the canonical descriptor schema is generated from Rust metadata or a + small neutral schema file, provided all validators share the corpus and staleness + check; +- exact operational thresholds for the existing binary deployment mechanism. + +Current-main authority; the retained `RCJ-*` concept-gap ledger membership and +behavioral dispositions; the public diagnostics namespace; Prebid artifact +independence; integration parity; the one-runtime rule; immutable budgets; and the +first-display/takeover/deferred semantic boundaries are not open implementation +decisions. The implementation plan must map every canonical release-catalog row +above to exact current-main source/build/test steps and preserve each concrete +first-display, parser-time, or later-only obligation. It cannot add, remove, reorder, +or reclassify modules opportunistically to make a budget pass, and it cannot merge or +build retired `rc/july` source. + +They may not be resolved by adding compatibility shims, a second runtime, external +telemetry requirements, durable persistence, experiment routing, or a weaker +lifecycle success definition. diff --git a/docs/superpowers/specs/2026-08-04-gpt-delivery-evidence-and-auction-competition-design.md b/docs/superpowers/specs/2026-08-04-gpt-delivery-evidence-and-auction-competition-design.md index d35b22528..f9783b3df 100644 --- a/docs/superpowers/specs/2026-08-04-gpt-delivery-evidence-and-auction-competition-design.md +++ b/docs/superpowers/specs/2026-08-04-gpt-delivery-evidence-and-auction-competition-design.md @@ -1,7 +1,7 @@ # GPT Delivery Evidence and Auction Competition -**Date:** 2026-08-04 -**Status:** Implemented +**Date:** 2026-08-04 +**Status:** Implemented **Scope:** Opt-in GPT diagnostics only; no publisher-code changes ## Relationship to the Existing Diagnostics Design diff --git a/docs/superpowers/specs/2026-08-05-gpt-refresh-source-and-replacement-diagnostics-design.md b/docs/superpowers/specs/2026-08-05-gpt-refresh-source-and-replacement-diagnostics-design.md index 6a688a422..c1ced52f6 100644 --- a/docs/superpowers/specs/2026-08-05-gpt-refresh-source-and-replacement-diagnostics-design.md +++ b/docs/superpowers/specs/2026-08-05-gpt-refresh-source-and-replacement-diagnostics-design.md @@ -1,6 +1,6 @@ # GPT Refresh Source and Replacement Diagnostics -**Date:** 2026-08-05 +**Date:** 2026-08-05 **Status:** Implemented **Scope:** Opt-in GPT diagnostics only; zero publisher-code changes diff --git a/scripts/dispatch-workflow-run.mjs b/scripts/dispatch-workflow-run.mjs new file mode 100644 index 000000000..871d907e0 --- /dev/null +++ b/scripts/dispatch-workflow-run.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; + +const POLL_INTERVAL_MS = 2_000; +const POLL_TIMEOUT_MS = 120_000; + +function fail(message) { + throw new Error(`[dispatch-workflow-run] ${message}`); +} + +function run(command, args, options = {}) { + try { + return execFileSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (error) { + const stderr = + error && typeof error === "object" && "stderr" in error + ? error.stderr + : ""; + fail( + `${command} ${args.join(" ")} failed${stderr ? `: ${String(stderr).trim()}` : ""}`, + ); + } +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function parseInputs(argumentsList) { + const inputs = new Map(); + for (const argument of argumentsList) { + const separator = argument.indexOf("="); + if (separator <= 0) + fail(`workflow input must use key=value syntax: ${argument}`); + const key = argument.slice(0, separator); + const value = argument.slice(separator + 1); + if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) || value.length === 0) { + fail(`invalid workflow input: ${argument}`); + } + if (inputs.has(key)) fail(`duplicate workflow input: ${key}`); + inputs.set(key, value); + } + return inputs; +} + +function remoteShaForRef(ref) { + const escapedRef = ref.replace(/^refs\/(heads|tags)\//, ""); + const output = run("git", [ + "ls-remote", + "--heads", + "--tags", + "origin", + `refs/heads/${escapedRef}`, + `refs/tags/${escapedRef}`, + `refs/tags/${escapedRef}^{}`, + ]); + const rows = output + .split("\n") + .filter(Boolean) + .map((row) => row.split(/\s+/, 2)); + if (rows.length === 0) fail(`ref is not pushed to origin: ${ref}`); + const dereferencedTag = rows.find(([, remoteRef]) => + remoteRef?.endsWith("^{}"), + ); + return (dereferencedTag ?? rows[0])?.[0]; +} + +function listRuns(workflow) { + const output = run("gh", [ + "run", + "list", + "--workflow", + workflow, + "--event", + "workflow_dispatch", + "--limit", + "100", + "--json", + "databaseId,displayTitle,headBranch,headSha,createdAt", + ]); + try { + return JSON.parse(output); + } catch (error) { + fail( + `gh returned invalid run JSON: ${error instanceof Error ? error.message : error}`, + ); + } +} + +function matchingRuns(runs, evidenceId, sha, dispatchedAfter) { + return runs.filter((candidate) => { + const createdAt = Date.parse(candidate.createdAt); + return ( + candidate.headSha === sha && + candidate.displayTitle?.includes(evidenceId) && + Number.isFinite(createdAt) && + createdAt >= dispatchedAfter + ); + }); +} + +async function main() { + const [workflow, ref, ...inputArguments] = process.argv.slice(2); + if (!workflow || !ref) { + fail( + "usage: dispatch-workflow-run.mjs key=value [...]", + ); + } + const inputs = parseInputs(inputArguments); + const evidenceId = inputs.get("evidence_id"); + if (!evidenceId) fail("required workflow input is missing: evidence_id"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/.test(evidenceId)) { + fail("evidence_id must be 8-128 URL-safe characters"); + } + + const localSha = run("git", ["rev-parse", `${ref}^{commit}`]); + const remoteSha = remoteShaForRef(ref); + if (localSha !== remoteSha) { + fail( + `ref is not pushed at the local commit: local ${localSha}, origin ${remoteSha}`, + ); + } + + const existing = listRuns(workflow).filter((runRecord) => + runRecord.displayTitle?.includes(evidenceId), + ); + if (existing.length > 0) + fail(`evidence_id has already been used: ${evidenceId}`); + + const dispatchedAfter = Date.now() - 5_000; + const dispatchArguments = ["workflow", "run", workflow, "--ref", ref]; + for (const [key, value] of inputs) + dispatchArguments.push("-f", `${key}=${value}`); + run("gh", dispatchArguments); + + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + const matches = matchingRuns( + listRuns(workflow), + evidenceId, + localSha, + dispatchedAfter, + ); + if (matches.length > 1) + fail(`more than one workflow run matched evidence_id ${evidenceId}`); + if (matches.length === 1) { + const runId = matches[0].databaseId; + if (!Number.isSafeInteger(runId) || runId <= 0) + fail("matched run has an invalid numeric id"); + process.stdout.write(`${runId}\n`); + return; + } + await sleep(POLL_INTERVAL_MS); + } + fail(`timed out waiting for workflow run with evidence_id ${evidenceId}`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : error}\n`); + process.exitCode = 1; +}); diff --git a/scripts/generate-aps-renderer-contract.mjs b/scripts/generate-aps-renderer-contract.mjs new file mode 100644 index 000000000..d835b8144 --- /dev/null +++ b/scripts/generate-aps-renderer-contract.mjs @@ -0,0 +1,315 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const fixtureRoot = path.join( + repositoryRoot, + 'crates/trusted-server-js/lib/test/fixtures' +); +const schemaPath = path.join(fixtureRoot, 'aps-renderer-v1.schema.json'); +const corpusPath = path.join(fixtureRoot, 'aps-renderer-v1-corpus.json'); +const es5Path = path.join( + repositoryRoot, + 'crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js' +); +const typescriptPath = path.join( + repositoryRoot, + 'crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts' +); + +const [schemaText, corpusText] = await Promise.all([ + readFile(schemaPath, 'utf8'), + readFile(corpusPath, 'utf8'), +]); +const schema = JSON.parse(schemaText); +const corpus = JSON.parse(corpusText); + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +invariant(schema?.properties?.type?.const === 'aps', 'schema type should be aps'); +invariant(schema?.properties?.version?.const === 1, 'schema version should be 1'); +invariant( + schema?.properties?.width?.minimum === 1 && + schema?.properties?.height?.minimum === 1, + 'schema minimum dimensions should be 1' +); +invariant( + schema?.properties?.width?.maximum === 4096 && + schema?.properties?.height?.maximum === 4096, + 'schema maximum dimensions should be 4096' +); +invariant( + schema?.properties?.aaxResponse?.['x-decodedMaxBytes'] === 262144, + 'schema decoded AAX limit should be 256 KiB' +); +invariant(corpus?.schemaVersion === 1, 'corpus schema version should be 1'); +invariant(Array.isArray(corpus?.vectors) && corpus.vectors.length > 0, 'corpus should have vectors'); +for (const result of [ + 'accepted', + 'descriptor_invalid', + 'invalid_dimensions', + 'dimensions_out_of_range', +]) { + invariant( + corpus.vectors.some((vector) => vector.expected === result), + 'corpus should exercise ' + result + ); +} + +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +const schemaHash = sha256(schemaText); +const corpusHash = sha256(corpusText); +const generatedHeader = + '// @generated by scripts/generate-aps-renderer-contract.mjs\n' + + '// schema-sha256: ' + + schemaHash + + '\n' + + '// corpus-sha256: ' + + corpusHash + + '\n'; + +const requiredKeys = [...schema.required].sort(); +const optionalKeys = Object.keys(schema.properties) + .filter((key) => !schema.required.includes(key)) + .sort(); +invariant( + optionalKeys.length === 1 && optionalKeys[0] === 'creativeId', + 'creativeId should be the only optional descriptor key' +); + +const constantsSource = + 'var DESCRIPTOR_KEYS = ' + + JSON.stringify(requiredKeys) + + ';\n' + + 'var DESCRIPTOR_KEYS_WITH_CREATIVE_ID = ' + + JSON.stringify([...requiredKeys, 'creativeId'].sort()) + + ';\n' + + 'var ENVELOPE_ROOT_KEYS = ' + + JSON.stringify([...schema['x-envelope'].rootKeys].sort()) + + ';\n' + + 'var ENVELOPE_SEAT_KEYS = ' + + JSON.stringify([...schema['x-envelope'].seatKeys].sort()) + + ';\n' + + 'var ENVELOPE_BID_KEYS = ' + + JSON.stringify([...schema['x-envelope'].bidKeys].sort()) + + ';\n' + + 'var ENVELOPE_EXT_KEYS = ' + + JSON.stringify([...schema['x-envelope'].extKeys].sort()) + + ';\n' + + 'var MAX_ACCOUNT_ID_BYTES = ' + + schema.properties.accountId['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_BID_ID_BYTES = ' + + schema.properties.bidId['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_CREATIVE_ID_BYTES = ' + + schema.properties.creativeId['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_CREATIVE_URL_BYTES = ' + + schema.properties.creativeUrl['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_RENDER_ENVELOPE_BYTES = ' + + schema.properties.aaxResponse['x-decodedMaxBytes'] + + ';\n' + + 'var MAX_RENDER_ENVELOPE_BASE64_BYTES = ' + + 4 * Math.ceil(schema.properties.aaxResponse['x-decodedMaxBytes'] / 3) + + ';\n' + + 'var STANDARD_BASE64_PATTERN = ' + + JSON.stringify(schema.properties.aaxResponse.pattern) + + ';\n'; + +const validatorSource = String.raw` +function apsExactRecord(value/*: any*/, expectedKeys/*: string[]*/)/*: boolean*/ { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + var prototype/*: any*/ = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + if (typeof Object.getOwnPropertySymbols === 'function' && Object.getOwnPropertySymbols(value).length !== 0) return false; + var actual/*: string[]*/ = Object.getOwnPropertyNames(value).sort(); + if (actual.length !== expectedKeys.length) return false; + for (var index = 0; index < actual.length; index += 1) { + var propertyName/*: string | undefined*/ = actual[index]; + if (propertyName === undefined || propertyName !== expectedKeys[index]) return false; + var property/*: any*/ = Object.getOwnPropertyDescriptor(value, propertyName); + if (!property || !Object.prototype.hasOwnProperty.call(property, 'value')) return false; + } + return true; +} + +function apsUtf8Length(value/*: string*/)/*: number*/ { + return (new TextEncoder()).encode(value).length; +} + +function apsHasAsciiControl(value/*: string*/)/*: boolean*/ { + return /[\x00-\x1f\x7f]/.test(value); +} + +function apsDimensionResult(value/*: any*/)/*: ApsRendererValidationResult*/ { + if (typeof value !== 'number' || !isFinite(value) || Math.floor(value) !== value || value <= 0) { + return 'invalid_dimensions'; + } + if (value < RENDER_DIMENSION_MIN || value > RENDER_DIMENSION_MAX) { + return 'dimensions_out_of_range'; + } + return 'accepted'; +} + +function apsValidCreativeUrl(value/*: string*/, publisherOrigin/*: string*/)/*: boolean*/ { + try { + var url/*: URL*/ = new URL(value); + return url.protocol === 'https:' && url.hostname !== '' && url.username === '' && + url.password === '' && url.origin !== publisherOrigin; + } catch (_error) { + return false; + } +} + +function apsDecodeEnvelope(value/*: string*/)/*: any | undefined*/ { + if (value.length === 0 || value.length > MAX_RENDER_ENVELOPE_BASE64_BYTES || + value.length % 4 !== 0 || !(new RegExp(STANDARD_BASE64_PATTERN)).test(value)) { + return undefined; + } + try { + var binary/*: string*/ = atob(value); + if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined; + var bytes/*: Uint8Array*/ = new Uint8Array(binary.length); + for (var index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch (_error) { + return undefined; + } +} + +/*EXPORT_DESCRIPTOR_CLASSIFIER*/ function classifyApsRendererDescriptorV1( + value/*: unknown*/ +)/*: ApsRendererValidationResult*/ { + var renderer/*: any*/ = value; + if (!apsExactRecord(renderer, DESCRIPTOR_KEYS) && + !apsExactRecord(renderer, DESCRIPTOR_KEYS_WITH_CREATIVE_ID)) return 'descriptor_invalid'; + if (renderer.type !== 'aps' || renderer.version !== 1 || + typeof renderer.accountId !== 'string' || renderer.accountId.length === 0 || + apsUtf8Length(renderer.accountId) > MAX_ACCOUNT_ID_BYTES || + typeof renderer.bidId !== 'string' || renderer.bidId.length === 0 || + apsUtf8Length(renderer.bidId) > MAX_BID_ID_BYTES || + apsHasAsciiControl(renderer.bidId)) return 'descriptor_invalid'; + if (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') && + (typeof renderer.creativeId !== 'string' || renderer.creativeId.length === 0 || + apsUtf8Length(renderer.creativeId) > MAX_CREATIVE_ID_BYTES)) return 'descriptor_invalid'; + if (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') return 'descriptor_invalid'; + + var widthResult/*: ApsRendererValidationResult*/ = apsDimensionResult(renderer.width); + if (widthResult !== 'accepted') return widthResult; + var heightResult/*: ApsRendererValidationResult*/ = apsDimensionResult(renderer.height); + if (heightResult !== 'accepted') return heightResult; + + if (typeof renderer.creativeUrl !== 'string' || + apsUtf8Length(renderer.creativeUrl) > MAX_CREATIVE_URL_BYTES || + typeof renderer.aaxResponse !== 'string' || + renderer.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES) return 'descriptor_invalid'; + return 'accepted'; +} + +/*EXPORT_CLASSIFIER*/ function classifyApsRendererV1( + value/*: unknown*/, + publisherOrigin/*: string*/ +)/*: ApsRendererValidationResult*/ { + var renderer/*: any*/ = value; + var descriptorResult/*: ApsRendererValidationResult*/ = + classifyApsRendererDescriptorV1(renderer); + if (descriptorResult !== 'accepted') return descriptorResult; + if (!apsValidCreativeUrl(renderer.creativeUrl, publisherOrigin)) return 'descriptor_invalid'; + + var decoded/*: any*/ = apsDecodeEnvelope(renderer.aaxResponse); + if (!apsExactRecord(decoded, ENVELOPE_ROOT_KEYS) || !Array.isArray(decoded.seatbid) || + decoded.seatbid.length !== 1) return 'descriptor_invalid'; + var seat/*: any*/ = decoded.seatbid[0]; + if (!apsExactRecord(seat, ENVELOPE_SEAT_KEYS) || !Array.isArray(seat.bid) || + seat.bid.length !== 1) return 'descriptor_invalid'; + var bid/*: any*/ = seat.bid[0]; + if (!apsExactRecord(bid, ENVELOPE_BID_KEYS) || + !apsExactRecord(bid.ext, ENVELOPE_EXT_KEYS)) return 'descriptor_invalid'; + + var bidWidthResult/*: ApsRendererValidationResult*/ = apsDimensionResult(bid.w); + if (bidWidthResult !== 'accepted') return bidWidthResult; + var bidHeightResult/*: ApsRendererValidationResult*/ = apsDimensionResult(bid.h); + if (bidHeightResult !== 'accepted') return bidHeightResult; + if (bid.id !== renderer.bidId || bid.w !== renderer.width || bid.h !== renderer.height || + bid.ext.creativeurl !== renderer.creativeUrl || bid.ext.tagtype !== renderer.tagType || + typeof bid.price !== 'number' || !isFinite(bid.price) || bid.price < 0) { + return 'descriptor_invalid'; + } + return 'accepted'; +} +`; + +function stripTypeMarkers(source) { + return source + .replaceAll('/*EXPORT_CLASSIFIER*/ ', '') + .replaceAll('/*EXPORT_DESCRIPTOR_CLASSIFIER*/ ', '') + .replace(/\/\*:[^*]+\*\//g, ''); +} + +function applyTypeMarkers(source) { + return source + .replaceAll('/*EXPORT_CLASSIFIER*/ ', 'export ') + .replaceAll('/*EXPORT_DESCRIPTOR_CLASSIFIER*/ ', 'export ') + .replace(/\/\*:([^*]+)\*\//g, ':$1'); +} + +const es5Output = + generatedHeader + + constantsSource + + 'var RENDER_DIMENSION_MIN = ' + + schema.properties.width.minimum + + ';\n' + + 'var RENDER_DIMENSION_MAX = ' + + schema.properties.width.maximum + + ';\n' + + stripTypeMarkers(validatorSource).trimStart(); + +const typescriptOutput = + generatedHeader + + '/* eslint-disable */\n' + + "export type ApsRendererValidationResult = 'accepted' | 'descriptor_invalid' | " + + "'invalid_dimensions' | 'dimensions_out_of_range';\n" + + constantsSource + + 'export { MAX_ACCOUNT_ID_BYTES, MAX_BID_ID_BYTES, MAX_CREATIVE_ID_BYTES, ' + + 'MAX_RENDER_ENVELOPE_BASE64_BYTES };\n' + + 'export const RENDER_DIMENSION_MIN = ' + + schema.properties.width.minimum + + ';\n' + + 'export const RENDER_DIMENSION_MAX = ' + + schema.properties.width.maximum + + ';\n' + + applyTypeMarkers(validatorSource).trimStart(); + +const outputs = [ + [es5Path, es5Output], + [typescriptPath, typescriptOutput], +]; +const checkOnly = process.argv.slice(2).includes('--check'); + +if (checkOnly) { + const stale = []; + for (const [outputPath, expected] of outputs) { + let actual; + try { + actual = await readFile(outputPath, 'utf8'); + } catch { + stale.push(path.relative(repositoryRoot, outputPath)); + continue; + } + if (actual !== expected) stale.push(path.relative(repositoryRoot, outputPath)); + } + if (stale.length > 0) { + throw new Error('stale APS renderer contract output: ' + stale.join(', ')); + } +} else { + for (const [outputPath, output] of outputs) { + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, output); + } +} diff --git a/scripts/integration-tests-aps-runner-proxy.sh b/scripts/integration-tests-aps-runner-proxy.sh new file mode 100755 index 000000000..c6027fa94 --- /dev/null +++ b/scripts/integration-tests-aps-runner-proxy.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# Run the hermetic APS runner-proxy corpus through one actual adapter runtime. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +if [ "$#" -ne 2 ] || [ "$1" != "--runtime" ]; then + echo "usage: $0 --runtime " >&2 + exit 2 +fi + +RUNTIME="$2" +case "$RUNTIME" in + axum|fastly|cloudflare|spin) ;; + *) + echo "unsupported APS runner-proxy runtime: $RUNTIME" >&2 + exit 2 + ;; +esac + +ORIGIN_PORT="${INTEGRATION_ORIGIN_PORT:-8888}" +export ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/target/integration-test-artifacts}" +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +if [ -z "$HOST_TARGET" ]; then + echo "failed to detect the native Rust target" >&2 + exit 1 +fi + +export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" +export TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" +export TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret-padded-32" +export TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false + +ABSENCE_PATTERNS=( + aps-runner-proxy-integration-test + TS_APS_RUNNER_PROXY_TEST_ENDPOINT + APS_RUNNER_PROXY_FIXTURE + aps_runner_proxy_test_endpoint + aps_runner_proxy_fixture + aps-runner-proxy-fixture-bounded + x-ts-aps-logical-url +) + +# The production renderer and live runner-proxy routes are intentionally not +# absence sentinels. The entries above are unique to the feature-only local +# upstream and test transport; finding one in a release artifact would mean the +# hermetic fixture or its routing seam leaked into production. + +CARGO_TEST_PID="" +CARGO_TEST_PGID="" +PROCESS_GROUP_FILE="$(mktemp -t trusted-server-aps-pgids.XXXXXX)" +SHELL_PGID="$(ps -o pgid= -p "$$" 2>/dev/null | tr -d '[:space:]' || true)" + +terminate_registered_process_groups() { + local pgid + local actual_pgid + while IFS= read -r pgid; do + if [[ ! "$pgid" =~ ^[1-9][0-9]*$ ]] || [ "$pgid" = "$SHELL_PGID" ]; then + continue + fi + actual_pgid="$(ps -o pgid= -p "$pgid" 2>/dev/null | tr -d '[:space:]' || true)" + if [ "$actual_pgid" = "$pgid" ]; then + kill -TERM -- "-$pgid" 2>/dev/null || true + fi + done < "$PROCESS_GROUP_FILE" +} + +terminate_cargo_test() { + if [ -z "$CARGO_TEST_PID" ]; then + return + fi + if [ -n "$CARGO_TEST_PGID" ]; then + kill -TERM -- "-$CARGO_TEST_PGID" 2>/dev/null || true + else + kill -TERM "$CARGO_TEST_PID" 2>/dev/null || true + fi + wait "$CARGO_TEST_PID" 2>/dev/null || true +} + +cleanup() { + local status="$?" + trap - EXIT INT TERM + terminate_cargo_test + terminate_registered_process_groups + if [[ "$PROCESS_GROUP_FILE" = /* ]] && [ -f "$PROCESS_GROUP_FILE" ]; then + rm -f -- "$PROCESS_GROUP_FILE" + fi + exit "$status" +} + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +assert_release_absence() { + local artifact="$1" + local rg_args=() + local pattern + for pattern in "${ABSENCE_PATTERNS[@]}"; do + rg_args+=(--regexp "$pattern") + done + if strings "$artifact" | rg --fixed-strings "${rg_args[@]}"; then + echo "production artifact contains an APS proxy integration sentinel: $artifact" >&2 + exit 1 + fi +} + +echo "==> Building and checking the production $RUNTIME artifact..." +case "$RUNTIME" in + axum) + cargo build --package trusted-server-adapter-axum --release + assert_release_absence target/release/trusted-server-axum + cargo build --package trusted-server-adapter-axum --release \ + --features aps-runner-proxy-integration-test + export AXUM_BINARY_PATH="$REPO_ROOT/target/release/trusted-server-axum" + ;; + fastly) + cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 + assert_release_absence \ + target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm + cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 \ + --features aps-runner-proxy-integration-test + INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" \ + ./scripts/generate-integration-viceroy-configs.sh + export WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" + export VICEROY_CONFIG_PATH="$ARTIFACTS_DIR/configs/viceroy.toml" + ;; + cloudflare) + bash crates/trusted-server-adapter-cloudflare/build.sh + assert_release_absence crates/trusted-server-adapter-cloudflare/build/index.js + assert_release_absence crates/trusted-server-adapter-cloudflare/build/index_bg.wasm + TS_WORKER_BUILD_FEATURES="cloudflare,aps-runner-proxy-integration-test" \ + bash crates/trusted-server-adapter-cloudflare/build.sh + export CLOUDFLARE_WRANGLER_DIR="$REPO_ROOT/crates/trusted-server-adapter-cloudflare" + ;; + spin) + cargo build --package trusted-server-adapter-spin --release --target wasm32-wasip1 \ + --features spin + assert_release_absence \ + target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm + cargo build --package trusted-server-adapter-spin --release --target wasm32-wasip1 \ + --features spin,aps-runner-proxy-integration-test + export WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm" + ;; +esac + +echo "==> Running the APS runner-proxy corpus through $RUNTIME..." +TEST_COMMAND=( + cargo test + --manifest-path crates/trusted-server-integration-tests/Cargo.toml + --features aps-runner-proxy + --target "$HOST_TARGET" + --test aps_runner_proxy + actual_adapter_proxy_corpus + -- --ignored --test-threads=1 +) + +if command -v setsid >/dev/null 2>&1; then + setsid env \ + APS_RUNNER_PROXY_RUNTIME="$RUNTIME" \ + APS_RUNNER_PROXY_PROCESS_GROUP_FILE="$PROCESS_GROUP_FILE" \ + INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" \ + RUST_LOG="${RUST_LOG:-info}" \ + "${TEST_COMMAND[@]}" & +else + # BSD/macOS does not provide `setsid`. Bash job control still launches a + # background job in its own process group, so the cleanup trap can terminate + # Cargo, the test binary, and every runtime it starts as one unit. + set -m + env \ + APS_RUNNER_PROXY_RUNTIME="$RUNTIME" \ + APS_RUNNER_PROXY_PROCESS_GROUP_FILE="$PROCESS_GROUP_FILE" \ + INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" \ + RUST_LOG="${RUST_LOG:-info}" \ + "${TEST_COMMAND[@]}" & + set +m +fi +CARGO_TEST_PID="$!" + +CHILD_PGID="" +# The background child can be observed between fork and `setsid(2)`, especially +# when `setsid` is provided by a shim on BSD/macOS. Give it a bounded moment to +# enter its dedicated process group before enforcing the cleanup invariant. +for ((attempt = 0; attempt < 50; attempt += 1)); do + CHILD_PGID="$(ps -o pgid= -p "$CARGO_TEST_PID" 2>/dev/null | tr -d '[:space:]' || true)" + if [[ "$CHILD_PGID" =~ ^[1-9][0-9]*$ ]] && [ "$CHILD_PGID" != "$SHELL_PGID" ]; then + break + fi + sleep 0.01 +done +if [[ "$CHILD_PGID" =~ ^[1-9][0-9]*$ ]] && [ "$CHILD_PGID" != "$SHELL_PGID" ]; then + CARGO_TEST_PGID="$CHILD_PGID" +else + echo "failed to isolate the APS corpus in a dedicated process group" >&2 + terminate_cargo_test + CARGO_TEST_PID="" + exit 1 +fi + +if wait "$CARGO_TEST_PID"; then + TEST_STATUS=0 +else + TEST_STATUS="$?" +fi +CARGO_TEST_PID="" +CARGO_TEST_PGID="" +exit "$TEST_STATUS" diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 714de510b..da2b32867 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -7,9 +7,9 @@ # # Prerequisites: # - Docker running -# - Viceroy installed: cargo install viceroy --version 0.17.0 --locked --force +# - Viceroy installed: cargo install viceroy --version 0.19.0 --locked --force # - wasm32-wasip1 target: rustup target add wasm32-wasip1 -# - Node.js with npx available +# - Node.js with npm available # set -euo pipefail @@ -17,15 +17,52 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" ORIGIN_PORT="${INTEGRATION_ORIGIN_PORT:-8888}" +export ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/target/integration-test-artifacts}" BROWSER_DIR="crates/trusted-server-integration-tests/browser" TSJS_LIB_DIR="crates/trusted-server-js/lib" NODE_VERSION="$(grep '^nodejs ' .tool-versions | awk '{print $2}')" +FRAMEWORKS_VALUE="${TS_BROWSER_FRAMEWORKS:-nextjs wordpress}" +FRAMEWORKS_VALUE="${FRAMEWORKS_VALUE//,/ }" +read -r -a FRAMEWORKS <<< "$FRAMEWORKS_VALUE" +PROJECTS_VALUE="${TS_BROWSER_PROJECTS:-chromium}" +PROJECTS_VALUE="${PROJECTS_VALUE//,/ }" +read -r -a BROWSER_PROJECTS <<< "$PROJECTS_VALUE" if [ -z "$NODE_VERSION" ]; then echo "Failed to detect Node.js version from .tool-versions" >&2 exit 1 fi +if [ "${#FRAMEWORKS[@]}" -eq 0 ]; then + echo "TS_BROWSER_FRAMEWORKS must select at least one framework" >&2 + exit 1 +fi + +if [ "${#BROWSER_PROJECTS[@]}" -eq 0 ]; then + echo "TS_BROWSER_PROJECTS must select at least one browser" >&2 + exit 1 +fi + +for framework in "${FRAMEWORKS[@]}"; do + case "$framework" in + nextjs|wordpress) ;; + *) + echo "Unsupported browser framework: $framework" >&2 + exit 1 + ;; + esac +done + +for project in "${BROWSER_PROJECTS[@]}"; do + case "$project" in + chromium|firefox|webkit) ;; + *) + echo "Unsupported browser project: $project" >&2 + exit 1 + ;; + esac +done + # --- Build WASM binary --- echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ @@ -37,32 +74,38 @@ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ echo "==> Generating Viceroy configs..." INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" ./scripts/generate-integration-viceroy-configs.sh -GENERATED_VICEROY_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy.toml" +GENERATED_VICEROY_CONFIG_PATH="$ARTIFACTS_DIR/configs/viceroy.toml" # --- Build Docker images --- -echo "==> Building WordPress test container..." -docker build -t test-wordpress:latest \ - crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/ - -echo "==> Building Next.js test container..." -docker build \ - --build-arg NODE_VERSION="$NODE_VERSION" \ - -t test-nextjs:latest \ - crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ +for framework in "${FRAMEWORKS[@]}"; do + if [ "$framework" = "wordpress" ]; then + echo "==> Building WordPress test container..." + docker build -t test-wordpress:latest \ + crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/ + else + echo "==> Building Next.js test container..." + docker build \ + --build-arg NODE_VERSION="$NODE_VERSION" \ + -t test-nextjs:latest \ + crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ + fi +done # --- Install Playwright --- echo "==> Installing Playwright dependencies..." -cd "$REPO_ROOT/$BROWSER_DIR" -npm ci -npx playwright install chromium +npm --prefix "$BROWSER_DIR" ci +PLAYWRIGHT_INSTALL_ARGS=(install) +if [ "${CI:-}" = "true" ]; then + PLAYWRIGHT_INSTALL_ARGS+=(--with-deps) +fi +npm --prefix "$BROWSER_DIR" exec -- playwright \ + "${PLAYWRIGHT_INSTALL_ARGS[@]}" "${BROWSER_PROJECTS[@]}" # --- Build browser-side Trusted Server and external Prebid fixtures --- echo "==> Building TSJS browser fixtures..." -cd "$REPO_ROOT/$TSJS_LIB_DIR" -npm ci -npm run build -npm run build:prebid-external -cd "$REPO_ROOT/$BROWSER_DIR" +npm --prefix "$TSJS_LIB_DIR" ci +npm --prefix "$TSJS_LIB_DIR" run build +npm --prefix "$TSJS_LIB_DIR" run build:prebid-external # --- Export env vars for global-setup.ts --- export WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" @@ -80,15 +123,17 @@ stop_matching_containers() { } cleanup() { - stop_matching_containers test-nextjs:latest - stop_matching_containers test-wordpress:latest + for framework in "${FRAMEWORKS[@]}"; do + stop_matching_containers "test-$framework:latest" + done } trap cleanup EXIT # --- Run tests for each framework --- -for framework in nextjs wordpress; do +for framework in "${FRAMEWORKS[@]}"; do echo "==> Running Playwright tests for $framework..." - TEST_FRAMEWORK="$framework" npx playwright test "$@" + TEST_FRAMEWORK="$framework" npm --prefix "$BROWSER_DIR" exec -- \ + playwright test --config "$REPO_ROOT/$BROWSER_DIR/playwright.config.ts" "$@" done echo "==> All browser tests passed." diff --git a/scripts/integration-tests.sh b/scripts/integration-tests.sh index 96e492f40..f4d8196b3 100755 --- a/scripts/integration-tests.sh +++ b/scripts/integration-tests.sh @@ -7,7 +7,7 @@ # # Prerequisites: # - Docker running -# - Viceroy installed: cargo install viceroy --version 0.17.0 --locked --force +# - Viceroy installed: cargo install viceroy --version 0.19.0 --locked --force # - wasm32-wasip1 target: rustup target add wasm32-wasip1 # set -euo pipefail diff --git a/scripts/validate-tsjs-performance-evidence.mjs b/scripts/validate-tsjs-performance-evidence.mjs new file mode 100644 index 000000000..99b80be77 --- /dev/null +++ b/scripts/validate-tsjs-performance-evidence.mjs @@ -0,0 +1,1039 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const EXPECTED = Object.freeze({ + schemaVersion: 5, + chromium: "145.0.7632.6", + machineClass: "github-hosted:ubuntu-24.04", + runnerImage: "ubuntu-24.04", + fixture: "tsjs-main-paired-network-v2", + controller: "generated-server-v1+production-main-v1", + node: "v24.12.0", + npm: "11.6.2", + typescript: "6.0.3", + warmupsPerVariant: 5, + samplesPerVariant: 50, + percentile: 90, + interleaving: "alternating-main-candidate", + networkProfile: Object.freeze({ + mechanism: "cdp-Network.emulateNetworkConditions", + appliedBeforeNavigation: true, + latencyMs: 150, + downloadThroughputBytesPerSecond: 200_000, + uploadThroughputBytesPerSecond: 93_750, + packetLossPercent: 0, + }), + maximumRatio: 1.1, + heapCheckpoints: Object.freeze([ + "afterBoot", + "afterFirstRender", + "afterRefresh", + "afterSpaNavigation", + ]), + heapMaximumRatio: 1.1, + heapHardCeilingBytes: 4 * 1024 * 1024, + workflowName: "TSJS Performance Gate", + workflowFile: ".github/workflows/tsjs-performance-gate.yml", +}); + +function fail(message) { + throw new Error(`invalid TSJS performance evidence: ${message}`); +} + +function record(value, path) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail(`${path} must be an object`); + } + return value; +} + +function exactKeys(value, keys, path) { + const actual = Object.keys(record(value, path)).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + fail(`${path} has an unexpected schema`); + } +} + +function exactString(value, expected, path) { + if (typeof value !== "string" || value !== expected) + fail(`${path} must equal ${expected}`); +} + +function boolean(value, expected, path) { + if (typeof value !== "boolean" || value !== expected) + fail(`${path} must equal ${expected}`); +} + +function finiteNumber(value, path, { integer = false, minimum = 0 } = {}) { + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < minimum || + (integer && !Number.isSafeInteger(value)) + ) { + fail(`${path} must be a finite${integer ? " safe integer" : " number"}`); + } + return value; +} + +function nearestRank(values, percentile) { + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.ceil((percentile / 100) * ordered.length) - 1]; +} + +export function validateEvidence(evidence, expected) { + const expectedMode = expected.mode; + if ( + expectedMode !== "preswitch" && + expectedMode !== "postswitch" && + expectedMode !== "pull-request" + ) { + fail("expected mode must be preswitch, postswitch, or pull-request"); + } + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/.test(expected.evidenceId ?? "")) { + fail("expected evidence id is invalid"); + } + if (!/^[0-9a-f]{40}$/.test(expected.headSha ?? "")) + fail("expected head SHA is invalid"); + if (!/^[0-9a-f]{40}$/.test(expected.mainSha ?? "")) + fail("expected main SHA is invalid"); + + exactKeys( + evidence, + [ + "schemaVersion", + "evidenceId", + "mode", + "headSha", + "environment", + "sampling", + "networkProfile", + "marks", + "performance", + "heap", + "requests", + "assertions", + "provenance", + "result", + ], + "evidence", + ); + if (evidence.schemaVersion !== EXPECTED.schemaVersion) + fail("schemaVersion drifted"); + exactString(evidence.evidenceId, expected.evidenceId, "evidenceId"); + exactString(evidence.mode, expectedMode, "mode"); + exactString(evidence.headSha, expected.headSha, "headSha"); + exactString(evidence.result, "complete", "result"); + + exactKeys( + evidence.environment, + [ + "chromium", + "controller", + "machineClass", + "runnerImage", + "fixture", + "node", + "npm", + "typescript", + ], + "environment", + ); + exactString( + evidence.environment.chromium, + EXPECTED.chromium, + "environment.chromium", + ); + exactString( + evidence.environment.controller, + EXPECTED.controller, + "environment.controller", + ); + exactString( + evidence.environment.machineClass, + EXPECTED.machineClass, + "environment.machineClass", + ); + exactString( + evidence.environment.runnerImage, + EXPECTED.runnerImage, + "environment.runnerImage", + ); + exactString( + evidence.environment.fixture, + EXPECTED.fixture, + "environment.fixture", + ); + for (const name of ["node", "npm", "typescript"]) + exactString( + evidence.environment[name], + EXPECTED[name], + `environment.${name}`, + ); + + exactKeys( + evidence.sampling, + ["warmupsPerVariant", "samplesPerVariant", "percentile", "interleaving"], + "sampling", + ); + if ( + evidence.sampling.warmupsPerVariant !== EXPECTED.warmupsPerVariant || + evidence.sampling.samplesPerVariant !== EXPECTED.samplesPerVariant || + evidence.sampling.percentile !== EXPECTED.percentile || + evidence.sampling.interleaving !== EXPECTED.interleaving + ) { + fail("sampling contract drifted"); + } + + exactKeys( + evidence.networkProfile, + [ + "mechanism", + "appliedBeforeNavigation", + "latencyMs", + "downloadThroughputBytesPerSecond", + "uploadThroughputBytesPerSecond", + "packetLossPercent", + ], + "networkProfile", + ); + for (const [name, expectedValue] of Object.entries(EXPECTED.networkProfile)) { + if (evidence.networkProfile[name] !== expectedValue) + fail(`networkProfile.${name} drifted`); + } + + exactKeys( + evidence.marks, + [ + "source", + "comparisonStart", + "firstObservableAction", + "candidateBidsScript", + "candidateFirstDisplay", + "candidateFirstDisplayPaint", + ], + "marks", + ); + exactString( + evidence.marks.source, + "fixture-first-observable-action", + "marks.source", + ); + for (const name of [ + "comparisonStart", + "firstObservableAction", + "candidateBidsScript", + "candidateFirstDisplay", + "candidateFirstDisplayPaint", + ]) { + boolean(evidence.marks[name], true, `marks.${name}`); + } + + exactKeys(evidence.performance, ["requestToFirstActionMs"], "performance"); + const timing = evidence.performance.requestToFirstActionMs; + exactKeys( + timing, + ["main", "candidate", "percentile", "maximumRatio", "observedRatio"], + "performance timing", + ); + if (timing.percentile !== EXPECTED.percentile) + fail("performance percentile drifted"); + if (timing.maximumRatio !== EXPECTED.maximumRatio) + fail("performance ratio limit drifted"); + exactKeys( + timing.main, + ["sha", "artifactModel", "criticalTransferBytes", "samples", "p90"], + "main performance timing", + ); + exactString(timing.main.sha, expected.mainSha, "performance main SHA"); + if ( + timing.main.artifactModel !== "legacy-main-v1" && + timing.main.artifactModel !== "release-v1" + ) { + fail("performance main artifact model is invalid"); + } + finiteNumber( + timing.main.criticalTransferBytes, + "main critical transfer bytes", + { integer: true, minimum: 1 }, + ); + exactKeys( + timing.candidate, + ["artifactModel", "criticalTransferBytes", "samples", "p90"], + "candidate performance timing", + ); + exactString( + timing.candidate.artifactModel, + "release-v1", + "performance candidate artifact model", + ); + finiteNumber( + timing.candidate.criticalTransferBytes, + "candidate critical transfer bytes", + { integer: true, minimum: 1 }, + ); + const validateVariant = (variant, path) => { + if ( + !Array.isArray(variant.samples) || + variant.samples.length !== EXPECTED.samplesPerVariant + ) { + fail(`${path} samples must contain exactly 50 values`); + } + const samples = variant.samples.map((value, index) => + finiteNumber(value, `${path} performance sample ${index}`), + ); + const variantP90 = finiteNumber(variant.p90, `${path} performance p90`); + if (!Object.is(variantP90, nearestRank(samples, EXPECTED.percentile))) { + fail(`${path} performance p90 is inconsistent with the samples`); + } + return variantP90; + }; + const mainP90 = validateVariant(timing.main, "main"); + const candidateP90 = validateVariant(timing.candidate, "candidate"); + if (mainP90 <= 0) fail("main performance p90 must be positive"); + const observedRatio = finiteNumber( + timing.observedRatio, + "performance observed ratio", + ); + if (Math.abs(observedRatio - candidateP90 / mainP90) > Number.EPSILON) { + fail("performance observed ratio is inconsistent with the p90 values"); + } + if (candidateP90 > mainP90 * EXPECTED.maximumRatio) + fail("candidate performance p90 exceeds the paired 10% limit"); + + exactKeys( + evidence.heap, + ["collection", "maximumRatio", "hardCeilingBytes", "main", "candidate"], + "heap", + ); + exactString( + evidence.heap.collection, + "one-collectGarbage-then-immediate-getHeapUsage", + "heap.collection", + ); + if (evidence.heap.maximumRatio !== EXPECTED.heapMaximumRatio) + fail("heap ratio limit drifted"); + if (evidence.heap.hardCeilingBytes !== EXPECTED.heapHardCeilingBytes) + fail("heap hard ceiling drifted"); + exactKeys(evidence.heap.main, ["sha", "checkpoints"], "heap.main"); + exactString(evidence.heap.main.sha, expected.mainSha, "heap main SHA"); + exactKeys(evidence.heap.candidate, ["checkpoints"], "heap.candidate"); + exactKeys( + evidence.heap.main.checkpoints, + EXPECTED.heapCheckpoints, + "heap.main.checkpoints", + ); + exactKeys( + evidence.heap.candidate.checkpoints, + EXPECTED.heapCheckpoints, + "heap.candidate.checkpoints", + ); + for (const name of EXPECTED.heapCheckpoints) { + const mainUsedSize = finiteNumber( + evidence.heap.main.checkpoints[name], + `heap.main.checkpoints.${name}`, + { integer: true, minimum: 1 }, + ); + const candidateUsedSize = finiteNumber( + evidence.heap.candidate.checkpoints[name], + `heap.candidate.checkpoints.${name}`, + { integer: true, minimum: 1 }, + ); + if ( + mainUsedSize > EXPECTED.heapHardCeilingBytes || + candidateUsedSize > EXPECTED.heapHardCeilingBytes + ) { + fail(`${name} retained heap exceeds the hard ceiling`); + } + if (candidateUsedSize > mainUsedSize * EXPECTED.heapMaximumRatio) { + fail(`${name} retained heap exceeds the paired 10% limit`); + } + } + + exactKeys(evidence.requests, ["critical", "deferred"], "requests"); + exactKeys(evidence.requests.critical, ["count"], "requests.critical"); + if (evidence.requests.critical.count !== 1) + fail("critical request count must be exactly one"); + exactKeys( + evidence.requests.deferred, + [ + "count", + "requestBeforePaintCount", + "preloadBeforePaintCount", + "preparationBeforePaintCount", + "executionBeforePaintCount", + "independentlyTriggered", + "headOfLineBlocking", + ], + "requests.deferred", + ); + finiteNumber(evidence.requests.deferred.count, "deferred count", { + integer: true, + }); + if (evidence.requests.deferred.count !== 2) + fail("deferred module count must be exactly two"); + for (const name of [ + "requestBeforePaintCount", + "preloadBeforePaintCount", + "preparationBeforePaintCount", + "executionBeforePaintCount", + ]) { + if (evidence.requests.deferred[name] !== 0) + fail(`deferred ${name} must be zero`); + } + boolean( + evidence.requests.deferred.independentlyTriggered, + true, + "deferred independentlyTriggered", + ); + boolean( + evidence.requests.deferred.headOfLineBlocking, + false, + "deferred headOfLineBlocking", + ); + + exactKeys(evidence.assertions, ["correctness", "loadOrder"], "assertions"); + boolean(evidence.assertions.correctness, true, "assertions.correctness"); + boolean(evidence.assertions.loadOrder, true, "assertions.loadOrder"); + + exactKeys( + evidence.provenance, + [ + "workflowName", + "workflowFile", + "runId", + "runAttempt", + "artifactName", + "headSha", + ], + "provenance", + ); + exactString( + evidence.provenance.workflowName, + EXPECTED.workflowName, + "provenance.workflowName", + ); + exactString( + evidence.provenance.workflowFile, + EXPECTED.workflowFile, + "provenance.workflowFile", + ); + finiteNumber(evidence.provenance.runId, "provenance.runId", { + integer: true, + minimum: 1, + }); + finiteNumber(evidence.provenance.runAttempt, "provenance.runAttempt", { + integer: true, + minimum: 1, + }); + exactString( + evidence.provenance.artifactName, + `tsjs-performance-${expected.evidenceId}`, + "provenance.artifactName", + ); + exactString( + evidence.provenance.headSha, + expected.headSha, + "provenance.headSha", + ); + return evidence; +} + +function validFixture() { + const evidenceId = "aps-tsjs-preswitch-12345678"; + const headSha = "a".repeat(40); + const mainSha = "c".repeat(40); + const mainSamples = Array.from({ length: 50 }, () => 200); + const candidateSamples = Array.from({ length: 50 }, () => 210); + return { + expected: { evidenceId, headSha, mainSha, mode: "preswitch" }, + evidence: { + schemaVersion: 5, + evidenceId, + mode: "preswitch", + headSha, + environment: { + chromium: "145.0.7632.6", + controller: "generated-server-v1+production-main-v1", + machineClass: "github-hosted:ubuntu-24.04", + runnerImage: "ubuntu-24.04", + fixture: "tsjs-main-paired-network-v2", + node: "v24.12.0", + npm: "11.6.2", + typescript: "6.0.3", + }, + sampling: { + warmupsPerVariant: 5, + samplesPerVariant: 50, + percentile: 90, + interleaving: "alternating-main-candidate", + }, + networkProfile: { + mechanism: "cdp-Network.emulateNetworkConditions", + appliedBeforeNavigation: true, + latencyMs: 150, + downloadThroughputBytesPerSecond: 200_000, + uploadThroughputBytesPerSecond: 93_750, + packetLossPercent: 0, + }, + marks: { + source: "fixture-first-observable-action", + comparisonStart: true, + firstObservableAction: true, + candidateBidsScript: true, + candidateFirstDisplay: true, + candidateFirstDisplayPaint: true, + }, + performance: { + requestToFirstActionMs: { + main: { + sha: mainSha, + artifactModel: "legacy-main-v1", + criticalTransferBytes: 82_000, + samples: mainSamples, + p90: 200, + }, + candidate: { + artifactModel: "release-v1", + criticalTransferBytes: 220_000, + samples: candidateSamples, + p90: 210, + }, + percentile: 90, + maximumRatio: 1.1, + observedRatio: 210 / 200, + }, + }, + heap: { + collection: "one-collectGarbage-then-immediate-getHeapUsage", + maximumRatio: 1.1, + hardCeilingBytes: 4 * 1024 * 1024, + main: { + sha: mainSha, + checkpoints: Object.fromEntries( + EXPECTED.heapCheckpoints.map((name) => [name, 1_600_000]), + ), + }, + candidate: { + checkpoints: Object.fromEntries( + EXPECTED.heapCheckpoints.map((name) => [name, 1_650_000]), + ), + }, + }, + requests: { + critical: { count: 1 }, + deferred: { + count: 2, + requestBeforePaintCount: 0, + preloadBeforePaintCount: 0, + preparationBeforePaintCount: 0, + executionBeforePaintCount: 0, + independentlyTriggered: true, + headOfLineBlocking: false, + }, + }, + assertions: { correctness: true, loadOrder: true }, + provenance: { + workflowName: "TSJS Performance Gate", + workflowFile: ".github/workflows/tsjs-performance-gate.yml", + runId: 123, + runAttempt: 1, + artifactName: `tsjs-performance-${evidenceId}`, + headSha, + }, + result: "complete", + }, + }; +} + +function runSelfTest() { + const fixture = validFixture(); + validateEvidence(fixture.evidence, fixture.expected); + const mutations = [ + ["evidence id", (value) => (value.evidenceId = "wrong-evidence")], + ["head SHA", (value) => (value.headSha = "b".repeat(40))], + ["mode", (value) => (value.mode = "postswitch")], + ["environment", (value) => (value.environment.chromium = "145.0.0.0")], + ["controller", (value) => (value.environment.controller = "handwritten")], + ["fixture", (value) => (value.environment.fixture = "drifted")], + ["node", (value) => (value.environment.node = "v24.11.0")], + ["npm", (value) => (value.environment.npm = "11.6.1")], + ["typescript", (value) => (value.environment.typescript = "6.0.2")], + ["warmups", (value) => (value.sampling.warmupsPerVariant = 4)], + ["interleaving", (value) => (value.sampling.interleaving = "sequential")], + ["network latency", (value) => (value.networkProfile.latencyMs = 0)], + [ + "network ordering", + (value) => (value.networkProfile.appliedBeforeNavigation = false), + ], + [ + "candidate sample count", + (value) => + value.performance.requestToFirstActionMs.candidate.samples.pop(), + ], + [ + "candidate transfer bytes", + (value) => + (value.performance.requestToFirstActionMs.candidate.criticalTransferBytes = 0), + ], + [ + "main sample count", + (value) => value.performance.requestToFirstActionMs.main.samples.pop(), + ], + [ + "main transfer bytes", + (value) => + (value.performance.requestToFirstActionMs.main.criticalTransferBytes = 0), + ], + ["percentile", (value) => (value.sampling.percentile = 95)], + ["real marks", (value) => (value.marks.source = "synthetic")], + ["missing mark", (value) => (value.marks.firstObservableAction = false)], + [ + "paired p90 limit", + (value) => { + value.performance.requestToFirstActionMs.candidate.samples.fill(221); + value.performance.requestToFirstActionMs.candidate.p90 = 221; + value.performance.requestToFirstActionMs.observedRatio = 221 / 200; + }, + ], + [ + "p90 consistency", + (value) => (value.performance.requestToFirstActionMs.candidate.p90 = 19), + ], + [ + "finite sample", + (value) => + (value.performance.requestToFirstActionMs.candidate.samples[0] = null), + ], + [ + "main SHA", + (value) => + (value.performance.requestToFirstActionMs.main.sha = "b".repeat(40)), + ], + [ + "main artifact model", + (value) => + (value.performance.requestToFirstActionMs.main.artifactModel = + "unknown-v1"), + ], + [ + "candidate artifact model", + (value) => + (value.performance.requestToFirstActionMs.candidate.artifactModel = + "legacy-main-v1"), + ], + [ + "observed ratio", + (value) => (value.performance.requestToFirstActionMs.observedRatio = 1), + ], + [ + "heap ratio", + (value) => (value.heap.candidate.checkpoints.afterBoot = 1_800_000), + ], + [ + "heap hard ceiling", + (value) => { + value.heap.main.checkpoints.afterBoot = 4 * 1024 * 1024 + 1; + value.heap.candidate.checkpoints.afterBoot = 4 * 1024 * 1024 + 1; + }, + ], + ["heap main SHA", (value) => (value.heap.main.sha = "b".repeat(40))], + ["critical count", (value) => (value.requests.critical.count = 2)], + ["deferred count", (value) => (value.requests.deferred.count = 1)], + ["excess deferred count", (value) => (value.requests.deferred.count = 3)], + [ + "deferred request", + (value) => (value.requests.deferred.requestBeforePaintCount = 1), + ], + [ + "deferred preload", + (value) => (value.requests.deferred.preloadBeforePaintCount = 1), + ], + [ + "deferred prepare", + (value) => (value.requests.deferred.preparationBeforePaintCount = 1), + ], + [ + "deferred execute", + (value) => (value.requests.deferred.executionBeforePaintCount = 1), + ], + ["HOL", (value) => (value.requests.deferred.headOfLineBlocking = true)], + [ + "independent", + (value) => (value.requests.deferred.independentlyTriggered = false), + ], + ["correctness", (value) => (value.assertions.correctness = false)], + ["load order", (value) => (value.assertions.loadOrder = false)], + ["incomplete", (value) => (value.result = "failed")], + [ + "workflow", + (value) => (value.provenance.workflowFile = ".github/workflows/test.yml"), + ], + ["artifact", (value) => (value.provenance.artifactName = "wrong")], + ["schema", (value) => (value.extra = true)], + ]; + for (const [name, mutate] of mutations) { + const candidate = structuredClone(fixture.evidence); + mutate(candidate); + assert.throws( + () => validateEvidence(candidate, fixture.expected), + /invalid TSJS performance evidence/u, + `${name} mutation should be rejected`, + ); + } + assert.throws( + () => + parseArguments([ + "--file", + "evidence.json", + "--evidence-id", + fixture.expected.evidenceId, + "--head-sha", + fixture.expected.headSha, + "--mode", + "preswitch", + "--mode", + "postswitch", + ]), + /invalid TSJS performance evidence/u, + "duplicate CLI bindings must be rejected rather than overwritten", + ); + const repositoryRoot = new URL("../", import.meta.url); + const performanceTest = readFileSync( + new URL( + "crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts", + repositoryRoot, + ), + "utf8", + ); + const performanceWorkflow = readFileSync( + new URL(".github/workflows/tsjs-performance-gate.yml", repositoryRoot), + "utf8", + ); + const generatorSource = readFileSync( + new URL( + "crates/trusted-server-integration-tests/src/bin/generate-tsjs-prospective-fixture.rs", + repositoryRoot, + ), + "utf8", + ); + const integrationWorkflow = readFileSync( + new URL(".github/workflows/integration-tests.yml", repositoryRoot), + "utf8", + ); + const browserTestScript = readFileSync( + new URL("scripts/integration-tests-browser.sh", repositoryRoot), + "utf8", + ); + const apsProxyScript = readFileSync( + new URL("scripts/integration-tests-aps-runner-proxy.sh", repositoryRoot), + "utf8", + ); + const generalTestWorkflow = readFileSync( + new URL(".github/workflows/test.yml", repositoryRoot), + "utf8", + ); + assert.match( + performanceTest, + /generate-tsjs-prospective-fixture/u, + "the browser gate must consume the generated prospective server controller", + ); + assert.match( + performanceTest, + /TSJS_SKIP_BUILD/u, + "the controller generator must consume the workflow's one canonical artifact build", + ); + assert.doesNotMatch( + performanceTest, + /__tsjsPerf/u, + "the browser gate must read real performance entries, never the placeholder API", + ); + assert.match( + performanceTest, + /from "node:http"/u, + "the browser gate must serve the fixture through node:http", + ); + assert.match( + performanceTest, + /\.listen\(0, "127\.0\.0\.1"/u, + "the browser gate must listen on an ephemeral IPv4 loopback port", + ); + assert.match( + performanceTest, + /FIXTURE_ID = "tsjs-main-paired-network-v2"/u, + "the browser gate must identify the paired network-shaped fixture", + ); + assert.doesNotMatch( + performanceTest, + /62421ee44c62f24534ea8782a46dfa5bfbcea950/u, + "the browser gate must not retain the obsolete frozen reference SHA", + ); + assert.match( + performanceTest, + /Network\.emulateNetworkConditions[\s\S]*latency: 150[\s\S]*downloadThroughput: 200_000[\s\S]*uploadThroughput: 93_750/u, + "the browser gate must apply the fixed CDP network profile before navigation", + ); + assert.ok( + performanceTest.indexOf( + 'networkSession.send("Network.emulateNetworkConditions"', + ) < performanceTest.indexOf("await page.goto(fixtureUrl"), + "the browser gate must install network shaping before either variant navigates", + ); + assert.match( + performanceTest, + /loadLegacyMainFixtureResources[\s\S]*tsjs-core\.js[\s\S]*tsjs-creative\.js[\s\S]*tsjs-gpt\.js/u, + "the browser gate must consume main's actual legacy core, creative, and GPT artifact shape", + ); + assert.match( + performanceTest, + /CRITICAL_IDS = \["render_runtime", "creative", "gpt"\]/u, + "the release-v1 comparison must use the same core, render, creative, and GPT shape", + ); + assert.match( + generatorSource, + /creative:[\s\S]*enabled: true/u, + "the generated candidate controller must enable main's default creative policy", + ); + assert.match( + performanceTest, + /loadMainFixtureResources[\s\S]*tsjs-release-v1\.json[\s\S]*loadReleaseFixtureResources[\s\S]*loadLegacyMainFixtureResources/u, + "the browser gate must detect main's actual legacy or release-v1 artifact shape", + ); + assert.match( + performanceTest, + /performance\.mark\("tsjs:first-observable-action"\)[\s\S]*display\(target:[\s\S]*markFirstObservableAction\(\)/u, + "the cross-version endpoint must be the first observable GPT display or refresh action", + ); + assert.match( + performanceTest, + /criticalTransferBytes: mainResources\.criticalTransferBytes[\s\S]*criticalTransferBytes: candidateResources\.criticalTransferBytes/u, + "the evidence must record each variant's exact served critical bytes", + ); + assert.match( + performanceWorkflow, + /git fetch origin main[\s\S]*main_sha="\$\(git rev-parse origin\/main\)"[\s\S]*TSJS_PERF_MAIN_SHA/u, + "the performance workflow must resolve and export the exact current main SHA", + ); + assert.match( + performanceWorkflow, + /pull_request:[\s\S]*paths:/u, + "the performance workflow must run automatically for relevant PR changes", + ); + assert.match( + performanceWorkflow, + /TSJS_PERF_HEAD_SHA: \$\{\{ github\.event_name == 'pull_request' && github\.event\.pull_request\.head\.sha \|\| github\.sha \}\}/u, + "the performance workflow must bind PR evidence to the head commit rather than the synthetic merge commit", + ); + assert.doesNotMatch( + performanceWorkflow, + /GITHUB_SHA: \$\{\{ github\.sha \}\}|--head-sha "\$\{\{ github\.sha \}\}"/u, + "the performance workflow must not attest a pull-request merge SHA as the source commit", + ); + assert.doesNotMatch( + performanceWorkflow, + /62421ee44c62f24534ea8782a46dfa5bfbcea950/u, + "the performance workflow must never build a frozen reference instead of current main", + ); + assert.doesNotMatch( + performanceTest, + /\.route(?:FromHAR)?\(|\.fulfill\(/u, + "the browser gate must not intercept or fulfill measured requests through Playwright", + ); + assert.match( + performanceTest, + /server\.closeAllConnections\(\)/u, + "the browser gate must force-close Chromium keepalive connections during cleanup", + ); + assert.match( + performanceTest, + /test\.setTimeout\(1_500_000\)/u, + "the browser gate must leave enough time to collect and write failure evidence after the 20-minute sample", + ); + assert.match( + performanceTest, + /preparationBeforePaintCount/u, + "the browser gate must record deferred preparation timing", + ); + assert.match( + performanceTest, + /executionBeforePaintCount/u, + "the browser gate must record deferred activation timing", + ); + assert.match( + performanceTest, + /preloadTimes/u, + "the browser gate must retain transient deferred preload observations", + ); + assert.match( + performanceTest, + /publisherRefresh/u, + "the retained-heap refresh checkpoint must use the publisher GPT refresh path", + ); + assert.match( + performanceTest, + /auctionId: "performance-navigation"[\s\S]*results: \[\{ slot: "perf-slot", outcome: "no_bid" \}\]/u, + "the SPA heap checkpoint must use a projection with real GPT reconciliation", + ); + assert.match( + performanceTest, + /expect\(await response\.finished\(\)\)\.toBeNull\(\)[\s\S]*getSlots\(\)[\s\S]*afterSpaNavigation/u, + "the SPA heap checkpoint must await the response body and reconciled GPT slot", + ); + assert.doesNotMatch( + performanceWorkflow, + /setup-integration-test-env|VICEROY|WASM_ARTIFACT|build-test-images/u, + "the hermetic performance fixture must not build unused runtime infrastructure", + ); + assert.match( + performanceWorkflow, + /node_version="\$\(awk '\$1 == "nodejs" \{ print \$2 \}' \.tool-versions\)"/u, + "the performance workflow must extract the pinned Node.js version with valid awk quoting", + ); + assert.match( + performanceWorkflow, + /rust_version="\$\(awk '\$1 == "rust" \{ print \$2 \}' \.tool-versions\)"/u, + "the performance workflow must extract the pinned Rust version with valid awk quoting", + ); + assert.match( + performanceWorkflow, + /test -n "\$node_version"[\s\S]*test -n "\$rust_version"/u, + "the performance workflow must reject empty toolchain pins", + ); + assert.match( + performanceWorkflow, + /test "\$\(node --version\)" = "v24\.12\.0"[\s\S]*test "\$\(npm --version\)" = "11\.6\.2"[\s\S]*test "\$\(rustc --version \| awk '\{ print \$2 \}'\)" = "1\.95\.0"/u, + "the performance workflow must verify the installed Node.js, npm, and Rust versions", + ); + assert.match( + integrationWorkflow, + /uses: fermyon\/actions\/spin\/setup@v1[\s\S]{0,100}version: "v4\.0\.2"/u, + "the integration workflow must retain Spin's required v-prefixed version", + ); + for (const job of [ + "prepare-artifacts", + "integration-tests", + "integration-tests-fastly-ec", + "aps-runner-proxy", + "browser-tests", + "browser-tests-aps-tsjs-conformance", + ]) { + assert.match( + integrationWorkflow, + new RegExp( + `^ ${job}:\\n(?: .+\\n)*? if: github\\.event_name == 'pull_request'$`, + "mu", + ), + `${job} must stay PR-only so manual evidence dispatches run the immutable performance job once`, + ); + } + assert.deepEqual( + browserTestScript.match(/^cd .+$/gmu), + ['cd "$REPO_ROOT"'], + "the browser test launcher must remain at the repository root", + ); + assert.equal( + browserTestScript.match(/Building TSJS browser fixtures/gu)?.length, + 1, + "the browser test launcher must build TSJS fixtures exactly once", + ); + assert.match( + browserTestScript, + /npm --prefix "\$BROWSER_DIR" exec --[\s\S]*--config "\$REPO_ROOT\/\$BROWSER_DIR\/playwright\.config\.ts"/u, + "the browser test launcher must pass Playwright an absolute config path", + ); + assert.match( + browserTestScript, + /export ARTIFACTS_DIR="\$\{ARTIFACTS_DIR:-\$REPO_ROOT\/target\/integration-test-artifacts\}"/u, + "the browser test launcher must establish one effective artifacts directory", + ); + assert.match( + browserTestScript, + /GENERATED_VICEROY_CONFIG_PATH="\$ARTIFACTS_DIR\/configs\/viceroy\.toml"/u, + "the browser test launcher must consume the config generated in the effective artifacts directory", + ); + assert.match( + apsProxyScript, + /ARTIFACTS_DIR="\$\{ARTIFACTS_DIR:-\$REPO_ROOT\/target\/integration-test-artifacts\}"/u, + "the APS proxy launcher must establish one effective artifacts directory", + ); + assert.match( + apsProxyScript, + /VICEROY_CONFIG_PATH="\$ARTIFACTS_DIR\/configs\/viceroy\.toml"/u, + "the APS proxy launcher must use the generator's effective artifacts directory", + ); + assert.doesNotMatch( + generalTestWorkflow, + /tsjs-performance-gate\.yml/u, + "general CI must not redefine or automatically rerun immutable performance evidence", + ); + assert.match( + generalTestWorkflow, + /test-typescript:[\s\S]*?uses: actions\/checkout@v4\n with:\n fetch-depth: 0/u, + "the rc/july adoption contract must receive the pinned baseline commit", + ); + console.log( + `TSJS performance evidence self-test passed (${mutations.length} mutations)`, + ); +} + +function parseArguments(arguments_) { + const values = new Map(); + for (let index = 0; index < arguments_.length; index += 2) { + const name = arguments_[index]; + const value = arguments_[index + 1]; + if (!name?.startsWith("--") || value === undefined) + fail("invalid CLI arguments"); + if (values.has(name)) fail(`duplicate ${name}`); + values.set(name, value); + } + for (const name of [ + "--file", + "--evidence-id", + "--head-sha", + "--main-sha", + "--mode", + ]) { + if (!values.has(name)) fail(`missing ${name}`); + } + if (values.size !== 5) fail("unknown or duplicate CLI arguments"); + return values; +} + +function main() { + if (process.argv.length === 3 && process.argv[2] === "--self-test") { + runSelfTest(); + return; + } + const arguments_ = parseArguments(process.argv.slice(2)); + let evidence; + try { + evidence = JSON.parse(readFileSync(arguments_.get("--file"), "utf8")); + } catch (error) { + fail( + `cannot read JSON evidence: ${error instanceof Error ? error.message : String(error)}`, + ); + } + validateEvidence(evidence, { + evidenceId: arguments_.get("--evidence-id"), + headSha: arguments_.get("--head-sha"), + mainSha: arguments_.get("--main-sha"), + mode: arguments_.get("--mode"), + }); + console.log("TSJS performance evidence is valid"); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 19ecda4a5..31ab3c13b 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -127,11 +127,9 @@ rewrite_creatives = true # Strip executable markup (script/object/embed/form/...) from winning-bid adm, # removing those elements together with their inner content. # -# Defaults to false: executable markup is preserved rather than stripped. Note -# this is not "untouched" — with the default `rewrite_creatives = true` above, -# eligible URLs are still rewritten to first-party endpoints, bidder `` -# elements are removed, and the creative TSJS runtime is injected. -# +# Defaults to false: executable markup is preserved rather than stripped. With +# the default `rewrite_creatives = true`, eligible URLs are still rewritten, +# bidder `` elements are removed, and the creative runtime is injected. # Enable whenever creatives can render in a context that shares the publisher's # origin — it is the primary defence there. # @@ -180,7 +178,19 @@ ja4_endpoint_enabled = false # in production. auction_html_comment = false +# Expose GET /_ts/trace, which toggles the `ts-trace` cookie and redirects to /. +# While the cookie is set, the TSJS overlay draws a floating panel summarising +# every traced ad slot (render path, bidder, and GAM/injected/visible state) +# plus a confirmation badge on each genuinely-rendered creative. It only +# surfaces data already present on window.tsjs, so it leaks nothing new — but +# it is off by default so the toggle route is not live on deployments that +# never asked for it. Enable only for render-verification debugging. +# trace_route_enabled = false + [creative_opportunities] +# Set to false to disable server-side ad templates while retaining slot definitions +# and direct POST /auction callers. +enabled = true gam_network_id = "123456789" # FCP is not affected by this value — body content above has already # streamed and painted before the hold begins. What this caps is the slip on