From c36c82df43c2f62a59444290f863f736b579ed33 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 27 Oct 2026 18:47:44 -0500 Subject: [PATCH 01/13] Add opt-in publisher-registered GPT initial delivery --- .github/workflows/integration-tests.yml | 134 +- .../src/creative_opportunities.rs | 136 ++ .../src/integrations/gpt.rs | 144 +- .../src/integrations/gpt_bootstrap.js | 641 +++++- crates/trusted-server-core/src/publisher.rs | 177 +- .../browser/helpers/infra.ts | 29 +- .../browser/package.json | 5 +- ...laywright.publisher-registration.config.ts | 40 + .../browser/publisher-registration/README.md | 115 + .../publisher-registration/gpt-fixture.js | 320 +++ .../browser/publisher-registration/pages.cjs | 65 + .../publisher-registration/pages.test.cjs | 186 ++ .../browser/publisher-registration/run.cjs | 1845 +++++++++++++++++ .../nextjs/publisher-registration.spec.ts | 1134 ++++++++++ .../publisher_registration_backends.rs | 225 ++ ...trusted-server.publisher-registration.toml | 89 + .../nextjs/app/api/fixture-auction/route.ts | 90 + .../publisher-registration/RegisteredSlot.tsx | 316 +++ .../publisher-registration/[variant]/page.tsx | 1 + .../app/publisher-registration/page.tsx | 8 + .../lib/src/core/first_impression.ts | 17 +- .../trusted-server-js/lib/src/core/types.ts | 92 +- .../lib/src/integrations/gpt/index.ts | 205 +- .../gpt/publisher_registration.ts | 454 ++++ .../lib/test/core/first_impression.test.ts | 19 + .../publisher_registration_contract.ts | 1278 ++++++++++++ .../gpt/publisher_registration.test.ts | 10 + .../test/integrations/gpt/spa_hook.test.ts | 2 + .../test/integrations/prebid/index.test.ts | 76 + docs/guide/configuration.md | 30 + docs/guide/integrations/gpt.md | 257 ++- ...lisher-registered-initial-delivery-plan.md | 823 ++++++++ ...-initial-delivery-implementation-review.md | 80 + ...er-registered-initial-delivery-rereview.md | 129 ++ ...sher-registered-initial-delivery-review.md | 113 + ...-registered-initial-delivery-validation.md | 263 +++ ...sher-registered-initial-delivery-design.md | 554 +++++ scripts/test-publisher-registration.sh | 347 ++++ 38 files changed, 10345 insertions(+), 104 deletions(-) create mode 100644 crates/trusted-server-integration-tests/browser/playwright.publisher-registration.config.ts create mode 100644 crates/trusted-server-integration-tests/browser/publisher-registration/README.md create mode 100644 crates/trusted-server-integration-tests/browser/publisher-registration/gpt-fixture.js create mode 100644 crates/trusted-server-integration-tests/browser/publisher-registration/pages.cjs create mode 100644 crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs create mode 100644 crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs create mode 100644 crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts create mode 100644 crates/trusted-server-integration-tests/examples/publisher_registration_backends.rs create mode 100644 crates/trusted-server-integration-tests/fixtures/configs/trusted-server.publisher-registration.toml create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/fixture-auction/route.ts create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration/RegisteredSlot.tsx create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration/[variant]/page.tsx create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration/page.tsx create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts create mode 100644 crates/trusted-server-js/lib/test/fixtures/publisher_registration_contract.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/publisher_registration.test.ts create mode 100644 docs/superpowers/plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md create mode 100644 docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-implementation-review.md create mode 100644 docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-rereview.md create mode 100644 docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-review.md create mode 100644 docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-validation.md create mode 100644 docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md create mode 100755 scripts/test-publisher-registration.sh diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 157a3582d3..c043121ced 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,4 +1,4 @@ -name: "Integration Tests" +name: 'Integration Tests' permissions: contents: read @@ -26,27 +26,57 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Read Node.js build version + id: build-node-version + run: echo "version=$(awk '$1 == "nodejs" {print $2}' .tool-versions)" >> "$GITHUB_OUTPUT" + + - name: Set up Node.js for embedded and browser bundles + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.build-node-version.outputs.version }} + - name: Set up shared integration build environment uses: ./.github/actions/setup-integration-test-env with: origin-port: ${{ env.ORIGIN_PORT }} - install-viceroy: "false" - build-cloudflare: "true" + install-viceroy: 'false' + build-cloudflare: 'true' - name: Generate integration Viceroy configs run: ./scripts/generate-integration-viceroy-configs.sh env: INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + - name: Prepare publisher registration artifacts + run: | + mkdir -p "$(dirname "$WASM_ARTIFACT_PATH")" + cp target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm "$WASM_ARTIFACT_PATH" + ( + cd crates/trusted-server-js/lib + node build-all.mjs --out-dir "$ARTIFACTS_DIR/publisher-registration-dist" + node build-prebid-external.mjs \ + --modules-json '{"bidder":["rubiconBidAdapter"],"userId":["sharedIdSystem"],"analytics":[]}' \ + --out "$ARTIFACTS_DIR/publisher-registration-prebid" + ) + ./scripts/test-publisher-registration.sh --stamp-wasm \ + --wasm "$WASM_ARTIFACT_PATH" --dist "$ARTIFACTS_DIR/publisher-registration-dist" \ + --bundle-mode build-rs + ./scripts/test-publisher-registration.sh --prepare \ + --wasm "$WASM_ARTIFACT_PATH" --dist "$ARTIFACTS_DIR/publisher-registration-dist" \ + --bundle-mode build-rs --prebid "$ARTIFACTS_DIR/publisher-registration-prebid" \ + --artifacts "$ARTIFACTS_DIR/publisher-registration" \ + --out "$ARTIFACTS_DIR/publisher-registration-prep" + - name: Package integration test artifacts run: | mkdir -p "$(dirname "$WASM_ARTIFACT_PATH")" "$(dirname "$AXUM_ARTIFACT_PATH")" "$(dirname "$DOCKER_ARTIFACT_PATH")" "$CF_BUILD_ARTIFACT_PATH" cp target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm "$WASM_ARTIFACT_PATH" cp target/debug/trusted-server-axum "$AXUM_ARTIFACT_PATH" cp -r crates/trusted-server-adapter-cloudflare/build/. "$CF_BUILD_ARTIFACT_PATH/" + PUBLISHER_IMAGE="$(node -e 'const image = require(process.argv[1]); console.log((Array.isArray(image) ? image[0] : image).RepoTags[0])' "$ARTIFACTS_DIR/publisher-registration-prep/image.json")" docker save \ --output "$DOCKER_ARTIFACT_PATH" \ - test-wordpress:latest test-nextjs:latest + test-wordpress:latest test-nextjs:latest "$PUBLISHER_IMAGE" - name: Upload integration test artifacts uses: actions/upload-artifact@v4 @@ -68,10 +98,10 @@ jobs: 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" + install-viceroy: 'true' + build-wasm: 'false' + build-axum: 'false' + build-test-images: 'false' - name: Download integration test artifacts uses: actions/download-artifact@v4 @@ -127,9 +157,9 @@ jobs: uses: ./.github/actions/setup-integration-test-env with: origin-port: ${{ env.ORIGIN_PORT }} - install-viceroy: "true" - build-wasm: "false" - build-test-images: "false" + install-viceroy: 'true' + build-wasm: 'false' + build-test-images: 'false' - name: Download integration test artifacts uses: actions/download-artifact@v4 @@ -165,9 +195,9 @@ jobs: uses: ./.github/actions/setup-integration-test-env with: origin-port: ${{ env.ORIGIN_PORT }} - install-viceroy: "true" - build-wasm: "false" - build-test-images: "false" + install-viceroy: 'true' + build-wasm: 'false' + build-test-images: 'false' - name: Download integration test artifacts uses: actions/download-artifact@v4 @@ -263,3 +293,79 @@ jobs: name: playwright-traces path: crates/trusted-server-integration-tests/browser/test-results/ retention-days: 7 + + publisher-registration: + name: publisher registration contracts + needs: [prepare-artifacts, browser-tests] + if: ${{ always() && needs.prepare-artifacts.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Set up publisher registration 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' + + - name: Download prepared integration artifacts + uses: actions/download-artifact@v4 + with: + name: integration-test-artifacts + path: ${{ env.ARTIFACTS_DIR }} + + - name: Load prepared fixture images + run: docker load --input "$DOCKER_ARTIFACT_PATH" + + - 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 + + - name: Install browser dependencies + working-directory: crates/trusted-server-integration-tests/browser + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Restore prepared TSJS bundles + run: | + mkdir -p crates/trusted-server-js/dist + cp "$ARTIFACTS_DIR/publisher-registration-dist/"*.js crates/trusted-server-js/dist/ + + - name: Test publisher registration fixture and offline contracts + run: | + node --test crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs + node crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs \ + --implementation all --prebid "$ARTIFACTS_DIR/publisher-registration-prebid" \ + --out "$RUNNER_TEMP/publisher-registration-offline" + + - name: Test actual framework injection and provider delivery + if: always() + env: + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + run: | + ./scripts/test-publisher-registration.sh \ + --wasm "$WASM_ARTIFACT_PATH" --dist "$ARTIFACTS_DIR/publisher-registration-dist" \ + --bundle-mode build-rs --prebid "$ARTIFACTS_DIR/publisher-registration-prebid" \ + --reuse-artifacts "$ARTIFACTS_DIR/publisher-registration" \ + --out "$RUNNER_TEMP/publisher-registration-framework" + + - name: Upload publisher registration evidence + uses: actions/upload-artifact@v4 + if: always() + with: + name: publisher-registration-evidence + path: | + ${{ runner.temp }}/publisher-registration-offline/ + ${{ runner.temp }}/publisher-registration-framework/ + ${{ env.ARTIFACTS_DIR }}/publisher-registration/ + ${{ env.ARTIFACTS_DIR }}/publisher-registration-prep/ + retention-days: 7 diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 8a20c72965..253e5e741a 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -622,6 +622,24 @@ impl CreativeOpportunitiesConfig { } } +/// Controls who establishes the native slot for initial ad delivery. +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CreativeOpportunityInitialization { + /// Use the existing automatic slot initialization. + #[default] + Auto, + /// Wait for the publisher to register its final native slot. + PublisherRegistered, +} + +impl CreativeOpportunityInitialization { + /// Whether initial delivery uses the default automatic behavior. + pub(crate) const fn is_auto(&self) -> bool { + matches!(self, Self::Auto) + } +} + /// A single ad placement opportunity on the publisher's site. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -640,6 +658,12 @@ pub struct CreativeOpportunitySlot { pub page_patterns: Vec, /// Supported ad formats (size + media type combinations). pub formats: Vec, + /// Initial delivery ownership, defaulting to automatic slot initialization. + #[serde( + default, + skip_serializing_if = "CreativeOpportunityInitialization::is_auto" + )] + pub initialization: CreativeOpportunityInitialization, /// Optional floor price in CPM (USD). pub floor_price: Option, /// Slot-level targeting key–value pairs forwarded to the auction. @@ -1459,6 +1483,7 @@ mod tests { height: 250, media_type: crate::auction::types::MediaType::Banner, }], + initialization: Default::default(), floor_price: Some(0.50), targeting: Default::default(), providers: Default::default(), @@ -1467,6 +1492,117 @@ mod tests { } } + #[test] + fn slot_initialization_defaults_to_auto_without_changing_serialization() { + let original = serde_json::to_value(make_slot("header", vec!["/example/*"])) + .expect("should serialize automatic slot"); + let absent: CreativeOpportunitySlot = serde_json::from_value(original.clone()) + .expect("should deserialize slot without initialization"); + assert!( + original.get("initialization").is_none(), + "should omit initialization from the default serialized slot" + ); + let mut explicit = original.clone(); + explicit["initialization"] = serde_json::json!("auto"); + let automatic: CreativeOpportunitySlot = serde_json::from_value(explicit) + .expect("should deserialize explicit automatic initialization"); + + for slot in [absent, automatic] { + assert_eq!( + serde_json::to_value(slot).expect("should serialize automatic slot"), + original, + "should omit default initialization and preserve the existing shape" + ); + } + } + + #[test] + fn slot_initialization_publisher_registered_round_trips() { + let mut value = serde_json::to_value(make_slot("header", vec!["/example/*"])) + .expect("should serialize slot"); + value["initialization"] = serde_json::json!("publisher_registered"); + let slot: CreativeOpportunitySlot = serde_json::from_value(value.clone()) + .expect("should deserialize publisher-registered initialization"); + + assert_eq!( + serde_json::to_value(slot).expect("should serialize registered slot"), + value, + "should preserve publisher-registered initialization in a round trip" + ); + } + + #[test] + fn slot_initialization_toml_configuration_accepts_only_known_modes() { + let base = r#" + id = "header" + page_patterns = ["/example/*"] + formats = [{ width = 300, height = 250, media_type = "banner" }] + "#; + for mode in [None, Some("auto"), Some("publisher_registered")] { + let source = match mode { + Some(mode) => format!("{base}\ninitialization = \"{mode}\"\n"), + None => base.to_string(), + }; + let slot: CreativeOpportunitySlot = + toml::from_str(&source).expect("should parse a supported initialization mode"); + let serialized = serde_json::to_value(&slot).expect("should serialize configured slot"); + assert_eq!( + serialized.get("initialization"), + mode.filter(|mode| *mode != "auto") + .map(serde_json::Value::from) + .as_ref(), + "should emit only non-default initialization for {mode:?}" + ); + } + assert!( + toml::from_str::(&format!( + "{base}\ninitialization = \"publisher_ready\"\n" + )) + .is_err(), + "should reject an unknown TOML initialization mode" + ); + } + + #[test] + fn slot_initialization_does_not_change_auction_input_or_path_matching() { + let mut slot = make_slot("header", vec!["/example/*"]); + slot.compile_patterns(); + let automatic = serde_json::to_value(slot.to_ad_slot()) + .expect("should serialize automatic auction slot"); + slot.initialization = CreativeOpportunityInitialization::PublisherRegistered; + + assert_eq!( + serde_json::to_value(slot.to_ad_slot()) + .expect("should serialize registered auction slot"), + automatic, + "should change delivery ownership without changing auction inputs" + ); + assert!( + slot.matches_path("/example/article"), + "should retain matching paths" + ); + assert!( + !slot.matches_path("/other"), + "should retain non-matching paths" + ); + assert!( + slot.validate_runtime().is_ok(), + "should retain valid slot semantics" + ); + } + + #[test] + fn slot_initialization_rejects_unknown_values() { + let mut value = serde_json::to_value(make_slot("header", vec!["/example/*"])) + .expect("should serialize slot"); + value["initialization"] = serde_json::json!("publisher_ready"); + + assert!( + serde_json::from_value::(value).is_err(), + "should reject an unknown initialization mode" + ); + } + #[test] fn compile_patterns_populates_cache_and_match_uses_it() { let mut slot = make_slot("atf", vec!["/20**", "/about"]); diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 3292cc5b3a..9c73964dcc 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -561,9 +561,12 @@ fn default_rewrite_script() -> bool { mod tests { use super::*; use crate::constants::HEADER_X_FORWARDED_FOR; - use crate::integrations::IntegrationDocumentState; + use crate::html_processor::{BodyCloseInjection, HtmlProcessorConfig, create_html_processor}; + use crate::integrations::{IntegrationDocumentState, IntegrationRegistry}; + use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; use crate::test_support::tests::create_test_settings; use http::Method; + use std::io::Cursor; fn test_config() -> GptConfig { GptConfig { @@ -1224,6 +1227,145 @@ mod tests { ); } + #[test] + fn head_inserts_embed_the_publisher_registration_v1_bootstrap() { + let integration = GptIntegration::new(test_config()); + let document_state = IntegrationDocumentState::default(); + let context = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + document_state: &document_state, + }; + let inserts = integration.head_inserts(&context); + + assert_eq!( + inserts.len(), + 2, + "should preserve the existing head insert count" + ); + assert_eq!( + inserts[1], + format!(""), + "should embed the complete included bootstrap without a new dependency" + ); + assert!( + inserts[1].contains("ts.publisherSlotRegistrationVersion = 1;"), + "should include protocol version 1 in the shipped bootstrap" + ); + assert!( + inserts[1].contains("ts.registerPublisherSlot ="), + "should include the publisher registration entrypoint" + ); + // The shared Vitest registration contract executes this included file. + // This check protects Rust delivery of that artifact, not JS execution. + } + + #[test] + fn publisher_registration_bootstrap_preserves_document_injection_order() { + const PUBLISHER_HEAD: &str = ""; + const SLOT_SCRIPT: &str = ""; + const SHARED_SEAM: &str = ""; + let settings = create_test_settings(); + let integration = GptIntegration::new(test_config()); + let registry = IntegrationRegistry::from_rewriters_with_head_injectors( + Vec::new(), + Vec::new(), + vec![integration], + ); + let html = + format!("{PUBLISHER_HEAD}

example-body

"); + + for shared in [false, true] { + let config = HtmlProcessorConfig::from_settings( + &settings, + ®istry, + "origin.example.com", + "edge.example.com", + "https", + ) + .with_ad_state((!shared).then(|| SLOT_SCRIPT.to_string()), Arc::default()) + .with_body_close(if shared { + BodyCloseInjection::Marker(SHARED_SEAM.to_string()) + } else { + BodyCloseInjection::InlineBids + }); + let processor = create_html_processor(config); + let mut pipeline = StreamingPipeline::new( + PipelineConfig { + input_compression: Compression::None, + output_compression: Compression::None, + chunk_size: 8192, + }, + processor, + ); + let mut output = Vec::new(); + pipeline + .process(Cursor::new(html.as_bytes()), &mut output) + .expect("should process publisher HTML with the GPT head injector"); + let output = String::from_utf8(output).expect("should produce UTF-8 HTML"); + let enabled = output + .find("window.__tsjs_gpt_enabled=true;") + .expect("should retain the GPT activation insert"); + let bootstrap = output + .find(GPT_BOOTSTRAP_JS) + .expect("should retain the complete inline bootstrap"); + let bundle = output + .find("id=\"trustedserver-js\"") + .expect("should retain the main bundle tag"); + let publisher = output + .find(PUBLISHER_HEAD) + .expect("should retain the original publisher head script"); + let body = output + .find("

example-body

") + .expect("should retain publisher body content"); + let payload = output + .find(if shared { + SHARED_SEAM + } else { + "var b=JSON.parse(" + }) + .expect("should retain the selected body payload seam"); + // The included head bootstrap also mentions in a comment. + let body_close = output + .rfind("") + .expect("should retain the body close"); + + assert!( + enabled < bootstrap + && bootstrap + GPT_BOOTSTRAP_JS.len() < bundle + && bundle < publisher + && publisher < body + && body < payload + && payload < body_close, + "should install the complete API before publisher head callbacks while preserving head/body order, shared={shared}" + ); + assert_eq!( + output.matches(GPT_BOOTSTRAP_JS).count(), + 1, + "should inject the registration bootstrap once" + ); + if shared { + assert!( + !output.contains(SLOT_SCRIPT), + "should keep shared-template heads slot-neutral" + ); + assert!( + !output.contains("var b=JSON.parse("), + "should leave shared payload assembly to the seam" + ); + } else { + assert!( + output + .find(SLOT_SCRIPT) + .expect("should retain initial slot definitions") + < enabled, + "should retain inline slot configuration before activation" + ); + } + } + } + #[test] fn head_inserts_includes_ts_ad_init_with_synchronous_bids_read() { let config = test_config(); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index afae775350..edf79c1ce2 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -107,20 +107,20 @@ function firstImpressionState(now) { var generation = ts.navGeneration || 0; - if ( - !ts.firstImpression || - ts.firstImpression.generation !== generation - ) { + if (!ts.firstImpression || ts.firstImpression.generation !== generation) { ts.firstImpression = { generation: generation, nextToken: 0, - slots: {}, - fallbackSlots: {}, + slots: Object.create(null), + fallbackSlots: Object.create(null), }; } var state = ts.firstImpression; - state.slots = state.slots || {}; - state.fallbackSlots = state.fallbackSlots || {}; + state.slots = Object.assign(Object.create(null), state.slots); + state.fallbackSlots = Object.assign( + Object.create(null), + state.fallbackSlots, + ); Object.keys(state.slots).forEach(function (elementId) { var claim = state.slots[elementId]; if ( @@ -246,7 +246,7 @@ phase: "delivery_pending", // Unbounded by contract; see the fallback transition above. expiresAt: Number.POSITIVE_INFINITY, - publisherAuctions: {}, + publisherAuctions: Object.create(null), }; if (!storeFirstImpressionClaim(state, claim)) return null; schedulePendingRenderDiagnostic(claim); @@ -281,6 +281,7 @@ : ""; var element = elementId && document.getElementById(elementId); if (!element) return; + if (phase === "rendered") observePublisherSlotRender(ts, event.slot); var state = firstImpressionState(Date.now()); var claim = state.slots[elementId]; if (!claim) { @@ -291,7 +292,7 @@ owner: "publisher", phase: phase, expiresAt: Number.POSITIVE_INFINITY, - publisherAuctions: {}, + publisherAuctions: Object.create(null), }); return; } @@ -311,6 +312,614 @@ installFirstImpressionListeners(); + // Protocol v1 deliberately mirrors publisher_registration.ts; the shared executable contract checks both. + const CANDIDATE_KEYS = [ + "hb_pb", + "hb_bidder", + "hb_adid", + "hb_cache_host", + "hb_cache_path", + ]; + function registrationTag() { + return window.googletag; + } + function invalidatePublisherSlotRegistrations(ts) { + var _a, _b, _c; + const previous = ts.publisherSlotRegistrationState; + for (const record of Object.values( + (_a = previous == null ? void 0 : previous.records) != null ? _a : {}, + )) { + record.status = { status: "cancelled", reason: "navigation" }; + record.element = void 0; + record.gptSlot = void 0; + } + ts.publisherSlotRegistrationState = { + generation: (_b = ts.navGeneration) != null ? _b : 0, + nextToken: + (_c = previous == null ? void 0 : previous.nextToken) != null ? _c : 0, + records: /* @__PURE__ */ Object.create(null), + }; + } + function registrationState(ts) { + var _a, _b; + if ( + ((_a = ts.publisherSlotRegistrationState) == null + ? void 0 + : _a.generation) !== ((_b = ts.navGeneration) != null ? _b : 0) + ) { + invalidatePublisherSlotRegistrations(ts); + } + return ts.publisherSlotRegistrationState; + } + function settlePublisherSlotPayload(ts, generation, slots, bids) { + var _a, _b; + if (((_a = ts.navGeneration) != null ? _a : 0) !== generation) return; + registrationState(ts).payload = { generation, slots, bids }; + (_b = ts.publisherSlotRegistrationDispatch) == null ? void 0 : _b.call(ts); + } + function retainPublisherSlotMappings(ts, mapping, targetingKeys) { + const state = registrationState(ts); + for (const record of Object.values(state.records)) { + if (!record.consumed || !record.targetingKeys) continue; + mapping[record.elementId] = record.slotId; + targetingKeys[record.elementId] = record.targetingKeys; + } + } + function registeredCandidateAllowed(ts, slotId, bid) { + var _a, _b, _c, _d, _e, _f, _g, _h; + const record = + ((_a = ts.publisherSlotRegistrationState) == null + ? void 0 + : _a.generation) === ((_b = ts.navGeneration) != null ? _b : 0) + ? ts.publisherSlotRegistrationState.records[slotId] + : void 0; + if (record) { + if ( + !record.consumed || + record.selectedCandidate !== bid || + !((_c = record.element) == null ? void 0 : _c.isConnected) || + record.element.id !== record.elementId || + document.getElementById(record.elementId) !== record.element + ) + return false; + try { + return ( + !!record.gptSlot && + !!((_g = + (_e = (_d = registrationTag()) == null ? void 0 : _d.pubads) == null + ? void 0 + : (_f = _e.call(_d)).getSlots) == null + ? void 0 + : _g.call(_f).includes(record.gptSlot)) + ); + } catch (e) { + return false; + } + } + return !((_h = ts.adSlots) == null + ? void 0 + : _h.some( + (slot) => + slot.id === slotId && + slot.initialization === "publisher_registered", + )); + } + function observePublisherSlotRender(ts, native) { + var _a, _b; + const state = ts.publisherSlotRegistrationState; + if ( + (state == null ? void 0 : state.generation) !== + ((_a = ts.navGeneration) != null ? _a : 0) + ) + return; + for (const record of Object.values(state.records)) { + if ( + record.gptSlot === native && + record.consumed && + ((_b = record.element) == null ? void 0 : _b.isConnected) && + record.element.id === record.elementId && + document.getElementById(record.elementId) === record.element && + (record.status.status === "requested" || + record.status.status === "dispatching") + ) { + record.status = { status: "rendered" }; + } + } + } + function finiteSize(size) { + return ( + Array.isArray(size) && + size.length === 2 && + size.every( + (value) => + typeof value === "number" && Number.isInteger(value) && value > 0, + ) + ); + } + function normalizedFormats(formats) { + return [ + ...new Map( + formats.map((size) => [size.join("x"), [size[0], size[1]]]), + ).values(), + ].sort((left, right) => left[0] - right[0] || left[1] - right[1]); + } + function loadPolicy(ts, tag) { + var _a, _b; + try { + const effective = + (_b = + (_a = tag == null ? void 0 : tag.getConfig) == null + ? void 0 + : _a.call(tag, "disableInitialLoad")) == null + ? void 0 + : _b.disableInitialLoad; + if (effective !== void 0) ts.gptInitialLoadDisabled = effective === true; + if ( + effective === true || + (effective === void 0 && ts.gptInitialLoadDisabled === true) + ) + return; + return { + status: "rejected", + reason: + effective === false || ts.gptInitialLoadDisabled === false + ? "initial_load_enabled" + : "initial_load_unknown", + }; + } catch (e) { + return { status: "rejected", reason: "initial_load_unknown" }; + } + } + function exactElement(id) { + const element = document.getElementById(id); + if (!id || !(element == null ? void 0 : element.isConnected)) + return { error: { status: "rejected", reason: "missing_element" } }; + if ( + Array.from(document.querySelectorAll("[id]")).filter( + (candidate) => candidate.id === id, + ).length !== 1 + ) { + return { error: { status: "rejected", reason: "duplicate_dom_id" } }; + } + return { element }; + } + function registrationHandle(ts, record) { + return { + getStatus: () => { + var _a; + if ( + record.generation !== ((_a = ts.navGeneration) != null ? _a : 0) && + !record.consumed + ) { + record.status = { status: "cancelled", reason: "navigation" }; + } + return { ...record.status }; + }, + cancel: () => { + if ( + record.status.status === "rejected" || + record.status.status === "cancelled" + ) + return; + record.status = { status: "cancelled", reason: "explicit" }; + if (!record.consumed) { + record.element = void 0; + record.gptSlot = void 0; + } + }, + }; + } + function installPublisherSlotRegistration(ts, claim) { + registrationState(ts); + ts.publisherSlotRegistrationVersion = 1; + ts.registerPublisherSlot = (options) => { + var _a, _b, _c, _d, _e; + const state = registrationState(ts); + const rejected = (status) => ({ + getStatus: () => ({ ...status }), + cancel: () => {}, + }); + if ( + !options || + typeof options.slotId !== "string" || + !options.slotId || + !Number.isInteger(options.navigationGeneration) || + !Array.isArray(options.eligibleFormats) || + !options.eligibleFormats.every(finiteSize) || + !options.gptSlot || + ![ + "getSlotElementId", + "getAdUnitPath", + "setTargeting", + "clearTargeting", + ].every((key) => typeof options.gptSlot[key] === "function") + ) { + return rejected({ status: "rejected", reason: "invalid_arguments" }); + } + if (options.navigationGeneration !== state.generation) + return rejected({ status: "rejected", reason: "stale_generation" }); + const tag = registrationTag(); + const policy = loadPolicy(ts, tag); + if (policy) return rejected(policy); + let elementId; + let adUnitPath; + try { + elementId = options.gptSlot.getSlotElementId(); + adUnitPath = options.gptSlot.getAdUnitPath(); + if (typeof elementId !== "string" || typeof adUnitPath !== "string") + throw new Error("invalid slot identity"); + if ( + !((_c = + (_a = tag == null ? void 0 : tag.pubads) == null + ? void 0 + : (_b = _a.call(tag)).getSlots) == null + ? void 0 + : _c.call(_b).includes(options.gptSlot)) + ) + return rejected({ + status: "rejected", + reason: "native_slot_missing", + }); + } catch (e) { + return rejected({ status: "rejected", reason: "invalid_arguments" }); + } + const { element, error } = exactElement(elementId); + if (error || !element) return rejected(error); + const configured = + (_d = state.payload) == null + ? void 0 + : _d.slots.find((slot) => slot.id === options.slotId); + if (configured && configured.gam_unit_path !== adUnitPath) { + return rejected({ status: "rejected", reason: "path_mismatch" }); + } + const eligibleFormats = normalizedFormats(options.eligibleFormats); + const previous = state.records[options.slotId]; + if ( + previous && + (previous.consumed || previous.status.status !== "cancelled") + ) { + if ( + previous.gptSlot === options.gptSlot && + previous.element === element && + JSON.stringify(previous.eligibleFormats) === + JSON.stringify(eligibleFormats) + ) + return registrationHandle(ts, previous); + return rejected({ + status: "rejected", + reason: "conflicting_registration", + }); + } + const record = { + generation: state.generation, + slotId: options.slotId, + token: ++state.nextToken, + gptSlot: options.gptSlot, + element, + elementId, + adUnitPath, + eligibleFormats, + status: { status: "waiting_payload" }, + consumed: false, + queued: false, + }; + state.records[record.slotId] = record; + (_e = ts.publisherSlotRegistrationDispatch) == null + ? void 0 + : _e.call(ts); + return registrationHandle(ts, record); + }; + ts.publisherSlotRegistrationDispatch = (readyRecord) => { + const state = registrationState(ts); + const current = (record) => + state.records[record.slotId] === record && + record.generation === state.generation && + !record.consumed && + (record.status.status === "waiting_payload" || + record.status.status === "waiting_delivery"); + const dispatch = (record) => { + var _a, _b, _c, _d, _e; + if (!current(record)) return; + record.queued = false; + const payload = state.payload; + if (!payload || payload.generation !== state.generation) return; + const slot = payload.slots.find( + (candidate) => candidate.id === record.slotId, + ); + if (!slot || slot.initialization !== "publisher_registered") { + record.status = { + status: "inactive", + reason: slot ? "not_registered_mode" : "not_admitted", + }; + return; + } + const tag = registrationTag(); + const policy = loadPolicy(ts, tag); + if (policy) { + record.status = policy; + return; + } + const identity = exactElement(record.elementId); + if ( + !record.element || + identity.element !== record.element || + record.element.id !== record.elementId + ) { + record.status = { status: "cancelled", reason: "element_changed" }; + return; + } + const native = record.gptSlot; + const service = + (_a = tag == null ? void 0 : tag.pubads) == null + ? void 0 + : _a.call(tag); + if ( + !native || + !((_b = service == null ? void 0 : service.getSlots) == null + ? void 0 + : _b.call(service).includes(native)) || + native.getSlotElementId() !== record.elementId + ) { + record.status = { + status: "cancelled", + reason: "native_slot_missing", + }; + return; + } + if ( + native.getAdUnitPath() !== record.adUnitPath || + record.adUnitPath !== slot.gam_unit_path + ) { + record.status = { status: "rejected", reason: "path_mismatch" }; + return; + } + if (document.hidden) { + queue(record); + return; + } + const first = claim(record.element); + if (!first) { + record.status = { status: "publisher_owned", reason: "prior_claim" }; + return; + } + record.consumed = true; + record.status = { status: "dispatching" }; + const wasInternal = ts.gptSlotHandoffInternal; + const wasRefreshing = ts.adInitRefreshInProgress; + try { + const bid = payload.bids[record.slotId]; + const fits = + bid && + finiteSize([bid.w, bid.h]) && + slot.formats.some( + (size) => size[0] === bid.w && size[1] === bid.h, + ) && + record.eligibleFormats.some( + (size) => size[0] === bid.w && size[1] === bid.h, + ); + const candidate = + ts.tsRenderBridgeVersion === 1 && fits ? bid : void 0; + record.selectedCandidate = candidate; + if (!candidate) + record.noCandidateReason = !bid + ? "no_bid" + : ts.tsRenderBridgeVersion !== 1 + ? "renderer_unavailable" + : "incompatible_dimensions"; + const previousKeys = + (_d = + (_c = ts.prevSlotTargetingKeys) == null + ? void 0 + : _c[record.elementId]) != null + ? _d + : []; + for (const key of /* @__PURE__ */ new Set([ + ...CANDIDATE_KEYS, + "ts_initial", + ...previousKeys, + ])) + native.clearTargeting(key); + const targeting = { ...slot.targeting, ts_initial: "1" }; + for (const key of CANDIDATE_KEYS) { + delete targeting[key]; + if (candidate == null ? void 0 : candidate[key]) + targeting[key] = String(candidate[key]); + } + record.targetingKeys = Object.keys(targeting); + for (const [key, value] of Object.entries(targeting)) + native.setTargeting(key, value); + first.targeting = targeting; + ts.divToSlotId = Object.assign( + /* @__PURE__ */ Object.create(null), + ts.divToSlotId, + ); + ts.prevSlotTargetingKeys = Object.assign( + /* @__PURE__ */ Object.create(null), + ts.prevSlotTargetingKeys, + ); + retainPublisherSlotMappings( + ts, + ts.divToSlotId, + ts.prevSlotTargetingKeys, + ); + try { + (_e = ts.gptDiagnosticsRecorder) == null + ? void 0 + : _e.recordTrustedServerOpportunity( + native, + slot.id, + (candidate == null ? void 0 : candidate.hb_adid) + ? candidate.adm || candidate.hb_cache_host + ? "renderable_candidate" + : "unrenderable_candidate" + : "no_candidate", + candidate == null ? void 0 : candidate.hb_auction_id, + record.eligibleFormats, + ); + } catch (e) {} + ts.gptSlotHandoffInternal = true; + ts.adInitRefreshInProgress = true; + tag.display(record.elementId); + service.refresh([native]); + if (record.status.status === "dispatching") + record.status = { status: "requested" }; + } catch (e) { + record.status = { + status: "failed_after_dispatch", + reason: "delivery_exception", + }; + } finally { + ts.gptSlotHandoffInternal = wasInternal; + ts.adInitRefreshInProgress = wasRefreshing; + } + }; + const queue = (record) => { + if (!current(record) || record.queued) return; + record.queued = true; + record.status = { status: "waiting_delivery" }; + const foreground = () => { + if (!current(record)) return; + if (document.hidden) { + document.addEventListener("visibilitychange", foreground, { + once: true, + }); + return; + } + window.requestAnimationFrame(() => { + var _a, _b; + if (!current(record)) return; + if (document.hidden) { + foreground(); + return; + } + (_b = (_a = registrationTag()) == null ? void 0 : _a.cmd) == null + ? void 0 + : _b.push(() => { + var _a2; + return (_a2 = ts.publisherSlotRegistrationDispatch) == null + ? void 0 + : _a2.call(ts, record); + }); + }); + }; + foreground(); + }; + if (readyRecord) { + dispatch(readyRecord); + return; + } + for (const record of Object.values(state.records)) { + if (!current(record)) continue; + if (!state.payload) { + record.status = { status: "waiting_payload" }; + continue; + } + const admitted = state.payload.slots.find( + (slot) => slot.id === record.slotId, + ); + if (!admitted || admitted.initialization !== "publisher_registered") { + record.status = { + status: "inactive", + reason: admitted ? "not_registered_mode" : "not_admitted", + }; + continue; + } + queue(record); + } + }; + } + + installPublisherSlotRegistration(ts, claimFirstImpressionForTrustedServer); + + // Snapshot exact old native identities before retiring their registration records. + // This mirrors runtime navigation cleanup, without adding a bootstrap auction client. + function clearBootstrapNavigationTargeting() { + var gpt = window.googletag; + var previousKeys = Object.assign( + Object.create(null), + ts.prevSlotTargetingKeys, + ); + var registeredRecords = Object.values( + (ts.publisherSlotRegistrationState && + ts.publisherSlotRegistrationState.records) || + {}, + ).filter(function (record) { + return record.consumed && record.targetingKeys; + }); + var registeredIds = new Set(); + var snapshot = new Map(); + var baseKeys = CANDIDATE_KEYS.concat(["ts_initial"]); + registeredRecords.forEach(function (record) { + registeredIds.add(record.elementId); + previousKeys[record.elementId] = record.targetingKeys; + if (record.gptSlot) + snapshot.set(record.gptSlot, baseKeys.concat(record.targetingKeys)); + }); + var touchedIds = new Set( + Object.keys(previousKeys).concat(Object.keys(ts.divToSlotId || {})), + ); + var pubads = gpt.pubads && gpt.pubads(); + var live = pubads && pubads.getSlots ? pubads.getSlots() : []; + live.concat(ts.prevGptSlots || []).forEach(function (slot) { + var id = slot.getSlotElementId(); + if (touchedIds.has(id) && !registeredIds.has(id)) { + snapshot.set(slot, baseKeys.concat(previousKeys[id] || [])); + } + }); + var clearSnapshot = function () { + var current = window.googletag.pubads && window.googletag.pubads(); + if (!current) return false; + var currentSlots = current.getSlots ? current.getSlots() : []; + snapshot.forEach(function (keys, slot) { + if ( + !currentSlots.includes(slot) || + typeof slot.clearTargeting !== "function" + ) + return; + new Set(keys).forEach(function (key) { + slot.clearTargeting(key); + }); + }); + return true; + }; + if (snapshot.size > 0 && !clearSnapshot()) tag.cmd.push(clearSnapshot); + ts.prevSlotTargetingKeys = Object.create(null); + ts.divToSlotId = Object.create(null); + } + + // One observer resolves a replaceable handler, so runtime takeover preserves + // framework wrappers and transfers counter ownership instead of nesting writers. + function installBootstrapNavigationBridge() { + if (ts.gptNavigationBridge) return; + var currentPath = location.pathname; + ts.gptNavigationBridge = { + onNavigate: function (path) { + if (path === currentPath) return; + currentPath = path; + clearBootstrapNavigationTargeting(); + ts.navGeneration = (ts.navGeneration || 0) + 1; + invalidatePublisherSlotRegistrations(ts); + delete ts.firstImpression; + Object.entries(ts.gptSlotHandoffs || {}).forEach(function (entry) { + if (!entry[1].publisherClaimed) delete ts.gptSlotHandoffs[entry[0]]; + }); + ts.gptNavigationBridge.pendingGeneration = ts.navGeneration; + }, + }; + var observe = function () { + ts.gptNavigationBridge.onNavigate(location.pathname); + }; + ["pushState", "replaceState"].forEach(function (method) { + var original = history[method].bind(history); + history[method] = function (state, unused, url) { + original(state, unused, url); + observe(); + }; + }); + window.addEventListener("popstate", observe, true); + } + installBootstrapNavigationBridge(); + // Minimal fallback for tsjs.scheduleInitialAdInit, mirroring the bundle's // hydration-safe scheduler in // crates/trusted-server-js/lib/src/integrations/gpt/index.ts: the @@ -336,6 +945,9 @@ // would overwrite a committed SPA navigation's slots. if (initialSlots !== undefined) ts.adSlots = initialSlots; if (initialBids !== undefined) ts.bids = initialBids; + if (initialBids !== undefined || initialSlots !== undefined) { + settlePublisherSlotPayload(ts, 0, ts.adSlots || [], ts.bids || {}); + } var fire = function () { if ((ts.navGeneration || 0) !== 0) return; if (typeof ts.adInit === "function") ts.adInit(); @@ -749,10 +1361,12 @@ } ts.adInit = function () { - var slots = ts.adSlots || []; + var slots = (ts.adSlots || []).filter(function (slot) { + return slot.initialization !== "publisher_registered"; + }); var bids = ts.bids || {}; - var divToSlotId = {}; - var nextSlotTargetingKeys = {}; + var divToSlotId = Object.create(null); + var nextSlotTargetingKeys = Object.create(null); // Generation this invocation belongs to. The slot work below is queued on // googletag.cmd, which drains only when GPT loads; recheck first inside // the queued callback so a navigation committed in the gap cancels the @@ -881,6 +1495,7 @@ } }); ts.prevGptSlots = newSlots; + retainPublisherSlotMappings(ts, divToSlotId, nextSlotTargetingKeys); ts.divToSlotId = divToSlotId; ts.prevSlotTargetingKeys = nextSlotTargetingKeys; var hasRenderableWork = diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 682ecc0f15..ebbbf0481e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -6064,7 +6064,8 @@ pub(crate) fn build_empty_bids_script() -> String { /// [`handle_page_bids`] (SPA navigation) so the slot wire shape has a single /// definition and the two paths cannot silently diverge. Property names match /// what the client-side TSJS bundle expects: `gam_unit_path`, `div_id`, -/// `formats`, and `targeting`. Returns `None` when the slot's dynamic GAM unit +/// `formats`, and `targeting`. Non-default `initialization` is emitted only for +/// explicitly opted-in slots. Returns `None` when the slot's dynamic GAM unit /// path exceeds its rendering limit. pub(crate) fn build_slot_json( slot: &crate::creative_opportunities::CreativeOpportunitySlot, @@ -6083,13 +6084,17 @@ pub(crate) fn build_slot_json( .iter() .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) .collect(); - Some(serde_json::json!({ + let mut value = serde_json::json!({ "id": slot.id, "gam_unit_path": gam_path, "div_id": div_id, "formats": formats, "targeting": targeting, - })) + }); + if !slot.initialization.is_auto() { + value["initialization"] = serde_json::json!(slot.initialization); + } + Some(value) } /// Match creative-opportunity slots and omit dynamic GAM paths that cannot be @@ -7685,6 +7690,7 @@ mod tests { fn scheduling_slot() -> CreativeOpportunitySlot { CreativeOpportunitySlot { + initialization: Default::default(), id: "scheduling-slot".to_owned(), gam_unit_path: None, div_id: None, @@ -10284,6 +10290,7 @@ mod tests { /// branch that only runs when it is on. fn article_slot() -> crate::creative_opportunities::CreativeOpportunitySlot { crate::creative_opportunities::CreativeOpportunitySlot { + initialization: Default::default(), id: "test-slot".to_string(), gam_unit_path: None, div_id: Some("test-slot".to_string()), @@ -16008,6 +16015,7 @@ mod tests { pub(super) fn slot() -> CreativeOpportunitySlot { CreativeOpportunitySlot { + initialization: Default::default(), id: "atf".to_string(), gam_unit_path: Some("/99999/example/home".to_string()), div_id: Some("ad-atf".to_string()), @@ -16050,6 +16058,66 @@ mod tests { settings } + #[test] + fn registered_initialization_uses_the_admitted_inline_and_shared_slot_wire() { + let settings = settings_with_slots(); + let mut value = serde_json::to_value(slot()).expect("should serialize slot"); + value["initialization"] = serde_json::json!("publisher_registered"); + let registered = + serde_json::from_value(value).expect("should deserialize registered slot"); + let slots = [registered]; + let config = settings + .creative_opportunities + .as_ref() + .expect("should configure creative opportunities"); + let wire = build_slot_json(&slots[0], config, "example") + .expect("should build registered slot JSON"); + assert_eq!( + wire["initialization"], "publisher_registered", + "should emit the explicitly selected initialization mode" + ); + let expected = + serde_json::to_string(&vec![wire]).expect("should serialize expected slot wire"); + let inline = + template_ad_slots_script(AssemblyMode::Inline, true, &settings, &slots, "/") + .expect("should emit admitted inline slots"); + assert_eq!( + inline, + format!( + "", + html_escape_for_script(&expected) + ), + "should use the same slot serializer for inline documents" + ); + assert_eq!( + seam_ad_slots_json(AssemblyMode::Esi, true, &settings, &slots, "/"), + Some(expected), + "should use the same slot serializer for shared-template seams" + ); + for mode in [AssemblyMode::Inline, AssemblyMode::Esi] { + assert_eq!( + template_ad_slots_script(mode, false, &settings, &slots, "/"), + None, + "should not enable a gated head payload for {mode:?}" + ); + assert_eq!( + seam_ad_slots_json(mode, false, &settings, &slots, "/"), + None, + "should not enable a gated seam payload for {mode:?}" + ); + } + assert_eq!( + seam_ad_slots_json(AssemblyMode::Esi, true, &settings, &[], "/"), + Some("[]".to_string()), + "should preserve explicitly empty admitted slots" + ); + assert_eq!( + template_ad_slots_script(AssemblyMode::Esi, true, &settings, &slots, "/"), + None, + "should keep shared-template head configuration neutral" + ); + } + #[test] fn shared_modes_emit_no_head_script_regardless_of_the_gating_decision() { let settings = settings_with_slots(); @@ -16501,6 +16569,7 @@ mod tests { fn article_slot() -> CreativeOpportunitySlot { CreativeOpportunitySlot { + initialization: Default::default(), id: "article-slot".to_string(), gam_unit_path: None, div_id: None, @@ -21931,6 +22000,7 @@ mod tests { fn make_slot() -> CreativeOpportunitySlot { CreativeOpportunitySlot { + initialization: Default::default(), id: "atf_sidebar_ad".to_string(), gam_unit_path: Some("/21765378893/publisher/atf-sidebar".to_string()), div_id: Some("div-atf-sidebar".to_string()), @@ -21981,6 +22051,26 @@ mod tests { } } + #[test] + fn build_slot_json_preserves_automatic_wire_bytes() { + let slot = make_slot(); + let config = make_config(); + let wire = crate::publisher::build_slot_json(&slot, &config, "example") + .expect("should build automatic slot JSON"); + let expected = serde_json::json!({ + "id": "atf_sidebar_ad", + "gam_unit_path": "/21765378893/publisher/atf-sidebar", + "div_id": "div-atf-sidebar", + "formats": [[300, 250]], + "targeting": {"pos": "atf"}, + }); + assert_eq!( + serde_json::to_string(&wire).expect("should serialize automatic wire"), + serde_json::to_string(&expected).expect("should serialize expected wire"), + "should preserve automatic slot wire byte-for-byte" + ); + } + #[test] fn ad_slots_script_contains_slot_data() { let mut slot = make_slot(); @@ -23667,6 +23757,7 @@ mod tests { fn article_slot() -> Vec { vec![CreativeOpportunitySlot { + initialization: Default::default(), id: "atf".to_string(), gam_unit_path: None, div_id: None, @@ -23688,6 +23779,85 @@ mod tests { make_page_bids_request_on(PAGE_BIDS_PATH, path) } + #[tokio::test] + async fn page_bids_registered_initialization_preserves_admission_gates() { + let mut value = + serde_json::to_value(&article_slot()[0]).expect("should serialize article slot"); + value["initialization"] = serde_json::json!("publisher_registered"); + let slots = + vec![serde_json::from_value(value).expect("should deserialize registered slot")]; + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let admitted = run_page_bids_consent_allowed( + &settings, + &orchestrator, + &slots, + make_page_bids_request("/2024/article"), + ) + .await; + let expected = crate::publisher::build_slot_json( + &slots[0], + settings + .creative_opportunities + .as_ref() + .expect("should configure slots"), + "2024", + ) + .expect("should build registered wire"); + assert_eq!( + expected["initialization"], "publisher_registered", + "should emit publisher-registered mode" + ); + assert_eq!( + admitted["slots"], + serde_json::json!([expected]), + "should return the shared slot wire for admitted SPA delivery" + ); + + let consent_denied = run_page_bids( + &settings, + &orchestrator, + &slots, + make_page_bids_request("/2024/article"), + ) + .await; + assert_eq!( + consent_denied["slots"], + serde_json::json!([]), + "should not bypass consent admission" + ); + let unmatched = run_page_bids_consent_allowed( + &settings, + &orchestrator, + &slots, + make_page_bids_request("/other"), + ) + .await; + assert_eq!( + unmatched["slots"], + serde_json::json!([]), + "should not bypass page matching" + ); + for gated in [ + settings_with_co_auction_disabled(), + settings_with_co_templates_disabled(), + ] { + let orchestrator = AuctionOrchestrator::new(gated.auction.clone()); + let response = run_page_bids_consent_allowed( + &gated, + &orchestrator, + &slots, + make_page_bids_request("/2024/article"), + ) + .await; + assert_eq!( + response["slots"], + serde_json::json!([]), + "should not bypass auction or template admission" + ); + } + } + #[tokio::test] async fn page_bids_format_absent_or_json_returns_json() { let settings = settings_with_co(); @@ -24634,6 +24804,7 @@ mod tests { fn article_slot() -> Vec { vec![CreativeOpportunitySlot { + initialization: Default::default(), id: "atf".to_string(), gam_unit_path: None, div_id: None, diff --git a/crates/trusted-server-integration-tests/browser/helpers/infra.ts b/crates/trusted-server-integration-tests/browser/helpers/infra.ts index 0402bb266e..0d4ca40943 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/infra.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/infra.ts @@ -1,6 +1,8 @@ import { execFileSync, spawn, type ChildProcess } from "node:child_process"; import { setTimeout as setTimeoutPromise } from "node:timers/promises"; import { createServer } from "node:net"; +import { createWriteStream, mkdirSync } from "node:fs"; +import { join } from "node:path"; import { waitForReady } from "./wait-for-ready.js"; const ORIGIN_PORT = process.env.INTEGRATION_ORIGIN_PORT || "8888"; @@ -55,7 +57,7 @@ export async function startContainer(framework: string): Promise { `${ORIGIN_PORT}:${config.port}`, "-e", `ORIGIN_HOST=127.0.0.1:${ORIGIN_PORT}`, - config.image, + process.env.INTEGRATION_FRAMEWORK_IMAGE || config.image, ], { encoding: "utf-8" }, ).trim(); @@ -93,17 +95,36 @@ export async function startViceroy( ): Promise<{ process: ChildProcess; baseUrl: string }> { const port = await findAvailablePort(); + const logDirectory = process.env.PUBLISHER_REGISTRATION_LOG_DIR; + if (logDirectory) mkdirSync(logDirectory, { recursive: true }); const child = spawn( "viceroy", - [wasmPath, "-C", configPath, "--addr", `127.0.0.1:${port}`], + [ + wasmPath, + "-C", + configPath, + "--addr", + `127.0.0.1:${port}`, + ...(logDirectory ? ["--log-stdout", "--log-stderr"] : []), + ], // stdin: ignore, stdout: pipe (drained), stderr: pipe (logged). // IMPORTANT: stdout MUST be drained to prevent pipe buffer deadlock. // If the OS pipe buffer (~64KB) fills, Viceroy blocks on write. { stdio: ["ignore", "pipe", "pipe"] }, ); - // Drain stdout to prevent pipe buffer deadlock - child.stdout?.resume(); + // Preserve dedicated-run logs without changing normal framework output. + // Both paths drain stdout to prevent pipe buffer deadlock. + if (logDirectory) { + child.stdout?.pipe( + createWriteStream(join(logDirectory, "viceroy.stdout.log")), + ); + child.stderr?.pipe( + createWriteStream(join(logDirectory, "viceroy.stderr.log")), + ); + } else { + child.stdout?.resume(); + } // Surface Viceroy stderr for debugging child.stderr?.on("data", (data: Buffer) => { diff --git a/crates/trusted-server-integration-tests/browser/package.json b/crates/trusted-server-integration-tests/browser/package.json index a59480e724..f3e9acd882 100644 --- a/crates/trusted-server-integration-tests/browser/package.json +++ b/crates/trusted-server-integration-tests/browser/package.json @@ -5,7 +5,10 @@ "scripts": { "test": "npx playwright test", "test:nextjs": "TEST_FRAMEWORK=nextjs npx playwright test", - "test:wordpress": "TEST_FRAMEWORK=wordpress npx playwright test" + "test:wordpress": "TEST_FRAMEWORK=wordpress npx playwright test", + "test:publisher-registration:fixture": "node --test publisher-registration/pages.test.cjs", + "test:publisher-registration": "node publisher-registration/run.cjs --implementation all", + "test:publisher-registration:framework": "../../../scripts/test-publisher-registration.sh" }, "devDependencies": { "@playwright/test": "^1.49.0", diff --git a/crates/trusted-server-integration-tests/browser/playwright.publisher-registration.config.ts b/crates/trusted-server-integration-tests/browser/playwright.publisher-registration.config.ts new file mode 100644 index 0000000000..1897670540 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/playwright.publisher-registration.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from "@playwright/test"; + +if (process.env.PUBLISHER_REGISTRATION_RUN !== "1") { + throw new Error( + "Use scripts/test-publisher-registration.sh for the isolated fixture settings", + ); +} + +export default defineConfig({ + testDir: "./tests/nextjs", + testMatch: "publisher-registration.spec.ts", + globalSetup: "./global-setup.ts", + globalTeardown: "./global-teardown.ts", + workers: 1, + retries: 0, + timeout: 60_000, + expect: { timeout: 10_000 }, + use: { + headless: true, + viewport: { width: 900, height: 800 }, + screenshot: "on", + trace: "on", + }, + projects: [ + { + name: "publisher-registration-chromium", + use: { browserName: "chromium" }, + }, + ], + outputDir: process.env.PUBLISHER_REGISTRATION_RESULTS, + reporter: [ + ["list"], + [ + "json", + { + outputFile: `${process.env.PUBLISHER_REGISTRATION_RESULTS}/results.json`, + }, + ], + ], +}); diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/README.md b/crates/trusted-server-integration-tests/browser/publisher-registration/README.md new file mode 100644 index 0000000000..4700def0be --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/README.md @@ -0,0 +1,115 @@ +# Publisher registration browser contracts + +These tests use fictional inventory and intercepted transport. They do not contact +GPT, GAM, or bidders and do not establish production correctness. + +## Run + +Build the worktree's JS bundles first, then run: + +```sh +node --test crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs +node crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs \ + --implementation all --out /tmp/ts-publisher-registration-offline +``` + +`--implementation` accepts `runtime`, `bootstrap`, `handoff`, or `all`. The runner +builds a real external Rubicon/SharedId Prebid bundle unless `--prebid DIR` supplies +one. It verifies the supplied manifest's SHA-256 and required modules. Source +bundles are snapshotted before browser launch so another build cannot mix assets +within the run. Output defaults to a temporary directory outside Git. + +The fixed contract is the default and fails if protocol version 1 is missing. +There is no allow-failures option. Runtime installs the actual GPT bundle; +bootstrap runs without that bundle or its renderer; handoff starts with bootstrap +and installs runtime across the waiting or consumed delivery boundary. + +Cases cover both admission/registration orders, inline and shared-seam payload +shapes, missing and visible SSR placeholders, outside-prefix final IDs, two slots +sharing a GAM path, repeated registration, reordered and narrowed eligibility, +missing/incompatible/unknown-dimension bids, empty/fluid eligibility, explicit +no-fill, pending cancellation, consumed teardown, hidden eligibility replacement, +stale callbacks, disabled-load rejection and rechecking, automatic/registered map +writer order, empty automatic passes, publisher-first arbitration, and a later +refresh through real Prebid. Runtime/handoff also exercise actual SPA page-bids +transport, navigation cleanup and duplicate runtime installation. Handoff includes +ordinary bootstrap fill followed by late runtime with no candidate resurrection. +Real history push/popstate cases keep bootstrap-only A-to-B-to-A registrations +unadmitted, prove one late-runtime current-path catchup without replacing the +waiting handle or generation, and reject a forced late B response after C. +A failed catchup can retry through a same-path history replace with query/hash +changes while preserving the waiting handle, token, native slot and generation. +No-navigation runtime arrival must not fetch or request again. + +Every successful initial delivery independently checks the exact native object, +exact final element, one display of its actual ID, one scoped refresh containing +only that object, request-time targeting, unrelated SRA isolation, and real iframe +creative text. Registered-only paths must make no service or policy calls. + +## Old automatic controls + +```sh +node crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs \ + --baseline --implementation all --out /tmp/ts-publisher-registration-baseline +``` + +This explicit diagnostic mode uses enabled initial loading. It first requests and +renders an automatic placement, then replaces its whole subtree or renames its +existing element. The publisher offers identical, reordered, or narrowed formats. +Identical formats alias the old native slot and suppress the publisher display. +Reordered and narrowed formats define another native slot and request again. + +For whole-subtree replacement, the identical-size alias has one request but no +creative in the final element. This is deliberately **not** a visible-delivery +pass. These old-mode controls omit destroy-before-removal to reproduce the +unsupported lifecycle, not to recommend it. A renamed element retaining its +children is recorded separately from replacement. + +## Fixture boundary + +`gpt-fixture.js` counts native definitions, displays, refreshes and destruction. +It does not inspect TS ownership, deduplicate requests, or synthesize admission. +Enabled-load SRA display fetches every unrelated unfetched slot. Disabled-load +display only registers; explicit selected refresh leaves unrelated slots alone. +Every native refresh invocation is recorded, but an undisplayed slot cannot request +through refresh. This models GPT registration rather than TS ownership. +Native object and element references remain accessible through `fixture.nativeSlots` +and `fixture.elements`; serialized request records carry their indices. + +Install the fixture after optional `window.publisherFixtureOptions` configuration: + +- `initialLoad`: `disabled`, `enabled`, or `unknown`. Unknown removes both getters. +- `deferCommands`: hold GPT callbacks until `fixture.flushCommands()`. + +`fixture.render(index)` chooses the creative from targeting captured at the native +request. Candidate requests load real Universal Creative in a separate-origin +frame. Non-candidate requests load an ordinary publisher creative without the TS +bridge. `fixture.render(index, { noFill: true })` emits an explicit no-fill instead. +The runner asserts real iframe text inside the final element's frame tree, not +just a render event or postMessage marker. This includes the nested iframe created +by the real Universal Creative renderer. + +Offline `pages.cjs` payloads are handwritten fictional inputs whose source is +recorded in evidence. They cannot substitute for the separate real React/Rust +provider framework gate. That suite may import this transport fixture but must +obtain slots and bids through actual Rust injection and provider admission. + +## Evidence and limits + +Every case retains a screenshot, Playwright trace, native requests, targeting, +creative facts, network calls, errors and elapsed time, including failures. +`artifacts.json` records commit, dirty inputs, browser/Node/Prebid/Universal Creative +versions, manifest and hashes of the exact snapshotted scripts. + +Offline hidden-tab controls override visibility getters and dispatch visibility +notifications explicitly. Old callback and handle controls force stale work after +replacement. Frame barriers drain already queued delivery work; they do not claim +that a React tree is mounted or hydrated. Actual framework lifecycle is proved by +the separate React/Rust suite. The stale catchup response case strips B's fetch +abort signal only in its controlled transport, then delivers a real intercepted +response after C. This exercises the generation/controller rejection even when +network cancellation cannot stop an old response. + +Real-GPT staging with matching deployed artifacts and a publisher adapter remains +a separate approval and deployment blocker. Local counts are not GAM network +batch counts, live fill guarantees, or a CWV measurement. diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/gpt-fixture.js b/crates/trusted-server-integration-tests/browser/publisher-registration/gpt-fixture.js new file mode 100644 index 0000000000..e6b9ce8174 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/gpt-fixture.js @@ -0,0 +1,320 @@ +// Transport only. Never read TS claims to decide whether a native call requests. +(() => { + const options = window.publisherFixtureOptions ?? {}; + const trace = []; + const requests = []; + const creatives = []; + const nativeSlots = []; + const elements = []; + const listeners = new Map(); + const displayed = new Set(); + const fetched = new Set(); + const destroyed = new Set(); + const queue = []; + let disabled = options.initialLoad !== "enabled"; + let sra = false; + let enabled = false; + let batch = 0; + let deferCommands = !!options.deferCommands; + const record = (event, details = {}) => + trace.push({ event, time: Math.round(performance.now()), ...details }); + const elementIndex = (element) => { + if (!element) return null; + if (!elements.includes(element)) elements.push(element); + return elements.indexOf(element); + }; + const emit = (event, slot, details = {}) => { + record(event, { native: nativeSlots.indexOf(slot), ...details }); + for (const callback of listeners.get(event) ?? []) + callback({ slot, ...details }); + }; + const snapshot = (slot) => ({ + native: nativeSlots.indexOf(slot), + id: slot.getSlotElementId(), + path: slot.getAdUnitPath(), + element: elementIndex(document.getElementById(slot.getSlotElementId())), + targeting: Object.fromEntries( + slot.getTargetingKeys().map((key) => [key, [...slot.getTargeting(key)]]), + ), + sizes: slot + .getSizes() + .map((size) => + size === "fluid" ? size : [size.getWidth(), size.getHeight()], + ), + }); + const request = (slots, source, refreshOptions) => { + const batchId = batch++; + for (const slot of slots) { + if (destroyed.has(slot)) continue; + const entry = { + index: requests.length, + batch: batchId, + source, + ...snapshot(slot), + options: refreshOptions, + }; + requests.push(entry); + fetched.add(slot); + record("nativeRequest", entry); + emit("slotRequested", slot); + } + }; + const serviceTargeting = new Map(); + const service = { + getSlots: () => nativeSlots.filter((slot) => !destroyed.has(slot)), + getTargeting: (key) => serviceTargeting.get(key) ?? [], + getTargetingKeys: () => [...serviceTargeting.keys()], + setTargeting(key, value) { + serviceTargeting.set(key, [String(value)]); + return service; + }, + clearTargeting(key) { + if (key === undefined) serviceTargeting.clear(); + else serviceTargeting.delete(key); + return service; + }, + enableSingleRequest() { + sra = true; + record("enableSingleRequest"); + return true; + }, + disableInitialLoad() { + disabled = true; + record("disableInitialLoad", { enabled }); + }, + addEventListener(event, callback) { + const callbacks = listeners.get(event) ?? []; + callbacks.push(callback); + listeners.set(event, callbacks); + return service; + }, + removeEventListener(event, callback) { + listeners.set( + event, + (listeners.get(event) ?? []).filter((fn) => fn !== callback), + ); + }, + refresh(slots, refreshOptions) { + const selected = slots ?? service.getSlots(); + record("nativeRefresh", { + natives: selected.map((slot) => nativeSlots.indexOf(slot)), + options: refreshOptions, + scoped: slots !== undefined, + }); + request( + selected.filter((slot) => displayed.has(slot)), + "refresh", + refreshOptions, + ); + }, + }; + if (options.initialLoad !== "unknown") + service.isInitialLoadDisabled = () => disabled; + const googletag = { + apiReady: true, + pubadsReady: true, + cmd: { + push(...callbacks) { + for (const callback of callbacks) { + if (deferCommands) queue.push(callback); + else callback(); + } + return queue.length; + }, + }, + pubads: () => service, + defineSlot(path, sizes, id) { + const targeting = new Map(); + const formats = + sizes.length === 2 && sizes.every((size) => typeof size === "number") + ? [sizes] + : sizes; + const slot = { + getSlotElementId: () => id, + getAdUnitPath: () => path, + getSizes: () => + formats.map((size) => + size === "fluid" + ? size + : { getWidth: () => size[0], getHeight: () => size[1] }, + ), + getTargeting: (key) => targeting.get(key) ?? [], + getTargetingKeys: () => [...targeting.keys()], + setTargeting(key, value) { + targeting.set( + key, + (Array.isArray(value) ? value : [value]).map(String), + ); + record("targetingSet", { + native: nativeSlots.indexOf(slot), + key, + value: [...targeting.get(key)], + }); + return slot; + }, + clearTargeting(key) { + if (key === undefined) targeting.clear(); + else targeting.delete(key); + record("targetingCleared", { + native: nativeSlots.indexOf(slot), + key, + }); + return slot; + }, + updateTargetingFromMap(map) { + for (const [key, value] of Object.entries(map)) { + if (value === null) slot.clearTargeting(key); + else slot.setTargeting(key, value); + } + return slot; + }, + addService: () => slot, + setConfig: () => slot, + defineSizeMapping: () => slot, + }; + nativeSlots.push(slot); + record("nativeDefine", snapshot(slot)); + return slot; + }, + destroySlots(slots = service.getSlots()) { + for (const slot of slots) { + record("nativeDestroy", { + ...snapshot(slot), + connected: !!document.getElementById(slot.getSlotElementId()) + ?.isConnected, + }); + destroyed.add(slot); + } + return true; + }, + enableServices() { + enabled = true; + record("enableServices"); + }, + display(target) { + const id = + typeof target === "string" + ? target + : (target.getSlotElementId?.() ?? target.id); + const slot = service + .getSlots() + .find((candidate) => candidate.getSlotElementId() === id); + record("nativeDisplay", { + id, + native: nativeSlots.indexOf(slot), + disabled, + sra, + }); + if (!slot) throw new Error("display requires a defined slot: " + id); + if (!document.getElementById(id)) + throw new Error("display requires a connected element: " + id); + displayed.add(slot); + if (!disabled) + request( + sra + ? service.getSlots().filter((candidate) => !fetched.has(candidate)) + : [slot], + "display", + ); + }, + setConfig(config) { + if (config.disableInitialLoad !== undefined) + disabled = !!config.disableInitialLoad; + if (config.singleRequest !== undefined) sra = !!config.singleRequest; + record("setConfig", { config }); + }, + }; + if (options.initialLoad !== "unknown") + googletag.getConfig = () => ({ + disableInitialLoad: disabled, + singleRequest: sra, + }); + window.googletag = googletag; + window.fixture = { + trace, + requests, + creatives, + nativeSlots, + elements, + record, + snapshot, + emit, + flushCommands() { + deferCommands = false; + while (queue.length) queue.shift()(); + }, + setPolicy(value) { + disabled = value; + record("fixturePolicy", { disabled }); + }, + render(index, { noFill = false } = {}) { + const entry = requests[index]; + if (!entry) throw new Error("Missing native request " + index); + const slot = nativeSlots[entry.native]; + const root = document.getElementById(entry.id); + if (!root) + throw new Error("Cannot render into absent native element " + entry.id); + if (noFill) { + root.replaceChildren(); + emit("slotRenderEnded", slot, { isEmpty: true }); + return; + } + const adId = entry.targeting.hb_adid?.[0]; + const frame = document.createElement("iframe"); + frame.width = "300"; + frame.height = "250"; + frame.dataset.request = String(index); + frame.src = adId + ? "https://creative.example.com/fixture-puc?adId=" + + encodeURIComponent(adId) + : "https://creative.example.com/fixture-publisher"; + root.replaceChildren(frame); + record("fixtureRender", { + index, + element: elementIndex(root), + candidate: !!adId, + }); + emit("slotResponseReceived", slot); + emit("slotRenderEnded", slot, { isEmpty: false, size: [300, 250] }); + }, + }; + const containsWindow = (view, target) => { + if (view === target) return true; + for (let index = 0; index < view.length; index++) { + if (containsWindow(view.frames[index], target)) return true; + } + return false; + }; + window.addEventListener("message", (event) => { + let data = event.data; + if (typeof data === "string") { + try { + data = JSON.parse(data); + } catch { + return; + } + } + if (data?.message === "Prebid Request") { + record("creativeBridgeRequest", { adId: data.adId }); + } + if (data?.type !== "fixture-creative-ready") return; + const frame = [...document.querySelectorAll("iframe")].find((frame) => + containsWindow(frame.contentWindow, event.source), + ); + if (!frame) return; + creatives.push({ + label: data.label, + index: Number(frame.dataset.request), + element: elementIndex(frame.parentElement), + }); + record("creativeReady", creatives.at(-1)); + }); + window.pbjs ??= { que: [], cmd: [] }; + window.__tsjs_gpt_enabled = true; + window.__tsjs_prebid = { + timeout: 15000, + debug: false, + serverSideBidders: [], + clientSideBidders: [], + }; +})(); diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/pages.cjs b/crates/trusted-server-integration-tests/browser/publisher-registration/pages.cjs new file mode 100644 index 0000000000..aa6e0d3399 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/pages.cjs @@ -0,0 +1,65 @@ +const { creative } = require("../initial-render/pages.cjs"); + +function inlineJson(value) { + return JSON.stringify(value) + .replaceAll("<", "\\u003c") + .replaceAll(">", "\\u003e") + .replaceAll("&", "\\u0026") + .replaceAll("\u2028", "\\u2028") + .replaceAll("\u2029", "\\u2029"); +} + +function pucPage(publisherOrigin = "https://publisher.example.com") { + return ``; +} + +// Direct-payload offline cases only. Framework proof must use Rust-injected data. +function payload({ registered = true, two = false, candidate = "fit" } = {}) { + const slots = (two ? ["header", "sidebar"] : ["header"]).map((id) => ({ + id, + gam_unit_path: "/123/example-placement", + div_id: "example-" + id + "-", + formats: [ + [300, 250], + [300, 600], + ], + ...(registered ? { initialization: "publisher_registered" } : {}), + targeting: { example_section: ["fiction"] }, + })); + const bids = Object.fromEntries( + slots.map((slot) => [ + slot.id, + candidate === "none" + ? {} + : { + hb_adid: "example-ad-" + slot.id, + hb_bidder: "exampleServer", + hb_pb: "1.00", + ...(candidate !== "unknown" + ? { w: 300, h: candidate === "incompatible" ? 600 : 250 } + : {}), + adm: creative("Server result"), + nurl: + "https://publisher.example.com/fixture-notice/win?slot=" + + slot.id, + burl: + "https://publisher.example.com/fixture-notice/bill?slot=" + + slot.id, + }, + ]), + ); + return { slots, bids }; +} + +function documentPage(data = {}) { + return `Publisher registration contract +

Publisher registration contract

+`; +} + +module.exports = { inlineJson, payload, documentPage, creative, pucPage }; diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs b/crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs new file mode 100644 index 0000000000..a3df5a9705 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs @@ -0,0 +1,186 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const test = require("node:test"); +const { + inlineJson, + documentPage, + payload, + creative, + pucPage, +} = require("./pages.cjs"); +const source = fs.readFileSync(require.resolve("./gpt-fixture.js"), "utf8"); + +function fixture(initialLoad = "disabled") { + const elements = new Map( + ["example-header-final", "unrelated"].map((id) => [ + id, + { id, isConnected: true }, + ]), + ); + const window = { + publisherFixtureOptions: { initialLoad }, + addEventListener() {}, + }; + vm.runInNewContext(source, { + window, + document: { getElementById: (id) => elements.get(id) }, + performance: { now: () => 0 }, + }); + const g = window.googletag; + const slot = g + .defineSlot("/123/example-placement", [[300, 250]], "example-header-final") + .addService(g.pubads()); + const unrelated = g + .defineSlot("/123/unrelated", [[300, 250]], "unrelated") + .addService(g.pubads()); + g.pubads().enableSingleRequest(); + g.enableServices(); + return { g, f: window.fixture, window, slot, unrelated }; +} + +test("inline JSON cannot terminate its script, including line separators", () => { + const input = { + id: '&\u2028\u2029', + }; + assert.deepEqual(JSON.parse(inlineJson(input)), input); + assert(!/[<>&\u2028\u2029]/u.test(inlineJson(input))); + const html = documentPage(input); + assert.equal((html.match(/&'); + assert(html.includes("</div><script>")); + assert.equal((html.match(/&"; + const html = pucPage(origin); + assert.equal((html.match(/]", + 250, + vec![], + ); + let rule = SlotReservationRule::from_slot(&slot).expect("should prepare escaped rule"); + let style = render_slot_reservations(&[rule], "/article") + .expect("should render escaped reservation"); + assert_eq!( + style.matches("").count(), + 1, + "should contain only owned closing tag" + ); + assert!( + !style.contains("`. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, @@ -118,6 +128,23 @@ impl HtmlProcessorConfig { request_host: request_host.to_owned(), request_scheme: request_scheme.to_owned(), integrations: integrations.clone(), + slot_reservations: settings + .creative_opportunities + .as_ref() + .filter(|config| config.enabled) + .map(|config| { + config + .slot + .iter() + .filter_map(SlotReservationRule::from_slot) + .collect() + }) + .unwrap_or_default(), + request_path: None, + enforced_csp: settings + .response_headers + .keys() + .any(|name| name.eq_ignore_ascii_case("content-security-policy")), ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, @@ -128,6 +155,27 @@ impl HtmlProcessorConfig { } } + /// Attach the raw initial-document pathname used by configured page patterns. + /// + /// Callers must pass the URI pathname without query or fragment. Reservation + /// policy is independent of consent, auction admission, and ad-slot payloads. + #[must_use] + pub fn with_request_path(mut self, request_path: &str) -> Self { + self.request_path = Some(request_path.to_owned()); + self + } + + /// Preserve synchronous loading when the origin response has enforced CSP. + /// + /// Pass header presence, independent of its value or auction admission. A + /// Content-Security-Policy-Report-Only header alone must pass `false`. + /// A configured enforced response header remains effective when passed `false`. + #[must_use] + pub fn with_enforced_csp(mut self, enforced_csp: bool) -> Self { + self.enforced_csp |= enforced_csp; + self + } + /// Attach the streaming-auction `"#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { + enforced_csp: false, + slot_reservations: Vec::new(), + request_path: None, csp_nonce_observed: None, body_close: BodyCloseInjection::InlineBids, origin_host: "origin.example.com".to_string(), @@ -1657,6 +2002,9 @@ mod tests { let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { + enforced_csp: false, + slot_reservations: Vec::new(), + request_path: None, csp_nonce_observed: None, body_close: BodyCloseInjection::InlineBids, origin_host: "origin.example.com".to_string(), @@ -1697,6 +2045,9 @@ mod tests { let request_host = "proxy.test-publisher.example.com"; let config = HtmlProcessorConfig { + enforced_csp: false, + slot_reservations: Vec::new(), + request_path: None, csp_nonce_observed: None, body_close: BodyCloseInjection::None, origin_host: "origin.test-publisher.example.com".to_string(), @@ -1751,6 +2102,9 @@ mod tests { // (state is None) — e.g. auction timed out with zero bids. Fallback to {}. let state = std::sync::Arc::new(std::sync::Mutex::new(None)); let config = HtmlProcessorConfig { + enforced_csp: false, + slot_reservations: Vec::new(), + request_path: None, csp_nonce_observed: None, body_close: BodyCloseInjection::InlineBids, origin_host: "origin.example.com".to_string(), @@ -1783,6 +2137,9 @@ mod tests { // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified"). let state = std::sync::Arc::new(std::sync::Mutex::new(None)); let config = HtmlProcessorConfig { + enforced_csp: false, + slot_reservations: Vec::new(), + request_path: None, csp_nonce_observed: None, body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_string(), @@ -1808,6 +2165,9 @@ mod tests { fn marker_mode_config(marker: &str, observer: Option>) -> HtmlProcessorConfig { HtmlProcessorConfig { + enforced_csp: false, + slot_reservations: Vec::new(), + request_path: None, csp_nonce_observed: observer, body_close: BodyCloseInjection::Marker(marker.to_string()), origin_host: "origin.example.com".to_string(), @@ -2069,6 +2429,9 @@ mod tests { fn bodyless_marker_mode_emits_an_owned_terminal_seam_even_after_source_bytes() { const MARKER: &str = ""; let config = HtmlProcessorConfig { + enforced_csp: false, + slot_reservations: Vec::new(), + request_path: None, csp_nonce_observed: None, body_close: BodyCloseInjection::Marker(MARKER.to_string()), origin_host: "origin.example.com".to_string(), diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index 6b301bf935..4963878f98 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -629,6 +629,9 @@ mod tests { fn process_html_with_registry(html: &str, integrations: IntegrationRegistry) -> String { let processor = create_html_processor(HtmlProcessorConfig { + enforced_csp: false, + slot_reservations: Vec::new(), + request_path: None, csp_nonce_observed: None, body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_string(), diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index ebbbf0481e..8b4df2e997 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -555,14 +555,15 @@ pub fn handle_tsjs_dynamic( } if let Some(module_id) = parse_single_module_filename(filename) { - // Deferred modules and the conditionally injected diagnostics module - // are served as content-addressed standalone assets. Delivery remains - // cookie-independent so the static response can stay publicly cached. + // Deferred modules, the GPT runtime and conditional diagnostics are + // content-addressed standalone assets. Delivery remains cookie-independent + // so the static response can stay publicly cached. let deferred_ids = integration_registry.js_module_ids_deferred(); let diagnostics_standalone = module_id == crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_INTEGRATION_ID && integration_registry.integration_enabled(module_id); - if !deferred_ids.contains(&module_id) && !diagnostics_standalone { + let gpt_standalone = module_id == "gpt" && integration_registry.integration_enabled("gpt"); + if !deferred_ids.contains(&module_id) && !diagnostics_standalone && !gpt_standalone { return Ok(not_found_response()); } if let (Some(content), Some(hash)) = ( @@ -609,7 +610,7 @@ fn request_version_hash(req: &Request) -> Option<&str> { /// /// Returns `Some(&'static str)` if the filename matches a known JS module ID, /// `None` otherwise. The caller must additionally verify that the module is -/// both deferred and enabled via the [`IntegrationRegistry`]. +/// enabled for standalone delivery via the [`IntegrationRegistry`]. #[must_use] fn parse_single_module_filename(filename: &str) -> Option<&'static str> { let stem = filename @@ -627,6 +628,8 @@ struct ProcessResponseParams<'a> { content_encoding: &'a str, origin_host: &'a str, origin_url: &'a str, + request_path: &'a str, + enforced_csp: bool, request_host: &'a str, request_scheme: &'a str, settings: &'a Settings, @@ -670,6 +673,8 @@ impl PublisherBodyProcessor { ); let inner: Box = if is_html { Box::new(create_html_stream_processor(HtmlStreamProcessorParams { + enforced_csp: params.enforced_csp, + request_path: ¶ms.request_path, origin_host: ¶ms.origin_host, request_host: ¶ms.request_host, request_scheme: ¶ms.request_scheme, @@ -754,6 +759,8 @@ fn process_response_streaming( if is_html { let processor = create_html_stream_processor(HtmlStreamProcessorParams { + enforced_csp: params.enforced_csp, + request_path: params.request_path, origin_host: params.origin_host, request_host: params.request_host, request_scheme: params.request_scheme, @@ -1311,6 +1318,8 @@ async fn hold_finish_tail_segments( /// type capture every input lifetime, forcing callers to keep the settings and /// registry alive for as long as the processor. struct HtmlStreamProcessorParams<'a> { + request_path: &'a str, + enforced_csp: bool, origin_host: &'a str, request_host: &'a str, request_scheme: &'a str, @@ -1504,7 +1513,9 @@ fn create_html_stream_processor( params.origin_host, params.request_host, params.request_scheme, - ); + ) + .with_request_path(params.request_path) + .with_enforced_csp(params.enforced_csp); let assembly_mode = effective_assembly_mode(params.settings, params.shared_template_authorized); let body_close = match (assembly_mode, params.deferred_inline_marker) { @@ -1704,6 +1715,10 @@ pub struct OwnedProcessResponseParams { pub(crate) content_encoding: String, pub(crate) origin_host: String, pub(crate) origin_url: String, + /// Original URI pathname, independent of consent-gated slot payloads. + pub(crate) request_path: String, + /// Whether the actual origin response carries an enforced CSP header. + pub(crate) enforced_csp: bool, pub(crate) request_host: String, pub(crate) request_scheme: String, pub(crate) content_type: String, @@ -2141,6 +2156,12 @@ fn build_template_assembly_params( ad_bids_state: AdBidsState, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { + enforced_csp: entry + .metadata + .policy_headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("content-security-policy")), + request_path: "/".to_owned(), csp_nonce_observed: None, // Already stored; storing again on a hit would be pointless work. template_cache_key: None, @@ -2905,6 +2926,8 @@ pub fn stream_publisher_body( integration_registry: &IntegrationRegistry, ) -> Result<(), Report> { let borrowed = ProcessResponseParams { + enforced_csp: params.enforced_csp, + request_path: ¶ms.request_path, content_encoding: ¶ms.content_encoding, origin_host: ¶ms.origin_host, origin_url: ¶ms.origin_url, @@ -3013,6 +3036,8 @@ pub async fn stream_publisher_body_async( true, ); let mut processor = match create_html_stream_processor(HtmlStreamProcessorParams { + enforced_csp: params.enforced_csp, + request_path: ¶ms.request_path, origin_host: ¶ms.origin_host, request_host: ¶ms.request_host, request_scheme: ¶ms.request_scheme, @@ -5222,6 +5247,10 @@ pub async fn handle_publisher_request( return Ok(PublisherResponse::Buffered(response)); } + // Loading policy belongs to the actual origin representation, not consent or + // auction admission. Preserve the existing synchronous path for any enforced + // CSP, without trying to evaluate its directives or requiring new inline hashes. + let enforced_csp = response.headers().contains_key("content-security-policy"); let template_cache_policy = TemplateCachePolicy::from_settings(settings); let gate_content_type = response .headers() @@ -5472,6 +5501,8 @@ pub async fn handle_publisher_request( response, body, params: Box::new(OwnedProcessResponseParams { + enforced_csp, + request_path: request_path.clone(), // The transform writes here; the post-transform gate reads it. Always // present on the live path so no future caller has to remember to // supply it — the handlers themselves stay gated on authorization. @@ -6050,6 +6081,45 @@ pub(crate) fn seam_ad_slots_json( ) } +/// Generate production slot and bid snippets for isolated browser fixtures. +/// +/// This test-only bridge uses the same path filtering and serializers as publisher +/// responses. It does not simulate request consent or auction admission. +/// +/// # Panics +/// +/// Panics if shared-mode slot JSON is absent after confirming enabled configuration +/// and matching slots. The current serializer always returns it for those inputs. +#[cfg(feature = "test-utils")] +#[must_use] +pub fn fixture_ad_scripts( + settings: &Settings, + request_path: &str, + mode: AssemblyMode, + bids: &serde_json::Map, +) -> (Option, String) { + let Some(config) = settings.creative_opportunities.as_ref() else { + return (None, String::new()); + }; + if !config.enabled { + return (None, String::new()); + } + let slots = match_renderable_slots(&config.slot, config, request_path); + if slots.is_empty() { + return (None, String::new()); + } + let head = template_ad_slots_script(mode, true, settings, &slots, request_path); + let body = match mode { + AssemblyMode::Inline => build_bids_script(bids), + AssemblyMode::Esi => { + let slots_json = seam_ad_slots_json(mode, true, settings, &slots, request_path) + .expect("should generate shared fixture slot JSON"); + build_seam_script(&slots_json, bids) + } + }; + (head, body) +} + /// Build the empty-bids `".to_owned()); + held.dispatched_auction = Some(DispatchedAuction::empty_for_test( + test_auction_request(), + 10, + )); + let body = if streaming_body { + EdgeBody::stream(futures::stream::iter([bytes::Bytes::copy_from_slice(html)])) + } else { + EdgeBody::from(html.as_slice()) + }; + let mut output = Vec::new(); + stream_publisher_body_async( + body, + &mut output, + &mut held, + &settings, + ®istry, + &AuctionOrchestrator::new(settings.auction.clone()), + &noop_services(), + ) + .await + .expect("should retain CSP choice while collecting inline bids"); + outputs.push(output); + } + for output in outputs { + let document = String::from_utf8(output).expect("should produce HTML"); + assert!(document.contains("tsjs-unified.min.js?v=")); + assert!(!document.contains("id=\"trustedserver-bootstrap\"")); + } + } + #[test] fn stream_publisher_body_injects_active_diagnostics_for_materialized_html() { let mut settings = create_test_settings(); @@ -9286,6 +9425,9 @@ mod tests { }))); let config = HtmlProcessorConfig { + enforced_csp: false, + slot_reservations: Vec::new(), + request_path: None, csp_nonce_observed: None, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -10290,6 +10432,7 @@ mod tests { /// branch that only runs when it is on. fn article_slot() -> crate::creative_opportunities::CreativeOpportunitySlot { crate::creative_opportunities::CreativeOpportunitySlot { + reservation: None, initialization: Default::default(), id: "test-slot".to_string(), gam_unit_path: None, @@ -14797,6 +14940,183 @@ mod tests { assert_eq!(values("cross-origin-embedder-policy"), ["require-corp"]); } + #[tokio::test] + async fn enforced_csp_origin_header_selects_sync_loading_independent_of_admission() { + for (policy_header, policy_value, enforced) in [ + (None, "", false), + ( + Some("content-security-policy-report-only"), + "script-src 'none'", + false, + ), + (Some("content-security-policy"), "", true), + (Some("content-security-policy"), "default-src *", true), + ( + Some("Content-Security-Policy"), + "script-src 'self' 'sha256-old-policy'", + true, + ), + ] { + for admitted in [false, true] { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let mut configured = settings_with_mode("inline"); + configured + .integrations + .insert_config("gpt", &serde_json::json!({"enabled": true})) + .expect("should enable otherwise deferred GPT profile"); + let settings = Arc::new(configured); + let services = services(Arc::clone(&stub), cache); + let mut headers = vec![("content-type", "text/html; charset=utf-8")]; + if let Some(name) = policy_header { + headers.push((name, policy_value)); + } + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + headers, + ); + let request = if admitted { + navigation_request() + } else { + prefetch_navigation_request() + }; + let response = run(&settings, &services, request).await; + if let Some(name) = policy_header { + assert_eq!( + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()), + Some(policy_value), + "should preserve the actual origin policy unchanged" + ); + } + let document = + String::from_utf8(body_of(response).await).expect("should produce HTML"); + assert_eq!( + document.contains("tsjs-unified.min.js?v="), + enforced, + "origin header presence should control loading, admitted={admitted} header={policy_header:?}" + ); + assert_eq!( + document.contains("id=\"trustedserver-bootstrap\""), + !enforced + ); + if !admitted { + assert!( + !document.contains(".adSlots=JSON.parse"), + "should not use a policy fallback to authorize ad payloads" + ); + } + } + } + } + + #[tokio::test] + async fn enforced_csp_loading_choice_survives_shared_template_cold_and_warm_paths() { + for finalizer in [Finalizer::Streaming, Finalizer::Buffered] { + for (policy_header, enforced) in [ + ("content-security-policy", true), + ("content-security-policy-report-only", false), + ] { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let mut configured = settings_with_mode("esi"); + configured + .integrations + .insert_config("gpt", &serde_json::json!({"enabled": true})) + .expect("should enable otherwise deferred GPT profile"); + let settings = Arc::new(configured); + let services = services(Arc::clone(&stub), Arc::clone(&cache)); + let policy = "script-src 'self' 'sha256-old-policy'"; + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", "public, max-age=300"), + (policy_header, policy), + ], + ); + for status in ["miss-stored", "hit"] { + let response = + run_via(&settings, &services, navigation_request(), finalizer).await; + assert_eq!( + response + .headers() + .get(HEADER_X_TS_TEMPLATE_CACHE) + .and_then(|value| value.to_str().ok()), + Some(status) + ); + assert_eq!( + response + .headers() + .get(policy_header) + .and_then(|value| value.to_str().ok()), + Some(policy), + "should replay the policy that selected the stored template tags" + ); + let document = String::from_utf8(body_of(response).await) + .expect("should produce HTML"); + assert_eq!(document.contains("tsjs-unified.min.js?v="), enforced); + assert_eq!( + document.contains("id=\"trustedserver-bootstrap\""), + !enforced + ); + } + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "warm policy handling must reuse the existing shared template" + ); + let entries = cache.entries.lock().expect("should lock cached entries"); + let entry = entries + .values() + .next() + .expect("should store shared template"); + let hit = build_template_assembly_params( + entry, + &settings, + "ts.example.com", + "https", + Default::default(), + AdBidsState::default(), + ); + assert_eq!( + hit.enforced_csp, enforced, + "should preserve origin enforcement in cached-template parameters too" + ); + } + } + } + + #[tokio::test] + async fn configured_enforced_csp_retains_sync_tags_in_shared_templates() { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let mut configured = settings_with_mode("esi"); + configured + .integrations + .insert_config("gpt", &serde_json::json!({"enabled": true})) + .expect("should enable otherwise deferred GPT profile"); + configured.response_headers.insert( + "CoNtEnT-SeCuRiTy-PoLiCy".to_owned(), + "script-src 'self'".to_owned(), + ); + let settings = Arc::new(configured); + let services = services(Arc::clone(&stub), cache); + queue_shareable_html(&stub); + for _ in 0..2 { + let response = run(&settings, &services, navigation_request()).await; + let document = + String::from_utf8(body_of(response).await).expect("should produce HTML"); + assert!(document.contains("tsjs-unified.min.js?v=")); + assert!(!document.contains("id=\"trustedserver-bootstrap\"")); + } + assert_eq!(stub.recorded_request_uris().len(), 1); + } + #[tokio::test] async fn nonce_bearing_csp_bypasses_the_shared_template_cache() { let stub = Arc::new(StubHttpClient::new()); @@ -16015,6 +16335,7 @@ mod tests { pub(super) fn slot() -> CreativeOpportunitySlot { CreativeOpportunitySlot { + reservation: None, initialization: Default::default(), id: "atf".to_string(), gam_unit_path: Some("/99999/example/home".to_string()), @@ -16058,6 +16379,109 @@ mod tests { settings } + #[test] + fn reservation_css_is_independent_of_admission_and_assembly_mode() { + let mut settings = settings_with_slots(); + let config = settings + .creative_opportunities + .as_mut() + .expect("should have slot config"); + config.slot[0].page_patterns = vec!["/news/*".to_owned()]; + config.slot[0].reservation = Some( + crate::creative_opportunities::CreativeOpportunityReservation { + element_id: "stable-space".to_owned(), + min_height: 250, + breakpoints: vec![ + crate::creative_opportunities::CreativeOpportunityReservationBreakpoint { + min_width: 1024, + min_height: 600, + }, + ], + }, + ); + let slots = config.slot.clone(); + let registry = IntegrationRegistry::empty_for_tests(); + let expected = ""; + for mode in [AssemblyMode::Inline, AssemblyMode::Esi] { + settings + .creative_opportunities + .as_mut() + .expect("should have config") + .assembly_mode = Some(mode); + for admitted in [false, true] { + for shared_template_authorized in [false, true] { + let head = template_ad_slots_script( + mode, + admitted, + &settings, + &slots, + "/news/article", + ); + let mut processor = + create_html_stream_processor(HtmlStreamProcessorParams { + enforced_csp: false, + request_path: "/news/article", + origin_host: "origin.example.com", + request_host: "publisher.example.com", + request_scheme: "https", + settings: &settings, + integration_registry: ®istry, + ad_slots_script: head, + ad_bids_state: Arc::new(Mutex::new(None)), + suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, + shared_template_authorized, + csp_nonce_observed: None, + deferred_inline_marker: None, + }) + .expect("should create reservation-aware HTML processor"); + let output = processor.process_chunk(b"
", true) + .expect("should transform reservation fixture"); + let output = String::from_utf8(output).expect("should produce HTML"); + assert!( + output.contains(expected), + "should preserve exact CSS across admission and assembly paths" + ); + assert_eq!(output.matches("data-ts-slot-reservations").count(), 1); + if !admitted { + assert!( + !output.contains(".adSlots=") && !output.contains(".bids="), + "should not authorize ad payloads merely because CSS exists" + ); + } + } + } + } + } + + #[test] + fn reservation_config_changes_template_fingerprint_not_slot_wire() { + let mut settings = settings_with_slots(); + let before = template_fingerprint(&settings); + let config = settings + .creative_opportunities + .as_mut() + .expect("should have slot config"); + let original = build_slot_json(&config.slot[0], config, "example"); + config.slot[0].reservation = Some( + crate::creative_opportunities::CreativeOpportunityReservation { + element_id: "stable-space".to_owned(), + min_height: 250, + breakpoints: Vec::new(), + }, + ); + assert_eq!( + build_slot_json(&config.slot[0], config, "example"), + original, + "should leave the browser slot contract unchanged" + ); + assert_ne!( + template_fingerprint(&settings), + before, + "should invalidate a shared template after a reservation-only configuration change" + ); + } + #[test] fn registered_initialization_uses_the_admitted_inline_and_shared_slot_wire() { let settings = settings_with_slots(); @@ -16569,6 +16993,7 @@ mod tests { fn article_slot() -> CreativeOpportunitySlot { CreativeOpportunitySlot { + reservation: None, initialization: Default::default(), id: "article-slot".to_string(), gam_unit_path: None, @@ -19241,6 +19666,77 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[test] + fn tsjs_dynamic_serves_exact_standalone_gpt_only_when_enabled() { + for enabled in [false, true] { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt", &serde_json::json!({ "enabled": enabled })) + .expect("should configure GPT"); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let hash = + trusted_server_js::single_module_hash("gpt").expect("should embed the GPT runtime"); + let request = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-gpt.min.js?v={hash}"), + ); + let response = + handle_tsjs_dynamic(&request, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle standalone GPT"); + if enabled { + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "should content-address the standalone runtime", + ); + let bytes = response + .into_body() + .into_bytes() + .expect("should serve buffered JavaScript"); + assert_eq!( + bytes.as_ref(), + trusted_server_js::module_bundle("gpt") + .expect("should embed GPT") + .as_bytes(), + "should serve only the exact GPT IIFE, without core", + ); + } else { + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + } + } + + #[test] + fn tsjs_dynamic_does_not_expose_inline_bootstrap_as_an_integration() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt", &serde_json::json!({ "enabled": true })) + .expect("should enable GPT"); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + for module in ["bootstrap", "core", "creative"] { + let request = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-{module}.min.js"), + ); + let response = + handle_tsjs_dynamic(&request, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle standalone module request"); + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "should not expose {module}" + ); + } + } + #[test] fn tsjs_dynamic_serves_diagnostics_standalone_without_cookie_variance() { let mut settings = create_test_settings(); @@ -19564,6 +20060,8 @@ mod tests { let body = EdgeBody::from(compressed); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -19623,6 +20121,8 @@ mod tests { .expect("should create integration registry"); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -19671,6 +20171,8 @@ mod tests { ) .expect("should create integration registry"); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -19797,6 +20299,8 @@ mod tests { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -19861,6 +20365,8 @@ mod tests { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -19928,6 +20434,8 @@ mod tests { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -19995,6 +20503,8 @@ mod tests { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -20062,6 +20572,8 @@ mod tests { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -20111,6 +20623,8 @@ mod tests { fn non_html_stream_params(content_encoding: &str) -> OwnedProcessResponseParams { OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -20328,6 +20842,8 @@ mod tests { let services = noop_services(); let state = AdBidsState::default(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -20403,6 +20919,8 @@ mod tests { let services = noop_services(); let state = AdBidsState::default(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -20480,6 +20998,8 @@ mod tests { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -20551,6 +21071,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build response"); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -20704,6 +21226,8 @@ mod tests { dispatched_auction: Option, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -21385,6 +21909,8 @@ mod tests { let ec_context = EcContext::new_for_test(None, crate::consent::types::ConsentContext::default()); OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -21580,6 +22106,8 @@ mod tests { .map(bytes::Bytes::copy_from_slice) .collect(); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -21661,6 +22189,8 @@ mod tests { r#""#; let state = AdBidsState::with_script(bids_script); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -21725,6 +22255,8 @@ mod tests { // Claim gzip encoding but feed non-gzip bytes. The GzDecoder will // error as soon as it tries to read the gzip header. let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -21843,6 +22375,8 @@ mod tests { let body = EdgeBody::from(html.to_vec()); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -21910,6 +22444,8 @@ mod tests { // Small, single-fragment RSC script — placeholder path (not fallback). let html = br#""#; let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), csp_nonce_observed: None, template_cache_key: None, seam_ad_slots: None, @@ -22000,6 +22536,7 @@ mod tests { fn make_slot() -> CreativeOpportunitySlot { CreativeOpportunitySlot { + reservation: None, initialization: Default::default(), id: "atf_sidebar_ad".to_string(), gam_unit_path: Some("/21765378893/publisher/atf-sidebar".to_string()), @@ -23757,6 +24294,7 @@ mod tests { fn article_slot() -> Vec { vec![CreativeOpportunitySlot { + reservation: None, initialization: Default::default(), id: "atf".to_string(), gam_unit_path: None, @@ -24804,6 +25342,7 @@ mod tests { fn article_slot() -> Vec { vec![CreativeOpportunitySlot { + reservation: None, initialization: Default::default(), id: "atf".to_string(), gam_unit_path: None, diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index e8275b8144..d6c10b5e0d 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -1,26 +1,45 @@ -use trusted_server_js::{concatenated_hash, single_module_hash}; +use trusted_server_js::{concatenated_hash, module_bundle, single_module_hash}; -/// `/static` URL for the tsjs bundle with cache-busting hash based on -/// the concatenated content of the given module set. +use crate::integrations::IntegrationRegistry; + +/// Publisher runtime tags, preserving synchronous delivery for early dependencies. +/// +/// GPT-only pages install the real early APIs inline and defer the standalone +/// GPT runtime. Other integrations keep their established execution order until +/// their early dependencies have equivalent browser coverage. Enforced response +/// CSP or active diagnostics also requires the established synchronous path. #[must_use] -pub fn tsjs_script_src(module_ids: &[&str]) -> String { - let hash = concatenated_hash(module_ids); - format!("/static/tsjs=tsjs-unified.min.js?v={hash}") +pub(crate) fn publisher_script_tags( + integrations: &IntegrationRegistry, + synchronous_required: bool, +) -> String { + let attributes = integrations.tsjs_script_tag_attributes(); + if !supports_deferred_gpt(&integrations.js_module_ids(), synchronous_required) { + return tsjs_script_tag_with_attributes( + &integrations.js_module_ids_immediate(), + &attributes, + ); + } + + let bootstrap = module_bundle("bootstrap").expect("should embed the early publisher bootstrap"); + let attributes = trusted_script_attributes(&attributes); + format!( + "\ + ", + tsjs_single_module_script_src("gpt"), + ) } -/// `", @@ -121,6 +163,66 @@ mod tests { ); } + #[test] + fn deferred_gpt_requires_a_proven_profile_without_active_diagnostics() { + assert!(supports_deferred_gpt(&["creative", "gpt"], false)); + assert!(supports_deferred_gpt(&["creative", "gpt", "prebid"], false)); + assert!(!supports_deferred_gpt(&["creative"], false)); + assert!(!supports_deferred_gpt(&["creative", "gpt"], true)); + for blocked in [ + "datadome", + "google_tag_manager", + "lockr", + "permutive", + "didomi", + "sourcepoint", + "osano", + "testlight", + "future_integration", + ] { + assert!( + !supports_deferred_gpt(&["creative", "gpt", blocked], false), + "should retain synchronous loading for {blocked}", + ); + } + } + + #[test] + fn synchronous_loading_requirement_preserves_the_existing_publisher_tag() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .integrations + .insert_config("gpt", &serde_json::json!({"enabled": true})) + .expect("should enable GPT"); + let registry = IntegrationRegistry::new(&settings).expect("should build GPT registry"); + assert_eq!( + publisher_script_tags(®istry, true), + tsjs_script_tag_with_attributes( + ®istry.js_module_ids_immediate(), + ®istry.tsjs_script_tag_attributes(), + ), + "should retain the exact synchronous tag instead of introducing a CSP hash requirement", + ); + assert!( + publisher_script_tags(®istry, false).contains("id=\"trustedserver-bootstrap\""), + "should still defer a supported profile without a synchronous requirement" + ); + } + + #[test] + fn compiled_inline_bootstrap_cannot_terminate_its_script_element() { + let script = + module_bundle("bootstrap").expect("should embed the early publisher bootstrap"); + assert!( + !script.to_ascii_lowercase().contains("]*\bdata-ts-slot-reservations\b[^>]*>[\s\S]*?<\/style>/gi, + ) ?? [] + ); +} + +function withoutEarlyRenderer(html) { + return html.replace( + /]*\bid=["']trustedserver-bootstrap["'][^>]*>[\s\S]*?<\/script>/gi, + "", + ); +} + +function rendererAsset(pathname) { + return /^\/static\/tsjs=tsjs-(?:gpt|unified)(?:\.min)?\.js$/.test(pathname); +} + +module.exports = { reservationStyles, withoutEarlyRenderer, rendererAsset }; diff --git a/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs new file mode 100644 index 0000000000..327b71fd14 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs @@ -0,0 +1,37 @@ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { + reservationStyles, + withoutEarlyRenderer, + rendererAsset, +} = require("./html.cjs"); + +test("renderer absence removes compiled early code but retains raw protocol bootstrap", () => { + const raw = + ""; + const runtime = + ''; + const early = + ''; + assert.equal(withoutEarlyRenderer(raw + early + runtime), raw + runtime); + assert.equal( + withoutEarlyRenderer(raw + early.toUpperCase() + runtime), + raw + runtime, + ); + for (const name of ["gpt", "unified"]) { + assert(rendererAsset(`/static/tsjs=tsjs-${name}.min.js`)); + assert(rendererAsset(`/static/tsjs=tsjs-${name}.js`)); + } + assert(!rendererAsset("/static/tsjs=tsjs-prebid.min.js")); +}); + +test("CSS-only control copies only the exact production reservation stylesheet", () => { + const css = + ''; + const html = + "" + + css + + ''; + assert.deepEqual(reservationStyles(html), [css]); + assert.deepEqual(reservationStyles(""), []); +}); diff --git a/crates/trusted-server-integration-tests/browser/cwv/metrics.cjs b/crates/trusted-server-integration-tests/browser/cwv/metrics.cjs new file mode 100644 index 0000000000..9ee58cbb5a --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/metrics.cjs @@ -0,0 +1,145 @@ +// Chromium layout-shift entries use navigation-relative milliseconds. +function sessionCls(entries) { + let maximum = 0; + let value = 0; + let first = 0; + let previous = 0; + for (const entry of entries) { + if (entry.hadRecentInput) continue; + if ( + value && + entry.startTime - previous < 1000 && + entry.startTime - first < 5000 + ) { + value += entry.value; + } else { + value = entry.value; + first = entry.startTime; + } + previous = entry.startTime; + maximum = Math.max(maximum, value); + } + return maximum; +} + +function distribution(values) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (!sorted.length) return { count: 0, median: null, min: null, max: null }; + const middle = Math.floor(sorted.length / 2); + return { + count: sorted.length, + median: + sorted.length % 2 + ? sorted[middle] + : (sorted[middle - 1] + sorted[middle]) / 2, + min: sorted[0], + max: sorted.at(-1), + }; +} + +function observePage() { + const state = (window.cwv = { + supported: PerformanceObserver.supportedEntryTypes, + paint: [], + lcp: [], + shifts: [], + resources: [], + geometry: [], + firstVisibleEstimateMs: null, + delivered: false, + errors: [], + cspViolations: [], + }); + const observers = []; + const identifier = (node) => node?.id || node?.tagName || null; + const record = (type, entries) => { + for (const entry of entries) { + if (type === "paint") state.paint.push(entry.toJSON()); + if (type === "resource") state.resources.push(entry.toJSON()); + if (type === "largest-contentful-paint") { + state.lcp.push({ + ...entry.toJSON(), + element: identifier(entry.element), + }); + } + if (type === "layout-shift") { + state.shifts.push({ + startTime: entry.startTime, + value: entry.value, + hadRecentInput: entry.hadRecentInput, + sources: (entry.sources || []).map((source) => ({ + node: identifier(source.node), + previousRect: source.previousRect.toJSON(), + currentRect: source.currentRect.toJSON(), + })), + }); + } + } + }; + for (const type of [ + "paint", + "largest-contentful-paint", + "layout-shift", + "resource", + ]) { + if (!state.supported.includes(type)) continue; + const observer = new PerformanceObserver((list) => + record(type, list.getEntries()), + ); + observer.observe({ type, buffered: true }); + observers.push({ type, observer }); + } + state.geometrySnapshot = (phase) => { + const wrapper = document.getElementById("fixture-ad-space"); + const article = document.getElementById("article"); + state.geometry.push({ + phase, + time: performance.now(), + wrapper: wrapper?.getBoundingClientRect().toJSON() ?? null, + minHeight: wrapper ? getComputedStyle(wrapper).minHeight : null, + article: article?.getBoundingClientRect().toJSON() ?? null, + }); + }; + state.finish = () => { + for (const { type, observer } of observers) + record(type, observer.takeRecords()); + state.geometrySnapshot("finished"); + return { + ...state, + navigation: performance + .getEntriesByType("navigation") + .map((entry) => entry.toJSON()), + observationEndMs: performance.now(), + fixture: window.fixture && { + requests: fixture.requests, + trace: fixture.trace, + creatives: fixture.creatives, + }, + early: window.fixtureEarly, + registration: window.fixtureHandle?.getStatus(), + rendererVersion: window.tsjs?.tsRenderBridgeVersion, + candidateAdId: window.tsjs?.bids?.["fixture-header"]?.hb_adid, + registeredNative: window.fixturePublisherSlot + ? fixture.nativeSlots.indexOf(window.fixturePublisherSlot) + : null, + }; + }; + window.addEventListener("securitypolicyviolation", (event) => { + state.cspViolations.push({ + blockedURI: event.blockedURI, + violatedDirective: event.violatedDirective, + disposition: event.disposition, + }); + }); + window.addEventListener("error", (event) => { + if (event.message) state.errors.push(event.message); + }); + document.addEventListener("DOMContentLoaded", () => { + state.geometrySnapshot("parsed"); + requestAnimationFrame(() => + requestAnimationFrame(() => state.geometrySnapshot("first-frames")), + ); + }); +} + +module.exports = { sessionCls, distribution, observePage }; diff --git a/crates/trusted-server-integration-tests/browser/cwv/metrics.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/metrics.test.cjs new file mode 100644 index 0000000000..a37c995cea --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/metrics.test.cjs @@ -0,0 +1,53 @@ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { sessionCls, distribution } = require("./metrics.cjs"); +const shift = (startTime, value, hadRecentInput = false) => ({ + startTime, + value, + hadRecentInput, +}); + +test("CLS excludes recent input and selects the largest session, not the total", () => { + assert.equal( + sessionCls([ + shift(0, 0.1), + shift(500, 0.2), + shift(600, 9, true), + shift(1600, 0.4), + ]), + 0.4, + ); + assert.equal(sessionCls([]), 0); +}); +test("CLS starts a new session at the one-second gap", () => { + assert.equal( + sessionCls([shift(0, 0.1), shift(999, 0.2)]), + 0.30000000000000004, + ); + assert.equal(sessionCls([shift(0, 0.1), shift(1000, 0.2)]), 0.2); +}); +test("CLS session cannot extend beyond five seconds even with short gaps", () => { + const entries = Array.from({ length: 7 }, (_, i) => shift(i * 900, 0.1)); + assert(Math.abs(sessionCls(entries) - 0.6) < 1e-10); +}); +test("recent input does not bridge otherwise separated sessions", () => { + assert.equal( + sessionCls([shift(0, 0.1), shift(800, 9, true), shift(1500, 0.2)]), + 0.2, + ); +}); +test("distribution retains all finite samples, medians and ranges", () => { + assert.deepEqual(distribution([3, 1, 7, 5]), { + count: 4, + median: 4, + min: 1, + max: 7, + }); + assert.equal(distribution([3, 1, 2]).median, 2); + assert.deepEqual(distribution([null, undefined, NaN]), { + count: 0, + median: null, + min: null, + max: null, + }); +}); diff --git a/crates/trusted-server-integration-tests/browser/cwv/run.cjs b/crates/trusted-server-integration-tests/browser/cwv/run.cjs new file mode 100644 index 0000000000..2e0f7c6906 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/run.cjs @@ -0,0 +1,858 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const http = require("node:http"); +const crypto = require("node:crypto"); +const zlib = require("node:zlib"); +const assert = require("node:assert/strict"); +const { parseArgs } = require("node:util"); +const { chromium } = require("playwright"); +const { JSDOM } = require("jsdom"); +const { sessionCls, distribution, observePage } = require("./metrics.cjs"); +const { + reservationStyles, + withoutEarlyRenderer, + rendererAsset, +} = require("./html.cjs"); + +const { values: options } = parseArgs({ + options: { + baseline: { type: "string" }, + loading: { type: "string" }, + combined: { type: "string" }, + out: { type: "string" }, + runs: { type: "string", default: "5" }, + "correctness-only": { type: "boolean" }, + "regressions-only": { type: "boolean" }, + }, +}); +assert( + options.loading && options.combined && options.out, + "Required: --loading CHANGED_UNRESERVED --combined CHANGED_RESERVED --out DIR; --baseline enables four-way measurement", +); +assert( + options.baseline || + options["correctness-only"] || + options["regressions-only"], + "Measurement requires --baseline; CI may use --correctness-only", +); +const runs = Number(options.runs); +assert(Number.isInteger(runs) && runs > 0, "--runs must be a positive integer"); +const out = path.resolve(options.out); +fs.mkdirSync(out, { recursive: true }); +const save = (name, value) => + fs.writeFileSync(path.join(out, name), JSON.stringify(value, null, 2)); +const sha256 = (value) => + crypto.createHash("sha256").update(value).digest("hex"); +const sizes = (bytes) => ({ + raw: bytes.length, + gzip: zlib.gzipSync(bytes).length, + brotli: zlib.brotliCompressSync(bytes).length, +}); +const pucPath = require.resolve("prebid-universal-creative/dist/banner.js"); +const puc = fs.readFileSync(pucPath); +const fixturePath = path.join( + __dirname, + "../publisher-registration/gpt-fixture.js", +); +const fixtureSource = fs.readFileSync(fixturePath, "utf8"); + +function snapshot(directory, label) { + const root = path.resolve(directory); + const manifestBytes = fs.readFileSync(path.join(root, "manifest.json")); + const manifest = JSON.parse(manifestBytes); + assert.equal(manifest.schemaVersion, 1); + const retained = path.join(out, "inputs", label); + fs.mkdirSync(retained, { recursive: true }); + fs.writeFileSync(path.join(retained, "manifest.json"), manifestBytes); + const files = new Map(); + for (const filename of [ + ...manifest.documents.map((doc) => doc.file), + ...Object.values(manifest.assets), + "app.toml", + ]) { + const bytes = fs.readFileSync(path.join(root, filename)); + files.set(filename, bytes); + fs.mkdirSync(path.dirname(path.join(retained, filename)), { + recursive: true, + }); + fs.writeFileSync(path.join(retained, filename), bytes); + } + return { + root, + manifest, + files, + evidence: { + root, + manifestSha256: sha256(manifestBytes), + manifest, + files: Object.fromEntries( + [...files].map(([file, bytes]) => [ + file, + { sha256: sha256(bytes), ...sizes(bytes) }, + ]), + ), + }, + }; +} +const loading = snapshot(options.loading, "loading"); +const combined = snapshot(options.combined, "combined"); +const baseline = options.baseline && snapshot(options.baseline, "baseline"); +const profiles = { + loading, + combined, + ...(baseline ? { baseline, reservation: baseline } : {}), +}; +const evidence = { + node: process.version, + platform: process.platform, + architecture: process.arch, + command: process.argv, + startedAt: new Date().toISOString(), + settings: { + cpuThrottling: "none; shared host may run concurrent builds", + network: "loopback; runtime response held 750ms", + cache: "fresh contexts, no-store", + runs, + }, + inputs: { + loading: loading.evidence, + combined: combined.evidence, + ...(baseline ? { baseline: baseline.evidence } : {}), + }, + transport: { + fixtureSha256: sha256(fixtureSource), + pucSha256: sha256(puc), + pucVersion: require("prebid-universal-creative/package.json").version, + }, + runnerSha256: sha256(fs.readFileSync(__filename)), + metricsSha256: sha256(fs.readFileSync(path.join(__dirname, "metrics.cjs"))), + htmlSha256: sha256(fs.readFileSync(path.join(__dirname, "html.cjs"))), + interventions: [], + boundary: + "Controlled loopback lab fixture, simulated GPT and fictional bids. First-visible is an animation-frame/DOM estimate, not hardware paint, GAM impression time or field CWV.", +}; +save("artifacts.json", evidence); + +function documentInput(profile, name) { + const source = profiles[profile]; + const doc = source.manifest.documents.find((entry) => entry.name === name); + assert(doc, `Missing required exported document ${profile}/${name}`); + let html = source.files.get(doc.file).toString("utf8"); + if (profile === "reservation") { + const reservedDoc = combined.manifest.documents.find( + (entry) => entry.name === name, + ); + const styles = reservationStyles( + combined.files.get(reservedDoc.file).toString("utf8"), + ); + if (doc.matched && !doc.absentWrapper) + assert(styles.length, "Reserved export must supply production CSS"); + const css = styles.join(""); + html = html.replace("", "" + css); + evidence.interventions.push({ + profile, + name, + type: "CSS-only isolation derivative", + source: combined.root, + cssSha256: sha256(css), + htmlSha256: sha256(html), + }); + } + const dom = new JSDOM(html).window.document; + const runtime = dom.querySelector("script#trustedserver-js"); + assert(runtime?.src, `${profile}/${name} must have a production runtime tag`); + const runtimeSrc = runtime.getAttribute("src"); + const inline = [...dom.querySelectorAll("script:not([src])")] + .map((script) => script.textContent) + .join("\n"); + return { + ...doc, + html, + source, + runtimeSrc, + deferred: runtime.defer, + bytes: { + html: sizes(Buffer.from(html)), + inlineScript: sizes(Buffer.from(inline)), + inlineTags: [...dom.querySelectorAll("script:not([src])")].map( + (script, index) => ({ + id: script.id || `anonymous-${index}`, + ...sizes(Buffer.from(script.textContent)), + }), + ), + runtime: sizes(source.files.get(source.manifest.assets[runtimeSrc])), + }, + }; +} + +let active; +let publisherOrigin; +let creativeOrigin; +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +async function respond(request, response) { + const url = new URL(request.url, publisherOrigin); + const send = (type, bytes, status = 200) => { + response.writeHead(status, { + "Content-Type": type, + "Cache-Control": "no-store", + ...(url.pathname === active.input.requestPath + ? active.responseHeaders + : {}), + }); + response.end(bytes); + }; + if (url.pathname === "/fixture-puc.js") + return send("application/javascript", puc); + if (url.pathname === "/fixture-puc") + return send( + "text/html", + ``, + ); + if (url.pathname === "/fixture-publisher") + return send( + "text/html", + `
Publisher result
`, + ); + if (url.pathname === "/hero.svg") + return send( + "image/svg+xml", + 'Fictional article image', + ); + if ( + url.pathname === "/favicon.ico" || + url.pathname.startsWith("/fixture-notice/") + ) + return send("text/plain", "", 204); + if (request.url === active.input.runtimeSrc) { + active.runtimeAttempts++; + const retry = active.runtimeAttempts > 1; + if (!retry) await wait(750); + if (!retry && ["failed", "late-runtime"].includes(active.mode)) + return send("text/plain", "Deliberate fixture runtime failure", 404); + } + if (active.mode === "renderer-absent" && rendererAsset(url.pathname)) { + active.blockedRenderer.push(url.pathname); + return send("text/plain", "Deliberate fixture renderer absence", 404); + } + const asset = active.input.source.manifest.assets[request.url]; + if (asset) + return send("application/javascript", active.input.source.files.get(asset)); + if (url.pathname === active.input.requestPath) + return send("text/html", active.html); + active.unexpected.push(request.url); + return send("text/plain", "Undeclared fixture URL", 404); +} +const servers = []; +async function listen() { + const server = http.createServer((req, res) => + respond(req, res).catch((error) => { + console.error(error); + res.destroy(error); + }), + ); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return `http://127.0.0.1:${server.address().port}`; +} + +function installDelivery({ height, noFill }) { + const state = window.cwv; + const scheduled = new Set(); + const schedule = () => { + for (const request of fixture.requests) { + if (scheduled.has(request.index)) continue; + scheduled.add(request.index); + setTimeout(() => { + state.geometrySnapshot("before-fill"); + fixture.render(request.index, { noFill }); + const frame = document.querySelector( + `iframe[data-request="${request.index}"]`, + ); + if (frame) frame.height = String(height); + if (noFill) state.delivered = true; + }, 150); + } + }; + const service = googletag.pubads(); + for (const [object, name] of [ + [service, "refresh"], + [googletag, "display"], + ]) { + const original = object[name]; + object[name] = function (...args) { + const result = original.apply(this, args); + schedule(); + return result; + }; + } + window.addEventListener("message", (event) => { + if (event.data?.type !== "fixture-creative-ready") return; + requestAnimationFrame(() => { + const frame = document.querySelector("iframe[data-request]"); + const rect = frame?.getBoundingClientRect(); + if ( + rect && + rect.width > 0 && + rect.height > 0 && + rect.top < innerHeight && + rect.bottom > 0 + ) { + state.firstVisibleEstimateMs ??= performance.now(); + state.delivered = true; + state.geometrySnapshot("creative-visible"); + } + }); + }); +} + +const samples = []; +let stage = + options["correctness-only"] || options["regressions-only"] + ? "correctness" + : "measurement"; +async function sample( + browser, + profile, + name, + viewport, + mode = "slow", + noFill = false, +) { + const input = documentInput(profile, name); + assert.equal( + input.deferred, + !input.enforcedCsp && (profile === "loading" || profile === "combined"), + `${profile} must exercise its intended production loading mode`, + ); + const index = samples.length; + const label = `${index}-${profile}-${name}-${viewport.width}-${mode}${noFill ? "-nofill" : ""}`; + active = { + input, + mode, + runtimeAttempts: 0, + unexpected: [], + blockedRenderer: [], + responseHeaders: {}, + html: input.html, + }; + if (input.enforcedCsp) { + const legacy = baseline ?? input.source; + const legacyDoc = legacy.manifest.documents.find( + (doc) => doc.name === (baseline ? "inline" : "csp"), + ); + const legacyHtml = legacy.files.get(legacyDoc.file).toString("utf8"); + assert( + !legacyHtml.includes('id="trustedserver-bootstrap"'), + "CSP hashes must not come from the new compiled inline entry", + ); + const inline = [ + ...new JSDOM(legacyHtml).window.document.querySelectorAll( + "script:not([src])", + ), + ].map((script) => script.textContent); + const hashes = [ + ...inline, + ...(input.source.manifest.creativeInlineScripts ?? []), + ].map( + (script) => + `'sha256-${crypto.createHash("sha256").update(script).digest("base64")}'`, + ); + active.responseHeaders["Content-Security-Policy"] = + `script-src 'self' ${hashes.join(" ")}`; + evidence.interventions.push({ + profile, + name, + type: "enforced old-inline-hash and self CSP", + policySource: baseline + ? baseline.root + : "current synchronous legacy-inline output", + responseHeaders: active.responseHeaders, + }); + assert( + !input.html.includes('id="trustedserver-bootstrap"'), + "Enforced CSP must use synchronous production fallback", + ); + } + if (mode === "renderer-absent") { + active.html = withoutEarlyRenderer(input.html); + assert.notEqual( + active.html, + input.html, + "Renderer absence must remove the real compiled early entry", + ); + // This fixture-only probe calls core APIs deliberately absent in this case. + active.html = active.html.replace( + /]*>\s*window\.fixtureEarly=[\s\S]*?<\/script>/i, + "", + ); + assert( + active.html.includes("publisherSlotRegistrationVersion"), + "Raw protocol bootstrap must remain", + ); + evidence.interventions.push({ + profile, + name, + type: "true renderer absence, early entry and core probe removed; GPT/unified requests blocked", + htmlSha256: sha256(active.html), + }); + } + if (mode === "absent") { + active.html = input.html.replace( + /]*id="trustedserver-js"[^>]*><\/script>/i, + "", + ); + assert.notEqual( + active.html, + input.html, + "Absent-runtime control must remove the runtime tag", + ); + evidence.interventions.push({ + profile, + name, + type: "absent runtime tag control", + htmlSha256: sha256(active.html), + }); + } + const context = await browser.newContext({ + viewport, + serviceWorkers: "block", + }); + let traceStarted = false; + let result; + let page; + try { + await context.tracing.start({ + screenshots: true, + snapshots: true, + sources: true, + }); + traceStarted = true; + // One init script preserves setup order; Playwright does not order separate init scripts. + await context.addInitScript({ + content: `if(window===window.top){ + (${observePage.toString()})(); + window.publisherFixtureOptions={initialLoad:${JSON.stringify(input.registered ? "disabled" : "enabled")}}; + ${fixtureSource.replaceAll("https://creative.example.com", creativeOrigin)} + (${installDelivery.toString()})(${JSON.stringify({ height: input.creativeHeight, noFill })}); + }`, + }); + page = await context.newPage(); + const errors = []; + const network = []; + page.on("pageerror", (error) => errors.push(error.message)); + await context.route("**/*", (route) => { + const url = route.request().url(); + network.push({ url, method: route.request().method() }); + if ( + url.startsWith(publisherOrigin + "/") || + url.startsWith(creativeOrigin + "/") + ) + return route.continue(); + errors.push("Unexpected external request: " + url); + return route.abort(); + }); + await page.goto(publisherOrigin + input.requestPath, { + waitUntil: "load", + timeout: 15000, + }); + if (input.matched) + await page.waitForFunction(() => cwv.delivered, null, { timeout: 10000 }); + if (mode === "late-runtime") { + await page.evaluate( + (src) => + new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.onload = resolve; + script.onerror = reject; + document.head.append(script); + }), + input.runtimeSrc, + ); + } + // Multiple frames include fill-induced layout, then a declared quiet window. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + await wait(250); + const raw = await page.evaluate(() => cwv.finish()); + const cls = sessionCls(raw.shifts); + const fcp = raw.paint.find( + (entry) => entry.name === "first-contentful-paint", + )?.startTime; + const lcp = raw.lcp.at(-1)?.startTime; + for (const type of ["paint", "largest-contentful-paint", "layout-shift"]) + assert(raw.supported.includes(type), `Missing ${type} observer support`); + assert(Number.isFinite(fcp), "FCP must be observed"); + assert(Number.isFinite(lcp), "LCP must be observed"); + assert.equal(errors.length, 0, "No unexpected page or network errors"); + if (input.enforcedCsp) + assert.deepEqual( + raw.cspViolations, + [], + "Old-inline-hash/self CSP must permit synchronous fallback without violations", + ); + assert.deepEqual( + active.unexpected, + [], + "Every loopback URL must be declared", + ); + if (mode === "renderer-absent") { + assert.equal( + raw.rendererVersion, + undefined, + "No actual render bridge may remain installed", + ); + assert.equal( + raw.candidateAdId, + "fixture-server-ad", + "Absent renderer must not erase the admitted candidate", + ); + assert.deepEqual( + active.blockedRenderer, + [new URL(input.runtimeSrc, publisherOrigin).pathname], + "Actual standalone GPT renderer must be blocked", + ); + } else { + assert( + raw.early.core && raw.early.queued, + "Direct core API and queued publisher callback must work before deferred runtime", + ); + assert.equal( + raw.rendererVersion, + 1, + "Real renderer must remain installed when only deferred runtime fails", + ); + } + assert.equal( + raw.fixture.requests.length, + input.matched ? 1 : 0, + "Exactly one native initial request for a matched slot", + ); + if (input.matched) { + assert.equal( + raw.fixture.trace.filter((entry) => entry.event === "nativeDefine") + .length, + 1, + "One native slot definition must survive takeover", + ); + assert.equal( + raw.fixture.trace.filter((entry) => entry.event === "nativeDisplay") + .length, + 1, + "Initial delivery must display exactly once", + ); + if (input.registered) { + const refreshes = raw.fixture.trace.filter( + (entry) => entry.event === "nativeRefresh", + ); + assert.equal( + refreshes.length, + 1, + "Registered initial delivery must refresh exactly once", + ); + assert( + refreshes[0].scoped, + "Registered initial refresh must remain scoped", + ); + assert.deepEqual( + refreshes[0].natives, + [raw.registeredNative], + "Refresh must select only the publisher native object", + ); + assert.equal( + raw.fixture.requests[0].native, + raw.registeredNative, + "Registered delivery must use the publisher native object", + ); + assert.deepEqual( + raw.fixture.requests[0].targeting.publisher_key, + ["keep"], + "Publisher targeting must survive handoff", + ); + assert.equal( + raw.registration.status, + "rendered", + "Registration must settle through the real renderer", + ); + } + assert.equal( + raw.fixture.requests[0].targeting.hb_adid?.[0], + mode === "renderer-absent" ? undefined : "fixture-server-ad", + "Candidate targeting requires an actual renderer", + ); + if (!noFill) { + const text = await Promise.all( + page + .frames() + .map((frame) => frame.locator(".marker").allTextContents()), + ); + assert( + text + .flat() + .includes( + mode === "renderer-absent" ? "Publisher result" : "Server result", + ), + "Actual creative iframe DOM must contain server creative", + ); + assert( + Number.isFinite(raw.firstVisibleEstimateMs), + "Visible ad estimate must be observed", + ); + } + } + const resources = raw.resources.filter( + (entry) => + new URL(entry.name).pathname === + new URL(input.runtimeSrc, publisherOrigin).pathname, + ); + const responseEnd = resources[0]?.responseEnd; + if (mode === "slow") { + assert( + Number.isFinite(responseEnd), + "Runtime resource timing must exist", + ); + if (input.deferred) + assert( + fcp < responseEnd, + "Changed content must paint while runtime response is held", + ); + else + assert( + fcp >= responseEnd, + "Baseline must exercise parser-blocking runtime", + ); + } + const before = raw.geometry.find((entry) => entry.phase === "before-fill"); + const after = raw.geometry.at(-1); + const expectedHeight = viewport.width >= 1024 ? 600 : 250; + const reserved = + ["combined", "reservation"].includes(profile) && + input.matched && + !input.absentWrapper; + if (reserved) { + assert.equal( + Number.parseFloat(after.minHeight), + expectedHeight, + "Responsive reservation must match viewport", + ); + if (noFill || input.creativeHeight <= expectedHeight) { + assert.equal( + after.article.top, + before.article.top, + "Reserved article must not move during compatible fill or no-fill", + ); + assert.equal( + cls, + 0, + "Compatible reservation must prevent controlled ad-induced CLS", + ); + } + } else if (input.absentWrapper) { + assert.equal( + after.wrapper, + null, + "Absent configured wrapper must not be synthesized", + ); + } else if (after.wrapper) { + assert.equal( + after.minHeight, + "0px", + "Unmatched and unreserved wrappers must remain untouched", + ); + } + result = { + index, + stage, + profile, + name, + viewport, + mode, + noFill, + bytes: input.bytes, + metrics: { + fcp, + lcp, + cls, + domInteractive: raw.navigation[0].domInteractive, + domContentLoaded: raw.navigation[0].domContentLoadedEventEnd, + requestMs: + raw.fixture.trace.find((entry) => entry.event === "nativeRequest") + ?.time ?? null, + firstVisibleEstimateMs: raw.firstVisibleEstimateMs, + runtimeResponseEnd: responseEnd ?? null, + reservationHeight: after.wrapper?.height ?? null, + whitespace: after.wrapper + ? Math.max( + 0, + after.wrapper.height - (noFill ? 0 : input.creativeHeight), + ) + : null, + }, + raw, + errors, + network, + responseHeaders: active.responseHeaders, + blockedRenderer: active.blockedRenderer, + }; + samples.push(result); + save("samples.json", samples); + console.log(JSON.stringify({ label, ...result.metrics })); + await page.screenshot({ + path: path.join(out, label + ".png"), + fullPage: true, + }); + return result; + } catch (error) { + await page + ?.screenshot({ + path: path.join(out, label + "-failure.png"), + fullPage: true, + }) + .catch(() => {}); + save(label + "-failure.json", { + error: error.stack, + sample: result, + raw: await page?.evaluate(() => window.cwv?.finish()).catch(() => null), + server: { + runtimeAttempts: active.runtimeAttempts, + unexpected: active.unexpected, + }, + }); + throw error; + } finally { + if (traceStarted) + await context.tracing.stop({ + path: path.join(out, label + "-trace.zip"), + }); + await context.close(); + } +} + +(async () => { + publisherOrigin = await listen(); + creativeOrigin = await listen(); + let browser; + try { + browser = await chromium.launch({ headless: true }); + evidence.browser = browser.version(); + evidence.origins = { publisherOrigin, creativeOrigin }; + const mobile = { width: 390, height: 844 }; + const desktop = { width: 1280, height: 1000 }; + if (!options["correctness-only"] && !options["regressions-only"]) { + for (const viewport of [mobile, desktop]) { + for (let run = 0; run < runs; run++) { + const order = ["baseline", "loading", "reservation", "combined"]; + if (run % 2) order.reverse(); + for (const profile of order) + await sample(browser, profile, "inline", viewport); + } + } + const shiftControls = samples.filter((entry) => + ["baseline", "loading"].includes(entry.profile), + ); + assert( + shiftControls.every((entry) => entry.metrics.cls > 0), + "Unreserved controls must visibly reproduce ad-induced layout shift", + ); + } + stage = "correctness"; + if (options["correctness-only"]) { + for (const viewport of [mobile, desktop]) { + const control = await sample(browser, "loading", "inline", viewport); + assert( + control.metrics.cls > 0, + "Unreserved control must visibly shift after fill", + ); + } + } + await sample(browser, "combined", "registered", mobile, "renderer-absent"); + await sample(browser, "combined", "registered", mobile, "failed"); + await sample(browser, "combined", "csp", mobile); + if (!options["regressions-only"]) { + for (const mode of ["slow", "failed", "absent", "late-runtime"]) { + await sample(browser, "combined", "inline", mobile, mode); + await sample(browser, "combined", "registered", mobile, mode); + } + for (const name of [ + "shared", + "absent-wrapper", + "unmatched", + "child-replacement", + "unknown-height", + ]) + await sample(browser, "combined", name, mobile); + await sample(browser, "combined", "shared", desktop); + await sample(browser, "loading", "unknown-height", mobile); + for (const width of [1023, 1024]) + await sample(browser, "combined", "tall", { width, height: 1000 }); + await sample(browser, "combined", "inline", mobile, "slow", true); + } + const groups = {}; + for (const entry of samples) { + const key = `${entry.stage}/${entry.profile}/${entry.name}/${entry.viewport.width}/${entry.mode}/${entry.noFill ? "nofill" : "fill"}`; + (groups[key] ??= []).push(entry.metrics); + } + const summary = Object.fromEntries( + Object.entries(groups).map(([key, entries]) => [ + key, + Object.fromEntries( + Object.keys(entries[0]).map((metric) => [ + metric, + distribution(entries.map((entry) => entry[metric])), + ]), + ), + ]), + ); + save("summary.json", summary); + const comparisons = {}; + if (baseline) + for (const viewport of [mobile, desktop]) { + const reference = + summary[`measurement/baseline/inline/${viewport.width}/slow/fill`]; + for (const profile of ["loading", "reservation", "combined"]) { + const changed = + summary[ + `measurement/${profile}/inline/${viewport.width}/slow/fill` + ]; + if (!reference || !changed) continue; + comparisons[`${profile}/${viewport.width}`] = Object.fromEntries( + Object.keys(reference).map((metric) => { + const before = reference[metric].median; + const after = changed[metric].median; + return [ + metric, + { + baseline: before, + changed: after, + delta: + before === null || after === null ? null : after - before, + percent: + before === null || after === null || before === 0 + ? null + : ((after - before) / before) * 100, + }, + ]; + }), + ); + } + } + save("comparisons.json", comparisons); + console.log( + "CWV functional contracts passed; raw timings and ranges retained, no timing-improvement threshold applied.", + ); + } finally { + evidence.finishedAt = new Date().toISOString(); + save("artifacts.json", evidence); + await browser?.close(); + await Promise.all( + servers.map((server) => new Promise((resolve) => server.close(resolve))), + ); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/crates/trusted-server-integration-tests/browser/package.json b/crates/trusted-server-integration-tests/browser/package.json index f3e9acd882..7c03b5d7b3 100644 --- a/crates/trusted-server-integration-tests/browser/package.json +++ b/crates/trusted-server-integration-tests/browser/package.json @@ -6,7 +6,9 @@ "test": "npx playwright test", "test:nextjs": "TEST_FRAMEWORK=nextjs npx playwright test", "test:wordpress": "TEST_FRAMEWORK=wordpress npx playwright test", - "test:publisher-registration:fixture": "node --test publisher-registration/pages.test.cjs", + "test:publisher-registration:fixture": "node --test publisher-registration/*.test.cjs", + "test:cwv:unit": "node --test cwv/*.test.cjs", + "test:cwv": "node cwv/run.cjs", "test:publisher-registration": "node publisher-registration/run.cjs --implementation all", "test:publisher-registration:framework": "../../../scripts/test-publisher-registration.sh" }, diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/gpt-fixture.js b/crates/trusted-server-integration-tests/browser/publisher-registration/gpt-fixture.js index e6b9ce8174..dfc706a3ee 100644 --- a/crates/trusted-server-integration-tests/browser/publisher-registration/gpt-fixture.js +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/gpt-fixture.js @@ -263,6 +263,9 @@ const frame = document.createElement("iframe"); frame.width = "300"; frame.height = "250"; + // Model the creative box, not the browser\'s default iframe border and baseline gap. + frame.style.display = "block"; + frame.style.border = "0"; frame.dataset.request = String(index); frame.src = adId ? "https://creative.example.com/fixture-puc?adId=" + diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/launcher.test.cjs b/crates/trusted-server-integration-tests/browser/publisher-registration/launcher.test.cjs new file mode 100644 index 0000000000..44684037cf --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/launcher.test.cjs @@ -0,0 +1,55 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { test } = require("node:test"); + +const launcher = fs.readFileSync( + path.resolve(__dirname, "../../../../scripts/test-publisher-registration.sh"), + "utf8", +); +const preflight = launcher.match( + /if \[\[ "\$MODE" != prepare \]\]; then\n[\s\S]*?\nfi/, +); +assert.ok( + preflight, + "the test must execute the actual launcher runtime preflight", +); + +for (const pin of ["0.17.0", "0.21.0"]) { + test(`runtime preflight follows checkout pin ${pin}`, (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "publisher-registration-pin-"), + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync( + path.join(root, ".tool-versions"), + `nodejs 24.12.0\nviceroy ${pin}\n`, + ); + const check = (actual, mode = "framework") => + spawnSync( + "bash", + [ + "-c", + `set -eu\nviceroy() { printf '%s\\n' "$ACTUAL"; }\n${preflight[0]}`, + ], + { + encoding: "utf8", + env: { ...process.env, ROOT: root, MODE: mode, ACTUAL: actual }, + }, + ); + assert.equal(check(`viceroy ${pin}`).status, 0); + const mismatch = check("viceroy 0.0.0"); + assert.equal(mismatch.status, 1); + assert.match( + mismatch.stderr, + new RegExp(`Pinned Viceroy ${pin.replaceAll(".", "\\.")} required`), + ); + assert.equal( + check("viceroy 0.0.0", "prepare").status, + 0, + "preparation needs no runtime", + ); + }); +} diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs b/crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs index a3df5a9705..3dd8f2fa3b 100644 --- a/crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs @@ -2,6 +2,7 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const vm = require("node:vm"); const test = require("node:test"); +const { JSDOM } = require("jsdom"); const { inlineJson, documentPage, @@ -45,24 +46,33 @@ test("inline JSON cannot terminate its script, including line separators", () => }; assert.deepEqual(JSON.parse(inlineJson(input)), input); assert(!/[<>&\u2028\u2029]/u.test(inlineJson(input))); - const html = documentPage(input); - assert.equal((html.match(/', + ]) { + const html = documentPage({ id }); + assert.equal((html.match(/&'); - assert(html.includes("</div><script>")); - assert.equal((html.match(/&"; const html = pucPage(origin); - assert.equal((html.match(/"# + ) +} + +fn origin_page(absent: bool, replacement: bool, registered: bool) -> String { + let slot = if absent { + "
" + } else { + "
" + }; + let replacement = if replacement { + "document.getElementById('fixture-ad-space').innerHTML='
';" + } else { + "" + }; + let registration = if registered { + "googletag.cmd.push(function(){googletag.setConfig({disableInitialLoad:true});googletag.enableServices();var s=googletag.defineSlot('/123456789/fixture-header',[[300,250]],'fixture-ad').addService(googletag.pubads());s.setTargeting('publisher_key','keep');window.fixturePublisherSlot=s;window.fixtureHandle=tsjs.registerPublisherSlot({slotId:'fixture-header',gptSlot:s,navigationGeneration:tsjs.navGeneration||0,eligibleFormats:[[300,250]]});});" + } else { + "" + }; + format!( + r#"Fictional CWV article

Fictional article

{slot}
Fictional landscape

Readable article content

This fixed fictional article makes ad-space movement visible.

"# + ) +} + +fn main() -> Result<(), FixtureError> { + let args: Vec = env::args().skip(1).collect(); + if args.is_empty() || args.len() > 2 { + return Err("usage: cwv_html OUTPUT [unreserved|reserved]".into()); + } + let reserved = match args.get(1).map(String::as_str).unwrap_or("unreserved") { + "unreserved" => false, + "reserved" => true, + _ => return Err("reservation must be unreserved or reserved".into()), + }; + let out = Path::new(&args[0]); + fs::create_dir_all(out.join("assets"))?; + let config_source = format!("{CONFIG}{}", if reserved { RESERVATION } else { "" }); + let settings = Settings::from_toml(&config_source)?; + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new(trusted_server_core::auction::compile_auction_plan( + &settings, + )?), + )?; + fs::write(out.join("app.toml"), config_source)?; + let selector = Selector::parse("script[src]").expect("should parse a static script selector"); + let mut assets = BTreeMap::::new(); + let mut documents = Vec::::new(); + for (name, path, shared, absent, replacement, height) in [ + ("inline", "/news/article", false, false, false, 250), + ("csp", "/news/article", false, false, false, 250), + ("shared", "/news/article", true, false, false, 250), + ("registered", "/news/article", false, false, false, 250), + ("tall", "/news/article", false, false, false, 600), + ("unknown-height", "/news/article", false, false, false, 420), + ("absent-wrapper", "/news/article", false, true, false, 250), + ("unmatched", "/other/article", false, false, false, 250), + ( + "child-replacement", + "/news/article", + false, + false, + true, + 250, + ), + ] { + let mut bids = json!({"fixture-header": { + "hb_adid":"fixture-server-ad", "hb_bidder":"fixtureServer", "hb_pb":"1.00", + "w":300, "h":height, "adm":creative(height) + }}); + if name == "unknown-height" { + let bid = bids["fixture-header"] + .as_object_mut() + .expect("should construct a bid"); + bid.remove("w"); + bid.remove("h"); + } + let mut page_settings = settings.clone(); + if name == "registered" { + page_settings + .creative_opportunities + .as_mut() + .expect("should configure slots") + .slot[0] + .initialization = CreativeOpportunityInitialization::PublisherRegistered; + } + let (slots, body_script) = fixture_ad_scripts( + &page_settings, + path, + if shared { + AssemblyMode::Esi + } else { + AssemblyMode::Inline + }, + bids.as_object() + .expect("should construct an object bid map"), + ); + let config = HtmlProcessorConfig::from_settings( + &page_settings, + ®istry, + "origin.example.com", + "publisher.example.com", + "https", + ) + .with_request_path(path) + .with_enforced_csp(name == "csp") + .with_ad_state(slots, Arc::new(Mutex::new(Some(body_script)))) + .with_body_close(BodyCloseInjection::InlineBids); + let bytes = create_html_processor(config).process_chunk( + origin_page(absent, replacement, name == "registered").as_bytes(), + true, + )?; + let html = String::from_utf8(bytes)?; + for script in Html::parse_document(&html).select(&selector) { + let src = script + .value() + .attr("src") + .expect("should select script src"); + if assets.contains_key(src) { + continue; + } + let request = Request::builder().uri(src).body(EdgeBody::empty())?; + let response = handle_tsjs_dynamic(&request, ®istry, EdgeCacheHeader::None)?; + if !response.status().is_success() { + return Err(format!( + "production JS route did not serve {src}: {}", + response.status() + ) + .into()); + } + let body = response + .into_body() + .into_bytes() + .ok_or("expected buffered JS body")?; + let filename = format!("assets/{}.js", assets.len()); + fs::write(out.join(&filename), body)?; + assets.insert(src.to_owned(), filename); + } + let filename = format!("{name}.html"); + fs::write(out.join(&filename), html)?; + documents.push(json!({ + "name": name, "file": filename, "requestPath": path, "shared": shared, + "absentWrapper": absent, "replacement": replacement, "creativeHeight": height, + "matched": path.starts_with("/news/"), "registered": name == "registered", + "enforcedCsp": name == "csp" + })); + } + let source_files = [ + "Cargo.lock", + "rust-toolchain.toml", + ".tool-versions", + "crates/trusted-server-core/src/html_processor.rs", + "crates/trusted-server-core/src/publisher.rs", + "crates/trusted-server-core/src/integrations/registry.rs", + "crates/trusted-server-core/src/creative_opportunities.rs", + "crates/trusted-server-core/src/tsjs.rs", + "crates/trusted-server-js/lib/build-module-options.mjs", + "crates/trusted-server-js/lib/build-all.mjs", + "crates/trusted-server-core/src/integrations/gpt_bootstrap.js", + "crates/trusted-server-integration-tests/examples/cwv_html.rs", + ]; + let manifest = json!({ + "schemaVersion": 1, "reserved": reserved, + "sourceRevision": env::var("CWV_SOURCE_REVISION").unwrap_or_else(|_| command(&["git", "rev-parse", "HEAD"])), + "sourceStatus": env::var("CWV_SOURCE_STATUS").unwrap_or_else(|_| command(&["git", "status", "--short"])), + "sourceMetadataOverrides": { + "revision": env::var("CWV_SOURCE_REVISION").ok(), + "status": env::var("CWV_SOURCE_STATUS").ok(), + "origin": "CWV_SOURCE_REVISION/CWV_SOURCE_STATUS if present, otherwise current git checkout" + }, + "jsSourceFiles": command(&["find", "crates/trusted-server-js/lib/src", "-type", "f"]), + "sourceFingerprints": command(&["sha256sum"].into_iter().chain(source_files).collect::>()), + "rustc": command(&["rustc", "-Vv"]), + "exporterSource": include_str!("cwv_html.rs"), + "assets": assets, "documents": documents, + "creativeInlineScripts": Html::parse_document(&creative(250)).select(&Selector::parse("script").expect("should parse script selector")).map(|script| script.inner_html()).collect::>(), + "boundary": "Real Rust HTML transform, registry, payload builders and JS serving. Fictional bid input and simulated GPT transport; no auction execution or real GAM." + }); + fs::write( + out.join("manifest.json"), + serde_json::to_vec_pretty(&manifest)?, + )?; + Ok(()) +} diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.publisher-registration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.publisher-registration.toml index 0b7b67db4c..cbfcabbe7d 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.publisher-registration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.publisher-registration.toml @@ -73,6 +73,14 @@ div_id = "fixture-header-" page_patterns = ["/publisher-registration", "/publisher-registration/*"] formats = [{ width = 300, height = 250 }, { width = 300, height = 600 }] +[creative_opportunities.slot.reservation] +element_id = "fixture-header-space" +min_height = 250 + +[[creative_opportunities.slot.reservation.breakpoints]] +min_width = 601 +min_height = 600 + [creative_opportunities.slot.providers.prebid.bidders.fixture-bidder] placement = "fixture-header" diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration/RegisteredSlot.tsx b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration/RegisteredSlot.tsx index 4c4d7bd3a9..d3bb91105a 100644 --- a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration/RegisteredSlot.tsx +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration/RegisteredSlot.tsx @@ -200,7 +200,7 @@ function RegisteredSlot({ id={id} ref={elementRef} data-testid="final-placement" - style={{ width: 300, minHeight: 250 }} + style={{ width: 300 }} /> ); } @@ -297,18 +297,20 @@ export default function RegisteredPublisher() { - {mounted ? ( - - ) : ( -
- Publisher has not registered -
- )} +
+ {mounted ? ( + + ) : ( +
+ Publisher has not registered +
+ )} +
diff --git a/crates/trusted-server-js/lib/build-module-options.mjs b/crates/trusted-server-js/lib/build-module-options.mjs index 64b78a3fb7..26b420b04b 100644 --- a/crates/trusted-server-js/lib/build-module-options.mjs +++ b/crates/trusted-server-js/lib/build-module-options.mjs @@ -8,6 +8,9 @@ export function moduleBuildOptions({ name, entryPath, outDir }) { return { configFile: false, root: libDir, + // The publisher bootstrap is embedded in HTML script text. Keep escaping + // enabled explicitly for strings such as and B[Compiled inline GPT guard, attribution and render bridge] + B --> C[Real core APIs and creative guards] + C --> D[Publisher scripts continue parsing] + D --> E[Deferred standalone GPT runtime] + E --> F[Deferred Prebid shim] +``` + +The early entry runs inline, without another synchronous external fetch. It installs the real render bridge before core flushes queued callbacks or publisher scripts can request an eligible creative. Core's direct methods and immediate queue behavior remain available before the runtime arrives. The deferred GPT entry does not initialize core again, so early configuration and registered ad units survive takeover. + +Any enforced `Content-Security-Policy` response header selects the original synchronous unified bundle instead of the new inline compiled entry. This includes headers from the actual origin response or `Settings.response_headers`, matched case-insensitively, even when empty, permissive or repeated. An origin response without CSP cannot cancel a configured policy. `Content-Security-Policy-Report-Only` alone does not select this fallback. Publishers with unchanged enforced response CSP therefore need no new compiled-script hash for this loading change: they keep the existing loading path. Policies that might permit deferral still use synchronous loading until separately evaluated. + +Shared templates retain the loading choice and policy headers selected on the cold response and replay them on warm hits. Settings and core fingerprints invalidate older templates. This fallback checks response headers only; no meta-policy fallback is promised. It does not weaken CSP, add nonce handling or introduce an operator flag. + +The existing inline GPT bootstrap remains responsible for provisional `adInit`, initial scheduling, publisher registration and navigation generations. The deferred runtime installs the richer initializer, publisher-slot handoff, SPA page-bids fetching and slim-Prebid loader. Both entries share the same guard and rendering implementations. A delayed or failed runtime does not remove the installed early renderer or authorize another initial request. + +This still executes synchronous JavaScript. The raw GPT bootstrap alone is 56,429 source bytes, before the compiled early entry. Compare parser/paint timing and ad-startup timing with repeatable measurements; deferral is not a guarantee of better field Core Web Vitals. + +The following combinations keep the original synchronous unified bundle. Unknown browser integrations also fall back rather than being silently deferred. + +| Integration | Early dependency retained by synchronous fallback | +| ---------------------- | ----------------------------------------------------------------------- | +| DataDome | Dynamic SDK script and preload interception | +| Google Tag Manager | Script interception plus fetch and beacon rewriting | +| Lockr | SDK interception and live API-host rewriting | +| Permutive | SDK interception, endpoint rewriting and auction context registration | +| Didomi | `didomiConfig.sdkPath` before the SDK reads it | +| Sourcepoint | SDK interception, runtime configuration rewriting and consent mirroring | +| Osano | Initial consent mirroring and consent-event listeners | +| Testlight | Immediate publisher queue behavior | +| Active GPT diagnostics | GPT observation before publisher scripts | +| Enforced response CSP | Existing script permissions and synchronous loading | + +Rust-only integrations do not add browser modules to this list. Pages without GPT keep synchronous unified loading. Prebid's external bundle and shim retain their existing deferred execution order. Trusted GAM attribution and APS rendering-mode attributes stay on both executing entries, including the inline early tag. ### Script Guard @@ -372,9 +412,11 @@ finite positive dimensions match both configured and declared eligible formats. Missing, unknown-size or incompatible bids still permit one ordinary GAM request. An empty finite eligibility list permits GAM delivery without TS demand. -The inline bootstrap has no creative renderer. Without an installed renderer, -registered delivery omits TS candidate targeting and correlation and makes the -single ordinary GAM request. GAM can fill it with a publisher creative or return +The raw inline GPT bootstrap has no creative renderer on its own. Supported +profiles also install the compiled early renderer before publisher scripts, so +registration can select eligible TS demand without waiting for the deferred +runtime. If no renderer was installed, registered delivery omits TS candidate +targeting and correlation and makes the single ordinary GAM request. GAM can fill it with a publisher creative or return no-fill. TS fill is deliberately unavailable on that path. A later runtime load does not add the excluded candidate, replace the ad or request it again. diff --git a/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md b/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md new file mode 100644 index 0000000000..5ce8dec5e9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md @@ -0,0 +1,84 @@ +# Issue 854 measurement, implementation and validation plan + +Status: Implementation and local verification complete. Authorized 2026-10-29. Baseline `c36c82df43c2f62a59444290f863f736b579ed33`. +Spec: [Client CWV completion](../specs/2026-10-29-854-client-cwv-completion-design.md). + +## Ownership and evidence + +One coordinator owns the combined result, Cargo commands and integration builds. Loading and reservation workers have disjoint source ownership; measurement owns its new fixture/exporter/runner. Avoid global formatting until writers finish. Read and verify returned changes directly. + +Local evidence directory: `/tmp/ts-854-cwv/evidence`. This location is temporary; summarize commands, exact hashes and results in the committed validation record. Never commit private browser captures or customer information. Fixtures use fictional example data. + +## 0. Baseline before behavior changes + +- [x] Record clean branch, PR checks, toolchain and dependency state. +- [x] Install pinned npm dependencies in JS/browser/docs roots. +- [x] Freeze exact baseline bundles and raw GPT bootstrap outside generated output. +- [x] Run baseline JS suite: 1,490 tests across 51 files, no type errors. +- [x] Inspect the existing failing publisher-registration GitHub job. +- [x] Archive baseline tracked sources. +- [x] Generate production HTML with the same fixture exporter and fixture-only access to existing private builders; archived baseline omits the new reservation-path setter. +- [x] Run baseline parser/paint/layout/request measurements before interpreting changed measurements. + +Original CI failure is a launcher literal requiring Viceroy 0.17.0 while the PR merge checkout's authoritative tool pin is 0.21.0. Local branch pin and local binary are still 0.17.0. Fix the launcher to read its checkout's pin and test matching/mismatching version behavior. Do not downgrade CI or remove pin validation. + +## 1. Implement and prove explicit reservation + +- Add optional typed config and validation, omitted-field compatibility and all necessary struct-literal migrations. +- Render escaped exact-ID min-height CSS with ordered breakpoint rules. +- Pass path separately through HTML processing on ordinary, streaming, buffered and shared-template paths. +- Tests: absent config, matching/nonmatching path, absent element, widths around boundaries, zero/unknown height, unsafe IDs, deterministic first-matching same-wrapper policy, invalid ordering, no-fill and shared-template visitor neutrality. +- Run focused native Rust tests plus template/stream neighbors. Run real browser geometry and CLS checks after the exporter is ready. +- Document publisher wrapper ownership and whitespace tradeoffs. + +## 2. Implement and prove dependency-safe deferral + +- Extract shared GPT shim/render bridge without changing semantics; preserve exports used by existing tests. +- Add compiled early entry and default supported-profile selection with explicit synchronous fallback. +- Keep original raw bootstrap, unified route/hash and unsupported profiles unchanged. +- Enable standalone GPT route only for enabled GPT; version and defer its tag. +- Preserve GAM/APS executing-script attributes and script-text safety. +- Tests: core direct APIs/queues before runtime, early dynamic script rewrite, early candidate rendering, delayed/failed runtime, takeover exactly once, pre-runtime navigation, stale payload exclusion, active diagnostics and blocked integration fallback. +- Preserve old working response-CSP policies with conservative synchronous fallback for any enforced origin or configured response header. Report-Only alone does not force fallback. Prove actual origin propagation and shared cold/warm behavior, not merely the tag builder. +- Run affected GPT/core/guard/APS tests before the full JS suite. + +## 3. Measurement implementation and comparison + +- Add a small native Rust exporter using the real HTML transform, registry and script generation. +- Export baseline and changed documents from their exact source trees. Include build fingerprints and served-byte equality. +- Add loopback Playwright runner with parser-created tags and deterministic fixture transport/creative behavior. +- Capture baseline, loading-only, reservation-only and combined repeated alternating runs on mobile/desktop. +- Preserve raw PerformanceObserver data, session-window CLS, FCP/LCP, DOM timings, request/targeting snapshots, first visible ad, whitespace and raw/gzip/Brotli artifact sizes. +- Run boundary/no-fill/unreserved/absent-slot/framework-replacement correctness cases independently from repeated timing runs. +- Summarize medians/ranges and regressions without invented pass thresholds or field-CWV claims. + +## Review checkpoint and regression fixes + +The first four-lane review reproduced a default loading regression under an old-hash-plus-same-origin CSP and found that the framework renderer-absence control intercepted only the old bundle URL. Fixes add conservative enforced-response-CSP fallback, true deliberate renderer removal and a distinct failed-runtime case retaining early rendering. Six focused Rust CSP checks and three focused actual-browser regressions passed. The final exporter produces byte-identical ordinary HTML/assets to the 58-case timing snapshot, so those passing measurements remain applicable. The stable post-fix checkpoint passed all 21 backend gates, 40 actual Rust/Next.js cases, 61 lab cases and 109 offline contracts. Exact commands, fingerprints and baseline limitations are in the validation record. Hosted CI remains pending after push; real GPT/GAM staging and field CWV remain unverified. + +Measured transfer cost is an explicit tradeoff: combined HTML/runtime gzip estimate grew by 7,513 bytes while the controlled parser wait and compatible ad-induced shift were removed. A runtime-only entry was evaluated and deferred because it adds another lifecycle contract; no total-byte reduction is claimed. + +## 4. Stable combined verification + +After runtime and fixture sources stop changing: + +1. Build the complete JS distribution once; fingerprint it. +2. Run full Vitest, type checks, lint, JS/build-script formatting and docs formatting. +3. Run offline publisher-registration contracts and existing initial-render browser scenarios against those exact bundles. +4. Run the production Rust HTML browser measurement path and inspect output, geometry and visible content. +5. Run actual Rust/Next.js registration integration with fresh current WASM and artifact provenance. +6. Run repository-required target-matched adapter tests/Clippy, parity, native build-digest test/lint and WASM clock lint. Serialize Cargo on one build directory. Run docs checks with supported feature combinations and disclose the repository's all-features platform constraint. +7. Investigate failures once against the frozen baseline when attribution is uncertain. Keep failed commands and excluded coverage in the validation record. +8. Request independent correctness/architecture review, verify findings, fix confirmed defects and rerun affected checks only. + +## 5. Staging and handoff + +Real GPT staging and production activation require separate approval. Record missing approval/environment as unverified, not as passing. Lab evidence cannot establish field CWV or customer ad-request effects. + +Update spec/plan status and a committed validation record with commands, environment, fingerprints, results and remaining limits. Update old registration documents to point at the expanded scope without rewriting historical evidence. Commit scoped changes, push the existing branch, and update PR1460's description. Report new hosted check status after the push and distinguish pending CI from completed local checks. + +## Related + +- Spec: [Client CWV completion](../specs/2026-10-29-854-client-cwv-completion-design.md). +- Validation: [Local outcomes and evidence limits](../reviews/2026-10-29-854-client-cwv-validation.md). +- Prior plan: [Publisher registration](2026-10-27-854-publisher-registered-initial-delivery-plan.md). diff --git a/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md b/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md new file mode 100644 index 0000000000..075493be7f --- /dev/null +++ b/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md @@ -0,0 +1,165 @@ +# Issue 854 client CWV validation + +Status: Implementation and local validation complete. Hosted checks pending after push. No deployment or production activation performed. + +[Spec](../specs/2026-10-29-854-client-cwv-completion-design.md) · [Plan](../plans/2026-10-29-854-client-cwv-completion-plan.md) + +## Baseline and environment + +Baseline commit: `c36c82df43c2f62a59444290f863f736b579ed33`, clean before this extension. Node 24.12.0 and Rust 1.95.0. Local Viceroy is 0.17.0, matching this branch's `.tool-versions`. The hosted PR merge currently uses newer main-branch pins, including Viceroy 0.21.0. + +Temporary logs, frozen artifacts and raw browser evidence are under `/tmp/ts-854-cwv/`. Baseline source was frozen with `git archive HEAD`, not copied from another working tree. Native fixture access to private production payload builders and the exporter are added only to that archive for measurement. The baseline exporter omits the new reservation path setter because baseline production code has no reservation feature. + +One coordinator owns all Cargo commands, using `CARGO_TARGET_DIR=/tmp/ts-854-cwv/target`, `CARGO_BUILD_JOBS=2`, `CARGO_PROFILE_DEV_DEBUG=0`, `CARGO_PROFILE_TEST_DEBUG=0`, and `CARGO_INCREMENTAL=0`. Builds explicitly select baseline or current bundles; private Rust build.rs output is never accepted solely by filename. + +## Baseline and preflight checks + +| Check | Tested state | Result | Evidence | +| ------------------------------------------------------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `npm ci` in JS, browser and docs roots | Baseline lockfiles | Passed | `evidence/npm-{js,browser,docs}.log` | +| `node build-all.mjs --out-dir /tmp/ts-854-cwv/baseline-dist` | Baseline JS | Passed, 13 modules | `evidence/baseline-build.log` | +| `npx vitest run --maxWorkers=2` | Baseline | 1,490 tests across 51 files passed, no type errors, 40.03 s | `evidence/baseline-js.log` | +| Launcher runtime preflight tests | Dynamic checkout pin fix | Both 0.17.0 and 0.21.0 accepted only when matching their checkout pin; mismatches rejected; preparation bypass retained | `browser/publisher-registration/launcher.test.cjs` | +| `bash -n scripts/test-publisher-registration.sh` | Dynamic checkout pin fix | Passed | Coordinator command | +| `node --test browser/cwv/metrics.test.cjs` | New metrics code | Five CLS/session-window/distribution tests passed | Committed tests; combined with launcher, seven tests passed | + +Paths beginning `browser/` above are under `crates/trusted-server-integration-tests/`. + +## Retained iteration failures + +The first native fixture compile used `IntegrationRegistry::new`, which exists only in unit-test builds. The fixture now uses the production `with_plan` constructor and compiled auction plan. This was fixture code, not a runtime defect. Log: `evidence/baseline-export-first-failure.log`. + +The next fixture execution exposed missing required request-signing store identifiers in its fictional configuration. The fixture was corrected, then baseline and current exports completed successfully. No failed export is represented as a measurement pass. + +## Existing hosted failure + +Original failing job: https://github.com/IABTechLab/trusted-server/actions/runs/40604604795/job/122545660578 + +The shared setup action reads `.tool-versions` and restored Viceroy 0.21.0. The publisher-registration launcher then required a literal 0.17.0 and stopped before framework execution. The fix reads the authoritative pin from the checkout, preserving exact validation rather than weakening it. Local 0.17.0 success alone did not catch this merge-base drift. The separate original CodeQL check reported three high-severity "Bad HTML filtering regexp" annotations, all in test-only script counting/extraction. The regexes did not count uppercase script tags. Tests now use case-insensitive extraction and uppercase injection inputs. This strengthens the fixture assertions; no runtime injection defect is claimed, and the hosted CodeQL rerun is still required. + +## Combined JavaScript and native checks + +Full current Vitest passed 1,497 tests across 52 files in 35.95 s, with no errors in its configured type-test scope. Full JS ESLint and Prettier checks passed. Standalone `npx tsc --noEmit` is a separate failing baseline check: archived baseline produced 366 diagnostic lines, current produced 360 after test cleanup. No new-file diagnostics remain. This is not a claim that package-wide TypeScript compilation passes. + +The native core suite exposed two different issues. One attribution test searched for an attribute name anywhere in HTML and found the new inline program's attribute-reading code. It now checks the actual attribute value. Eleven Settings loader failures are pre-existing Cargo environment pollution: Cargo injects `TRUSTED_SERVER__GIT_VERSION` from build.rs into test execution, and the runtime Settings environment loader treats it as an unknown configuration field. The archived baseline reproduces `settings::tests::test_set_env` failing. Running the exact current test executables without that injected runtime variable passed 2,983 core tests plus both ten-test native integration suites. Logs: `evidence/current-core-native.log`, `baseline-core-env-failure.log`, `current-core-native-clean-env.log`, `current-core-native-final-clean-env.log` and `current-core-executables.jsonl`. + +Focused native checks also passed 14 reservation-filter tests, 54 TSJS-filter tests and the supported-profile/active-diagnostics HTML checks. The first broader adapter test pass completed Fastly, reusable Fastly, Axum, Cloudflare, Spin, parity, CLI and build-digest gates. Clippy then found a missing panic doc on the new fixture access wrapper. Those pre-CSP checks are retained under `evidence/gates-pre-csp`; affected gates were rerun after the compatibility fix rather than silently reusing the earlier source state; final results are below. + +The current distribution has 14 modules. Final normalized-transport publisher-registration browser contracts passed all 109 cases in 85 s; the unchanged initial-render comparison passed all ten scenarios in 9.5 s. Final fixture/launcher tests passed 14 cases. Both browser runners used the same current core/GPT/Prebid bundle bytes and real external Prebid v10.26.0. External Prebid SHA-256: `32a7f07131047948134510d57c7f4eeb832e1ae0c75d8b61d6fd38aab2be780c`. + +## Controlled before/after measurement + +Command: + +```sh +node crates/trusted-server-integration-tests/browser/cwv/run.cjs \ + --baseline /tmp/ts-854-cwv/baseline-export \ + --loading /tmp/ts-854-cwv/final-unreserved \ + --combined /tmp/ts-854-cwv/final-reserved \ + --runs 5 --out /tmp/ts-854-cwv/evidence/cwv-final-normalized +``` + +Final normalized-transport run passed 61 cases in 135 s: 40 alternating repeated measurements and 21 functional cases, including all three review regressions. Every timing group contains exactly five measurement samples; correctness cases are separate groups. Browser 145.0.7632.6, Node 24.12.0, fresh contexts, loopback HTTP and a deliberately held 750 ms runtime response. CPU was unthrottled on a shared host while Rust checks ran. First-visible is an animation-frame/DOM estimate. Large paint gains demonstrate removing that controlled parser-blocking wait, not a forecast of production improvement. + +| Viewport | Arm | FCP/LCP median and range, ms | CLS median | Native request median, ms | Visible estimate median, ms | +| ----------- | ---------------- | ---------------------------- | ---------- | ------------------------- | --------------------------- | +| 390 × 844 | Baseline | 856, 828 to 872 | 0.207065 | 845 | 1098.7 | +| 390 × 844 | Loading only | 100, 96 to 144 | 0.207065 | 843 | 1098.7 | +| 390 × 844 | Reservation only | 896, 840 to 1012 | 0 | 907 | 1143.0 | +| 390 × 844 | Combined | 104, 48 to 164 | 0 | 824 | 1068.7 | +| 1280 × 1000 | Baseline | 844, 820 to 860 | 0.111133 | 861 | 1093.6 | +| 1280 × 1000 | Loading only | 64, 44 to 96 | 0.111133 | 805 | 1020.0 | +| 1280 × 1000 | Reservation only | 832, 804 to 916 | 0 | 845 | 1064.2 | +| 1280 × 1000 | Combined | 72, 52 to 196 | 0 | 805 | 1045.8 | + +The reservation-only arm is explicitly a CSS-only isolation derivative: baseline production HTML and assets plus the exact Rust-generated reservation style from current output. It is not described as a baseline feature. Raw derived bytes, manifests, observer entries, targeting, screenshots, traces and distributions are retained in `evidence/cwv-final-normalized/`. The original 58-case `evidence/cwv-final/` is retained as a superseded transport-fixture version, not the final input state. Lockfiles were retained after the run with an explicit provenance sidecar, not represented as regenerated output. + +Functional cases covered inline/shared payloads, automatic/registered slots, delayed/failed/absent/late runtime, actual creative DOM through Universal Creative, child replacement, absent wrapper, unmatched path, unknown dimensions, breakpoint boundaries and no-fill. Each matched case made one native request; unmatched paths made none. A 420 px creative exceeding the 250 px minimum still shifted. A 600 px creative shifted at width 1023 and did not shift at width 1024. No-fill retained 250 px, and desktop 250 px fill left the explicitly configured 350 px whitespace. + +### Transfer cost + +This is not a size-reduction result. Raw inline JavaScript grew from 57,550 to 112,674 bytes. The retained raw GPT bootstrap is 56,429 bytes, gzip 12,953 and Brotli 11,113. The compiled early entry adds 55,123 bytes, gzip 18,072 and Brotli 16,001. Fetched runtime shrank from 91,321 to 56,932 bytes. Estimated combined HTML-plus-runtime gzip bytes grew from 42,104 to 49,617, an extra 7,513 bytes. These are compression estimates, not a throttled real-network transfer measurement. The extra early code preserves APIs, guards and rendering when runtime is delayed or absent. + +### Measurement iteration failure + +The first CSS-only derivative selected every style-looking substring and accidentally copied an iframe-template style string from inline JavaScript. That iteration is retained but superseded. The runner now selects only the exact `data-ts-slot-reservations` production tag, with a regression test. Seven CWV unit tests passed before the final 61-case comparison. + +## Internal review and fix loop + +Four lanes reviewed fingerprint `10b37d03ab8a17fb5055716bb9feeeb4adb44c16ee23050f5593eb23a80dac00`. Correctness and architecture were fresh workers; style and catalogued code-smell inspection reused implementation workers and did not claim independent authorship. No catalogued smells were found. + +- C1/A1 reproduced a default compatibility regression in Chromium. A response policy allowing same-origin external scripts and all old inline hashes blocked only the new compiled bootstrap. Baseline core APIs worked; current core APIs stayed undefined after load and early publisher code threw. Fixed with conservative synchronous fallback for enforced response CSP, including configured response headers, not merely a deployment warning. Six focused Rust CSP checks passed, including actual-origin and shared cold/warm paths. +- C2 found the actual framework's renderer-absence control still blocked only the old unified URL. The new path installed the renderer and never exercised the intended absence. The control now removes the compiled early entry and blocks the actual runtime. A separate failed-runtime control retains early rendering. Both pass parser-driven real-creative checks and the final actual Next.js inline/shared framework gate. +- T1/T2 final validation-document and launcher-test formatting were corrected. The final launcher/fixture test command passes all 14 cases. + +A deferred-only runtime entry was considered but not required in this patch. It needs another lifecycle entry and changes standalone recovery behavior; measured savings are not established. Extra bytes remain disclosed rather than relabeled as improvement. + +### Independent fix verification + +Correctness and architecture re-reviewed code/test/fixture/CI fingerprint `cde724a8e04098c81fb914d1d2d8bebb229c2c7dba73c181fa4002459f5afacb`, excluding documentation being finalized. Both reported no remaining findings. Independent Chromium checks used actual baseline inline hashes plus same-origin external permission, not hashes selected from the changed page. Core APIs and immediate queue behavior now work, with zero CSP violations. Genuine renderer absence yields one ordinary fill without candidate targeting; failure of only the deferred runtime preserves the early bridge and candidate fill. Twenty focused Node tests passed. + +The reviewed fingerprint included a Playwright `--list` discovery artifact with 20 empty/skipped result records. It was removed rather than committed or counted as execution. Source-equivalent cleanup fingerprint: `3e26f0810225b31e84ee128b583589222e303e1c20ffa2604d177bd1983c7964`. + +Final unreserved/reserved exporter manifests were checked against all 12 source/toolchain/lockfile fingerprints and embedded exporter text. All prior ordinary generated HTML documents and served JS assets compare byte-for-byte equal to the final exports. Those results remained applicable after the CSP fix. The transport fixture was then normalized as described below, and all 61 browser cases plus all 109 offline contracts were rerun for that exact final fixture hash. Three new focused regressions passed in the worker run and an independent correctness run. Their exact command uses `--baseline /tmp/ts-854-cwv/baseline-export --loading /tmp/ts-854-cwv/final-unreserved --combined /tmp/ts-854-cwv/final-reserved --regressions-only`. + +Final manifest hashes: + +- Unreserved: `d42aa228b5609dce03bed88f7633b931c10018d56d04f3c815a95fe4be4ef9ef`. +- Reserved: `722d560435a534b51d73bbf8384300028a1d0ece38610cd8544878e01a150972`. +- Baseline: `e903286285cab8ca79d0b334e6012a136f7d76975a813dc00eeb76d54664b5fe`. + +## Final framework, artifact and backend gates + +### Real build and framework + +With `TSJS_PREBUILT_DIR` unset, `cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1` passed in 11m28s. Its real build.rs generated all 14 modules; every file exactly matches the independently built distribution, and every complete module string is embedded byte-for-byte in the final WASM. + +WASM SHA-256: `8477c69f44e01318321a31fbd881bf0cf6c610260561d4d00e6e52c85cb48e2a`. + +The source-labelled Next.js image `test-nextjs:publisher-registration-25b7eefeb9c2f758` built successfully. Docker printed two pre-existing warnings about unset default build-argument values, but the actual build used the pinned Node argument. Full/reduced actual-provider transport preflights each observed two requests, admitted the intended winner and accepted legal no-bid. The selected backend timers were 1000 ms and 250 ms; certificate checking remained enabled. + +The first framework run passed 18 of 20 inline cases. Two new narrow-fill geometry assertions correctly failed: the simulated native iframe declared 300 by 250 but its browser-default border made its actual box 304 by 254, with another four pixels of inline baseline spacing expanding the wrapper to 258. This was fixture chrome, not reservation policy. The transport now sets only its own outer iframe to block layout with zero border. Production CSS and the strict 250 px assertions were not changed. The real Chromium geometry matrix confirms the old default, old lab-CSS normalization, new default and new lab-CSS cases. + +The final `scripts/test-publisher-registration.sh` run used `--bundle-mode build-rs --reuse-artifacts`, the same verified WASM/image/configs, and the normalized fixture. It passed all 40 actual Rust/Next.js cases: 20 inline in 27.4 s and 20 shared-template in 34.1 s. These include all six pre-hydration responsive/fill/no-fill cases, real registration/navigation lifecycle, true renderer absence, independent runtime failure, visible actual creative content and served-byte equality. No Cargo or Docker build was needed for that reuse run. Evidence: `evidence/framework-normalized/`; the prior failed run remains in `evidence/framework/`. + +Because the shared transport bytes changed, the entire 61-case lab and 109-case offline suite were rerun, rather than treating the older 58-case input state as current. CLS is identical in all eight repeated viewport/arm groups before/after the transport correction; timing ranges are wider on the concurrently loaded host. No ad-startup improvement is inferred from those noisy timing differences. + +Final transport SHA-256: `a9921ac1863db659094edbb079283458c7270d417e0043357f01a7f705b8a12e`. +Final normalized sample SHA-256: `a2f6940891d2021168ef5e9de4e1c739e5b277f13e00ace219d0222649021d8e`. +Final reviewed code-only fingerprint: `917beeb91a6709a9194417e57cfa175be4dcd5ad9738edb5d3331daacfa6afd5`. Independent correctness review accepted the fixture-only delta and its JSDOM regression without findings. + +### Final backend gates + +All 21 post-fix commands below passed. Commands used the coordinator environment described above and `TSJS_PREBUILT_DIR=/tmp/ts-854-cwv/current-dist`; the separate release build above deliberately did not use prebuilt JS. Per-command logs and the exact ledger are in `evidence/gates/`. Passing suites retain their existing ignored tests where the command's normal policy excludes them; no new skip was introduced. + +| Gate | Result | Seconds | Command | +| ------------------------------------ | ------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| test-fastly | Passed | 84 | `cargo test-fastly` | +| test-fastly-reuse | Passed | 34 | `cargo test-fastly-reuse` | +| test-axum | Passed | 46 | `cargo test-axum` | +| test-cloudflare | Passed | 33 | `cargo test-cloudflare` | +| test-spin | Passed | 5 | `cargo test-spin` | +| parity | Passed | 19 | `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --target x86_64-unknown-linux-gnu --test parity` | +| test-cli | Passed | 45 | `cargo test --package trusted-server-cli --target x86_64-unknown-linux-gnu` | +| template-build-digest | Passed | 15 | `cargo test -p trusted-server-core --target x86_64-unknown-linux-gnu --test template_build_digest` | +| clippy-fastly | Passed | 48 | `cargo clippy-fastly` | +| clippy-axum | Passed | 50 | `cargo clippy-axum` | +| clippy-cloudflare | Passed | 30 | `cargo clippy-cloudflare` | +| clippy-cloudflare-wasm | Passed | 57 | `cargo clippy-cloudflare-wasm` | +| clippy-spin-native | Passed | 1 | `cargo clippy-spin-native` | +| clippy-spin-wasm | Passed | 36 | `cargo clippy-spin-wasm` | +| clippy-cli | Passed | 107 | `cargo clippy-cli` | +| clippy-codegen | Passed | 9 | `cargo clippy-codegen` | +| clippy-template-build-digest | Passed | 51 | `cargo clippy -p trusted-server-core --target x86_64-unknown-linux-gnu --test template_build_digest -- -D warnings` | +| clippy-cwv-example | Passed | 50 | `cargo clippy -p trusted-server-integration-tests --target x86_64-unknown-linux-gnu --example cwv_html -- -D warnings` | +| clippy-publisher-registration-helper | Passed | 1 | `cargo clippy -p trusted-server-integration-tests --target x86_64-unknown-linux-gnu --example publisher_registration_backends -- -D warnings` | +| doc-core-js | Passed | 17 | `cargo doc -p trusted-server-core -p trusted-server-js --target x86_64-unknown-linux-gnu --all-features --no-deps` | +| wasm-clock | Passed | 10 | `scripts/lint-wasm-clock.sh` | + +Full Rust formatting, JS lint/format, docs lint/format and Markdown formatting outside docs passed. VitePress built successfully, with existing VCL-highlighting and chunk-size warnings. The final build output is retained in `evidence/docs-build-final.log`. The integration workflow parsed successfully through the repository's YAML formatter. ShellCheck/actionlint were unavailable, so their checks are not claimed. The new fixture/launcher and CWV unit commands passed 14 and seven cases respectively. + +The standalone full TypeScript and Cargo-injected native Settings failures described above remain disclosed baseline limitations. The final clean-environment native core result is 2,983 plus both ten-test integration suites, not a claim that the unmodified failing Cargo invocation passed. + +## Evidence limits + +No real GPT/GAM staging, customer-page measurement, production activation or field CWV validation has run. The controlled browser fixtures use fictional bids and simulated GPT transport. A first-visible timestamp is a frame/DOM estimate, not hardware presentation or a GAM impression timestamp. Initial-document reservation does not install new CSS for subsequent client-only SPA routes. diff --git a/docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md b/docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md index 33cc0a7124..8fc5a7f357 100644 --- a/docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md +++ b/docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md @@ -6,8 +6,12 @@ Issue: [#854](https://github.com/IABTechLab/trusted-server/issues/854). Base: `5bc38862512c5c71085b5052bc3e528ee9b9987d`. -This specifies one correctness change within #854. Bundle deferral and automatic -slot-height injection remain separate work. It supplements the existing +This specifies the publisher-registration portion of #854. The user expanded +PR #1460 on 2026-10-29 to include dependency-safe runtime deferral, explicit +responsive reservation and before/after lab measurements. See the +[CWV completion design](2026-10-29-854-client-cwv-completion-design.md) for that +scope. The original exclusions below describe this registration design, not the +expanded PR. It supplements the existing [slot handoff design](2026-07-24-prevent-duplicate-gpt-slot-requests-design.md) without replacing its default automatic mode. diff --git a/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md b/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md new file mode 100644 index 0000000000..e59ef2c731 --- /dev/null +++ b/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md @@ -0,0 +1,127 @@ +# Client CWV and initial-delivery completion for issue 854 + +Status: Implemented and locally validated. The user authorized specification, measurement, implementation, validation and pushing the existing PR on 2026-10-29. Hosted CI and real-GPT/field validation are separate checks; see the validation record. +Issue: https://github.com/IABTechLab/trusted-server/issues/854 +PR: https://github.com/IABTechLab/trusted-server/pull/1460 +Baseline: `c36c82df43c2f62a59444290f863f736b579ed33`. + +## Outcome + +Extend the existing publisher-registration PR with dependency-safe runtime deferral, explicit responsive slot-space reservation, and repeatable before/after browser measurements. Preserve the first-impression ownership contract. Report measured effects and blocking dependencies, not a guaranteed CWV improvement. + +The earlier registration design remains authoritative for registration, payload admission, cancellation, targeting and navigation generations. This document supersedes its exclusion of bundle deferral and reservation from this PR. Production deployment, publisher activation and real-GPT staging are not authorized by a request to implement and push code. + +## Invariants + +- Early publisher code retains working synchronous core APIs and its existing command-queue semantics. +- Dynamic GPT scripts are intercepted before insertion and eligible creatives have a working render bridge before any TS initial request. +- Delaying the runtime must not consume a registration as renderer-unavailable merely because that runtime has not arrived. +- Runtime takeover must not create another initial request, lose registrations, replay stale payloads or erase early configuration. +- CSS reservation is path/config-derived and visitor-neutral. It never depends on consent, auction outcome, bot classification or per-user bids. +- No reservation is inferred from an arbitrary supported format list. Unconfigured placements retain their layout. +- Browser claims distinguish parser/paint timing, ad timing, request count, controlled layout shift and production field CWV. + +## 1. Loading architecture + +Supported default profile: no enforced response CSP, GPT enabled with core/creative and optionally the existing separately deferred Prebid integration, plus integrations with no JavaScript or early head dependency. Use an explicit allowlist after inspecting the registry. Unknown JS integrations are not silently deferred. Pages without GPT retain the existing synchronous bundle rather than receive unused GPT bootstrap code. + +```mermaid +flowchart TD + A[Head opens] --> B[Reservation CSS, configuration and existing GPT bootstrap] + B --> C{Enforced response CSP, diagnostics or blocked profile?} + C -->|Yes| S[Original synchronous unified bundle] + S --> P[Publisher HTML and scripts continue] + C -->|No| D[Compiled inline core, creative guards, GPT shim and render bridge] + D --> E[Publisher HTML and scripts continue parsing] + E --> F[Standalone GPT runtime executes with defer] + F --> G[Existing deferred Prebid executes in document order] + G --> H[Runtime takeover preserves early slot and navigation state] +``` + +Compile an early entry from the real shared TypeScript implementation, rather than writing another hand-maintained protocol. Extract GPT shim and rendering helpers into modules used by both entry points. Early entry installs the real core APIs, creative interception, GPT command/script guard, attribution and render bridge. Existing inline GPT bootstrap continues to own provisional adInit, scheduler, registration and navigation state. The deferred GPT runtime owns full adInit takeover, publisher-slot handoff, page-bids fetching and slim-Prebid lifecycle. + +Keep the original unified route, module hash and synchronous tag as the fallback. Supported profiles instead emit the compiled early entry inline and a deferred, versioned standalone GPT tag. Do not initialize core twice. Standalone GPT serving is enabled only when GPT is enabled. Preserve trusted GAM attribution and APS rendering-mode attributes on executing tags. Inline compiled JS must be safe in script text, including literal closing script sequences. + +The existing raw GPT bootstrap is 56,429 source bytes, not the old issue's asserted 7.3 KB. Keeping it means synchronous execution remains. Measure raw and compressed inline bytes and remaining runtime bytes. Reducing or replacing that bootstrap is not a prerequisite for correctness and must not be claimed as accomplished. + +### Dependency inventory and fallback + +Document every current integration's early dependency and selection outcome. Initial blockers include DataDome, Google Tag Manager, Lockr, Permutive, Didomi, Sourcepoint, Osano and Testlight. They install guards, globals, consent initialization or immediate queue behavior that cannot simply run after publisher scripts. Active request-scoped GPT diagnostics also retains synchronous loading so listeners precede publisher calls. + +An enforced Content-Security-Policy response header from the origin or configured response headers also retains the original synchronous unified path. Presence is sufficient, even for a permissive policy; there is no new general CSP evaluator. Report-Only alone does not force fallback. This choice is independent of visitor/auction admission and retained in shared-template output. Header-only selection does not promise detection of policies introduced later in document markup. + +This rule follows an executed review counterexample: an old deployment allowing same-origin external scripts and the old inline hashes lost core APIs when the new default inline entry was denied. Documentation alone did not preserve compatibility. The conservative fallback gives up deferral on some compatible CSP pages rather than silently require new permissions. Do not weaken CSP, add nonce handling or add an operator loading-mode flag. + +Do not add parallel experimental loading modes or a new operator flag merely to conceal uncertainty. Keep fallback explicit in source and tests. Adding a newly supported profile later requires ordering coverage. + +### Failure behavior + +Test delayed, failed and absent deferred GPT runtime. Existing early bootstrap and installed rendering support must remain usable and must not repeat delivery when the runtime catches up. No timer is proof of framework readiness. Preserve real navigation invalidation across takeover and reject stale responses. + +## 2. Explicit slot-space reservation + +Add optional per-placement configuration: + +```toml +[[creative_opportunities.slot]] +id = "header" +page_patterns = ["/news/*"] +formats = [{ width = 300, height = 250 }, { width = 300, height = 600 }] + +[creative_opportunities.slot.reservation] +element_id = "header-ad-space" +min_height = 250 + +[[creative_opportunities.slot.reservation.breakpoints]] +min_width = 1024 +min_height = 600 +``` + +The publisher supplies a stable wrapper with that exact ID. A framework may replace a child GPT div without changing this wrapper. Reservation does not resolve a div prefix or depend on registration's eventual element ID. + +- Base height is a nonnegative integer CSS pixel value. +- Breakpoints are positive integer viewport widths with strictly increasing values and nonnegative heights. The base rule covers width zero. +- Only configured reservations for matching paths emit CSS. An absent matching element remains untouched. If multiple matching slots name the same wrapper, the first matching slot in configuration order owns its complete policy. Disjoint paths can reuse wrapper IDs. +- Emit scoped min-height rules in head before scripts. Use min-width media queries with deterministic mobile-first cascade. +- Escape IDs as CSS string content without allowing HTML style-tag termination or selector injection. Do not accept arbitrary CSS. +- No JS resize listener, mutation observer, guessed format maximum, forced width or overflow clipping. +- Preserve reservation for no-fill. Automatic collapse would itself shift content. Publishers choose whitespace tradeoffs explicitly. +- Fluid and unknown-height creatives may use explicit publisher-provided reservation, but it is not a bound on their final height and cannot guarantee zero shift. +- Omitted reservation serializes as absent. Do not change existing browser slot wire contracts or auction request formats. + +Thread request path into the HTML transform separately from adSlots so inline, streaming and shared-template paths share the policy. Visitor gating must not leak into a shared template. The creative-opportunities enabled switch still disables its configured reservations. Cover query/path normalization consistently with existing slot matching and preserve nonmatching pages. + +This unit reserves space for the initial document. Client-only SPA route additions need publisher-owned reservation CSS or a full document navigation; TS does not mutate head styles on navigation. Strict publisher style CSP must permit the deterministic style hash or supply equivalent reservation in an allowed stylesheet. Do not weaken CSP or inject visitor-specific nonces into shared templates. + +## 3. Measurement contract + +Use a loopback HTTP server, parser-created script tags, actual Rust-generated HTML and exact compiled production JS. Existing addScriptTag-based contracts remain useful for lifecycle behavior but cannot measure parser blocking. + +Retain the baseline source revision, source/file fingerprints, lockfiles, browser and toolchain versions, viewport, CPU/network settings, commands and raw per-run results. Fresh browser contexts isolate cache/state. Compare baseline, deferral only, reservation only and combined behavior with repeated alternating runs. Report medians and variation, not one selected sample. + +Capture FCP, LCP, DOM parse timings, raw layout-shift entries excluding recent input, proper maximum-session-window CLS, native request counts by slot, targeting at request time, and first visible ad time. Also capture reservation height/whitespace and exact inline/runtime byte counts with gzip/Brotli size. Observe shifts across multiple frames and finish pending observers before reading results. + +Desktop/mobile viewports, responsive boundaries, multi-height creatives, no-fill, unknown/unreserved formats, absent/nonmatching slots, delayed creatives, framework child replacement and inline/shared output are required fixture cases. Include a controlled layout-only page that visibly reproduces the baseline shift. + +Functional assertions are hard gates. Timing distributions are retained measurements; no arbitrary millisecond improvement threshold on shared CI. Fail if observers did not collect supported metrics, if the baseline does not exercise its intended blocking/shift, or if required cases were silently skipped. Controlled fixtures do not prove customer-site improvement. + +## 4. Initial request and real-GPT validation + +Retain existing request-count, targeting and visible-creative registration contracts, including ordinary automatic slots and publisher-owned slots. Test the new deferred loading through native browser parsing, not just direct module imports. + +Use real GPT/GAM staging only with a separately approved environment. Required staging evidence: one intended initial request per delegated slot, eligible server targeting on that request, visible creative in the final element, unrelated-slot isolation and legitimate subsequent refreshes. Do not change page-level initial-load/SRA policy or trigger live customer auctions without permission. + +## Acceptance and reporting + +1. Supported profiles use deferred GPT runtime with real early dependencies installed; blocked integrations, diagnostics and enforced response CSP keep tested synchronous behavior and working early core APIs. +2. Explicit reservation config produces safe responsive early CSS on all relevant Rust paths, including shared templates, with unconfigured pages unchanged. +3. Baseline and changed browser measurements report both page-paint and ad-startup consequences and reproduce a controlled layout-shift improvement. +4. Existing registration contracts and affected neighboring paths pass with current artifacts. +5. Required repository build/test/lint gates run, with exact failures, baseline attribution and unavailable checks disclosed. +6. Push commits to the existing PR branch and update the PR scope/validation. Do not claim production or field validation. Keep issue closure conditional on its outstanding real-page measurement requirements if those remain unavailable. + +## Related + +- Registration design: [Publisher-registered initial delivery](2026-10-27-854-publisher-registered-initial-delivery-design.md). +- Plan: [Measurement, implementation and validation](../plans/2026-10-29-854-client-cwv-completion-plan.md). +- Validation: [Commands, measurements and remaining limits](../reviews/2026-10-29-854-client-cwv-validation.md). diff --git a/scripts/test-publisher-registration.sh b/scripts/test-publisher-registration.sh index d0fd67c309..9672b656b4 100755 --- a/scripts/test-publisher-registration.sh +++ b/scripts/test-publisher-registration.sh @@ -133,7 +133,12 @@ if [[ "$MODE" == stamp ]]; then exit 0 fi if [[ "$MODE" != prepare ]]; then - [[ "$(viceroy --version)" == 'viceroy 0.17.0' ]] || { echo 'Pinned Viceroy 0.17.0 required'; exit 1; } + PINNED_VICEROY="$(awk '$1 == "viceroy" { print $2 }' "$ROOT/.tool-versions")" + ACTUAL_VICEROY="$(viceroy --version)" + [[ "$ACTUAL_VICEROY" == "viceroy $PINNED_VICEROY" ]] || { + echo "Pinned Viceroy $PINNED_VICEROY required; found $ACTUAL_VICEROY" >&2 + exit 1 + } fi { git rev-parse HEAD; git status --short; rustc -Vv; cargo --version; if command -v viceroy >/dev/null; then viceroy --version; else echo 'Viceroy not installed in artifact-preparation job'; fi; node --version; docker version --format '{{.Server.Version}}'; df -h / /tmp; } > "$OUT/environment.txt" if [[ -n "$WASM" ]]; then From 902486bd2d480d5ce868e84f79e1368563b942b6 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 29 Oct 2026 14:10:38 -0500 Subject: [PATCH 03/13] Record merged CWV validation and evidence limits --- ...26-10-29-854-client-cwv-completion-plan.md | 12 ++- .../2026-10-29-854-client-cwv-validation.md | 73 ++++++++++++++----- ...-10-29-854-client-cwv-completion-design.md | 2 +- 3 files changed, 65 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md b/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md index 5ce8dec5e9..c24247bca6 100644 --- a/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md +++ b/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md @@ -1,6 +1,6 @@ # Issue 854 measurement, implementation and validation plan -Status: Implementation and local verification complete. Authorized 2026-10-29. Baseline `c36c82df43c2f62a59444290f863f736b579ed33`. +Status: Implementation and merged-main local verification complete. Code commit `7b83a8ce87e773e2234a6dde7380a123f36e7d1f`. Hosted CI remains separate. Authorized 2026-10-29. Baseline `c36c82df43c2f62a59444290f863f736b579ed33`. Spec: [Client CWV completion](../specs/2026-10-29-854-client-cwv-completion-design.md). ## Ownership and evidence @@ -56,7 +56,15 @@ Original CI failure is a launcher literal requiring Viceroy 0.17.0 while the PR The first four-lane review reproduced a default loading regression under an old-hash-plus-same-origin CSP and found that the framework renderer-absence control intercepted only the old bundle URL. Fixes add conservative enforced-response-CSP fallback, true deliberate renderer removal and a distinct failed-runtime case retaining early rendering. Six focused Rust CSP checks and three focused actual-browser regressions passed. The final exporter produces byte-identical ordinary HTML/assets to the 58-case timing snapshot, so those passing measurements remain applicable. The stable post-fix checkpoint passed all 21 backend gates, 40 actual Rust/Next.js cases, 61 lab cases and 109 offline contracts. Exact commands, fingerprints and baseline limitations are in the validation record. Hosted CI remains pending after push; real GPT/GAM staging and field CWV remain unverified. -Measured transfer cost is an explicit tradeoff: combined HTML/runtime gzip estimate grew by 7,513 bytes while the controlled parser wait and compatible ad-induced shift were removed. A runtime-only entry was evaluated and deferred because it adds another lifecycle contract; no total-byte reduction is claimed. +Premerge transfer cost was an explicit tradeoff: combined HTML/runtime gzip estimate grew by 7,513 bytes. After merging main, the corresponding increase is 8,479 bytes and includes upstream/build-dependency changes. A runtime-only entry was evaluated and deferred because it adds another lifecycle contract; no total-byte reduction is claimed. + +## Main integration checkpoint + +Merged main `21f43aa0138498023455789f152f5a864bbefe24` into the implementation in commit `7b83a8ce87e773e2234a6dde7380a123f36e7d1f`. Preserved upstream APS failure relay, iframe/timeout ownership, asynchronous HTML EOF and origin-header rewriting. Threaded actual path/CSP through the new EOF driver. + +Fresh Node 24.20.0/Rust 1.98.1/Viceroy 0.21.0 checks passed 1,519 JS tests, 3,197 native core tests plus 20 native integrations, 61 lab cases, 109 offline contracts, 40 actual Next.js cases, six real-PUC APS cases and all 27 backend/EOF/URL gates. The new Node-labelled image and all 14 actual embedded JS module strings were verified. Independent merge correctness/architecture reviews accepted the resolutions and narrow host-lint exceptions. Standalone TypeScript and unrelated whole-workflow actionlint failures remain disclosed. Hosted CI and real GPT/GAM/field validation are not inferred from these local results. + +The unchanged baseline archive and all failed browser/quota/lint attempts remain distinct from successful post-merge runs. The validation record contains final measurements, hashes, commands and evidence limits. ## 4. Stable combined verification diff --git a/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md b/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md index 9ab72f9fa6..a1671c2c0e 100644 --- a/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md +++ b/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md @@ -1,6 +1,6 @@ # Issue 854 client CWV validation -Status: Merge integration behavior checks complete. The merged-tree URL-lint preflight passes; the committed-branch check and final evidence update follow the merge commit. Hosted checks require the push. No deployment or production activation performed. +Status: Implementation and merge integration validated locally at `7b83a8ce87e773e2234a6dde7380a123f36e7d1f`. All required local gates pass. Hosted checks are tracked separately on PR1460. No deployment or production activation performed. [Spec](../specs/2026-10-29-854-client-cwv-completion-design.md) · [Plan](../plans/2026-10-29-854-client-cwv-completion-plan.md) @@ -14,20 +14,21 @@ Conflict resolution preserves upstream asynchronous HTML EOF processing, origin ### Updated checks and artifacts -| Check | Result | Evidence under `merge/` | -| ------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------- | -| Full JS suite | 1,519 tests across 52 files passed; full lint and format passed | `js-final-{full,lint,format}.log` | -| Focused GPT/APS suite | 671 tests across nine files passed, including early/runtime APS failure relay | `gpt-focused.log` | -| Native core | Unmodified Cargo invocation passed 3,197 core tests plus two ten-test integration suites | `evidence/gates/core-native.log` | -| Fixture, launcher and CWV units | All 21 passed | `evidence/browser-units.log` | -| Updated lab | All 61 passed, including 40 repeated measurements | `evidence/cwv/` | -| Offline registration | All 109 passed | `evidence/offline-registration/` | -| Actual Rust/Next.js | All 40 passed, 20 inline and 20 shared-template | `evidence/framework/` | -| Real APS Universal Creative | All six passed, no skips or flaky cases | `standard/results.json` | -| Node pin guard | All 30 tests passed; all 27 jobs pin Node before compiling Rust | Coordinator output | -| Documentation proposal scripts | All 20 Node tests, shell tests and ShellCheck passed | Coordinator output and `evidence/docs-proposal.log` | -| Changed integration workflow | actionlint passed | `evidence/actionlint.log` | -| Publisher-registration launcher | ShellCheck passed | `evidence/shellcheck-publisher.log` | +| Check | Result | Evidence under `merge/` | +| --------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------- | +| Full JS suite | 1,519 tests across 52 files passed; full lint and format passed | `js-final-{full,lint,format}.log` | +| Focused GPT/APS suite | 671 tests across nine files passed, including early/runtime APS failure relay | `gpt-focused.log` | +| Native core | Unmodified Cargo invocation passed 3,197 core tests plus two ten-test integration suites | `evidence/gates/core-native.log` | +| Fixture, launcher and CWV units | All 21 passed | `evidence/browser-units.log` | +| Updated lab | All 61 passed, including 40 repeated measurements | `evidence/cwv/` | +| Offline registration | All 109 passed | `evidence/offline-registration/` | +| Actual Rust/Next.js | All 40 passed, 20 inline and 20 shared-template | `evidence/framework/` | +| Initial-render overlap regression | All ten passed against current bundles and the explicit Rubicon/SharedId artifact | `evidence/initial-render/` | +| Real APS Universal Creative | All six passed, no skips or flaky cases | `standard/results.json` | +| Node pin guard | All 30 tests passed; all 27 jobs pin Node before compiling Rust | Coordinator output | +| Documentation proposal scripts | All 20 Node tests, shell tests and ShellCheck passed | Coordinator output and `evidence/docs-proposal.log` | +| Changed integration workflow | actionlint passed | `evidence/actionlint.log` | +| Publisher-registration launcher | ShellCheck passed | `evidence/shellcheck-publisher.log` | With `TSJS_PREBUILT_DIR` unset, the fresh release build passed in 1m56s. All 14 complete JS module strings match the independent distribution and occur byte-for-byte in the frozen WASM. WASM SHA-256 is `dad40087f6123d73b8465d774bd3212e7b25b4d6c8ce3051579f263e0d117f23`. @@ -66,13 +67,47 @@ Standalone `tsc --noEmit` still fails, with 393 output lines and 320 primary dia Whole-repository actionlint still reports unchanged upstream workflow issues, including unquoted output variables and unsupported new workflow keys/permissions. The changed integration workflow passes separately. These extra whole-repository diagnostics are not represented as a clean result or fixed outside this PR's scope. -All 26 backend, native, Clippy, docs, clock and EOF commands other than the committed-branch URL check passed. This includes actual Axum and Fastly auction/EOF controls with disabled, successful and hung finalizers. Their exact commands and timings are retained in `evidence/gates/results.tsv`. - -The URL linter reads committed trees. Its first invocation still examined the earlier branch HEAD and reported seven older URLs. Fixes retain official documentation/source references and the actual APS SDK assertion with reviewed per-line exceptions, without adding global allowlist entries. The fixture's symbolic-port error text no longer contains an invalid URL. Three helper tests, helper Clippy, eight early-bundle tests and scoped JS lint/format pass. Explicit-file lint and an exact merged-tree commit snapshot pass. Staged mode also reports unrelated upstream documentation because the merge index includes main's changes; those are outside the resulting PR diff and were not modified. The committed-branch check follows the merge commit. +### Backend and EOF results + +All 27 commands pass after the URL-lint cleanup and committed-tree retry. This includes actual Axum and Fastly auction/EOF controls with disabled, successful and hung finalizers. The original first-pass ledger, including the older-HEAD URL failure, remains in `evidence/gates/results.tsv`; the successful committed-branch check is in `evidence/domain-committed.log`. + +| Gate | Result | Seconds | Command | +| ------------------------------------ | --------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| core-native | Passed | 56 | `cargo test -p trusted-server-core --target x86_64-unknown-linux-gnu` | +| test-fastly | Passed | 152 | `cargo test-fastly` | +| test-fastly-reuse | Passed | 26 | `cargo test-fastly-reuse` | +| test-axum | Passed | 42 | `cargo test-axum` | +| test-cloudflare | Passed | 21 | `cargo test-cloudflare` | +| test-spin | Passed | 2 | `cargo test-spin` | +| parity | Passed | 16 | `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --target x86_64-unknown-linux-gnu --test parity` | +| test-cli | Passed | 99 | `cargo test --package trusted-server-cli --target x86_64-unknown-linux-gnu` | +| template-build-digest | Passed | 15 | `cargo test -p trusted-server-core --target x86_64-unknown-linux-gnu --test template_build_digest` | +| clippy-fastly | Passed | 34 | `cargo clippy-fastly` | +| clippy-axum | Passed | 25 | `cargo clippy-axum` | +| clippy-cloudflare | Passed | 14 | `cargo clippy-cloudflare` | +| clippy-cloudflare-wasm | Passed | 30 | `cargo clippy-cloudflare-wasm` | +| clippy-spin-native | Passed | 1 | `cargo clippy-spin-native` | +| clippy-spin-wasm | Passed | 17 | `cargo clippy-spin-wasm` | +| clippy-cli | Passed | 51 | `cargo clippy-cli` | +| clippy-codegen | Passed | 5 | `cargo clippy-codegen` | +| clippy-template-build-digest | Passed | 25 | `cargo clippy -p trusted-server-core --target x86_64-unknown-linux-gnu --test template_build_digest -- -D warnings` | +| clippy-cwv-example | Passed | 23 | `cargo clippy -p trusted-server-integration-tests --target x86_64-unknown-linux-gnu --example cwv_html -- -D warnings` | +| clippy-publisher-registration-helper | Passed | 1 | `cargo clippy -p trusted-server-integration-tests --target x86_64-unknown-linux-gnu --example publisher_registration_backends -- -D warnings` | +| doc-core-js | Passed | 9 | `cargo doc -p trusted-server-core -p trusted-server-js --target x86_64-unknown-linux-gnu --all-features --no-deps` | +| wasm-clock | Passed | 12 | `scripts/lint-wasm-clock.sh` | +| eof-native | Passed | 21 | `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --target x86_64-unknown-linux-gnu --features html-eof-runtime --test html_eof_runtime` | +| eof-clippy | Passed | 7 | `cargo clippy --manifest-path crates/trusted-server-integration-tests/Cargo.toml --target x86_64-unknown-linux-gnu --features html-eof-runtime --test html_eof_runtime -- -D warnings` | +| lint-domains | Passed after committed-tree retry | <1 | `ts dev lint domains --changed-vs origin/main` | +| eof-axum-auction | Passed | 74 | `./scripts/async-html-finalization-local-test.sh axum --auction` | +| eof-fastly-auction | Passed | 44 | `./scripts/async-html-finalization-local-test.sh fastly --release --auction` | + +Full Rust formatting, docs lint/format and Markdown formatting outside docs also pass. VitePress builds successfully, retaining its existing VCL-highlighting and chunk-size warnings. Whole-workflow actionlint and standalone TypeScript limitations remain as described above. + +The URL linter reads committed trees. Its first invocation still examined the earlier branch HEAD and reported seven older URLs. Fixes retain official documentation/source references and the actual APS SDK assertion with reviewed per-line exceptions, without adding global allowlist entries. The fixture's symbolic-port error text no longer contains an invalid URL. Three helper tests, helper Clippy, eight early-bundle tests and scoped JS lint/format pass. Explicit-file lint and an exact merged-tree commit snapshot pass. Staged mode also reports unrelated upstream documentation because the merge index includes main's changes; those are outside the resulting PR diff and were not modified. The actual committed branch passes `ts dev lint domains --changed-vs origin/main` after merge commit `7b83a8ce87e773e2234a6dde7380a123f36e7d1f`. Independent merge correctness and architecture reviews found no defects. They compared moved APS functions and the complete EOF driver against exact main-parent code, and verified actual path/CSP/status/range/header ownership. The original reviewed fingerprint was `0a0130289e0a8e2f090bc323f604349182bf4030b64d0a6b113227b41e3a3cb2`. The final lint-only cleanup changes a fixture error message, one test comment and historical reference comments; production runtime sources remain unchanged. The new code fingerprint is `9df748852ddce84d766bab2b8241bebf3a46f31a720f62f9a0e52a70d533b270`. -Hosted CI remains pending the merge push. Real GPT/GAM, customer pages, production activation and field CWV remain unverified and require separate approval. +Hosted CI is tracked separately on PR1460; local results do not establish its outcome. Real GPT/GAM, customer pages, production activation and field CWV remain unverified and require separate approval. ## Historical premerge validation diff --git a/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md b/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md index e59ef2c731..4672fb4678 100644 --- a/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md +++ b/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md @@ -1,6 +1,6 @@ # Client CWV and initial-delivery completion for issue 854 -Status: Implemented and locally validated. The user authorized specification, measurement, implementation, validation and pushing the existing PR on 2026-10-29. Hosted CI and real-GPT/field validation are separate checks; see the validation record. +Status: Implemented and locally validated after merging main in code commit `7b83a8ce87e773e2234a6dde7380a123f36e7d1f`. The user authorized specification, measurement, implementation, validation and pushing the existing PR on 2026-10-29. Hosted CI and real-GPT/field validation are separate checks; see the validation record. Issue: https://github.com/IABTechLab/trusted-server/issues/854 PR: https://github.com/IABTechLab/trusted-server/pull/1460 Baseline: `c36c82df43c2f62a59444290f863f736b579ed33`. From 6a5cfd54d7a182481f4acddf2229057d48d8f489 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 29 Oct 2026 14:24:52 -0500 Subject: [PATCH 04/13] Parse fixture script elements with source locations --- .../browser/cwv/html.cjs | 56 +++++++- .../browser/cwv/html.test.cjs | 130 +++++++++++++++++- .../browser/cwv/run.cjs | 12 +- .../nextjs/publisher-registration.spec.ts | 13 +- ...26-10-29-854-client-cwv-completion-plan.md | 2 +- .../2026-10-29-854-client-cwv-validation.md | 42 ++++-- 6 files changed, 223 insertions(+), 32 deletions(-) diff --git a/crates/trusted-server-integration-tests/browser/cwv/html.cjs b/crates/trusted-server-integration-tests/browser/cwv/html.cjs index f9a5939560..da6ad47117 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/html.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/html.cjs @@ -1,3 +1,5 @@ +const { JSDOM } = require("jsdom"); + // Keep the exact Rust-emitted reservation tag, not iframe-template style strings // inside the inline production bundle and not a DOM reserialization. function reservationStyles(html) { @@ -8,10 +10,50 @@ function reservationStyles(html) { ); } +function scriptBlocks(html) { + const dom = new JSDOM(html, { includeNodeLocations: true }); + try { + return [...dom.window.document.querySelectorAll("script")] + .map((script) => { + const location = dom.nodeLocation(script); + const endOffset = + location.endOffset >= location.startTag.endOffset + ? location.endOffset + : html.length; + return { + content: html.slice( + location.startTag.endOffset, + location.endTag?.startOffset ?? endOffset, + ), + id: script.getAttribute("id") ?? "", + src: script.getAttribute("src"), + startOffset: location.startOffset, + endOffset, + }; + }) + .sort((left, right) => left.startOffset - right.startOffset); + } finally { + dom.window.close(); + } +} + +function withoutScripts(html, predicate) { + const pieces = []; + let offset = 0; + for (const script of scriptBlocks(html)) { + if (!predicate(script)) continue; + if (script.startOffset > offset) + pieces.push(html.slice(offset, script.startOffset)); + offset = Math.max(offset, script.endOffset); + } + pieces.push(html.slice(offset)); + return pieces.join(""); +} + function withoutEarlyRenderer(html) { - return html.replace( - /]*\bid=["']trustedserver-bootstrap["'][^>]*>[\s\S]*?<\/script>/gi, - "", + return withoutScripts( + html, + (script) => script.id === "trustedserver-bootstrap", ); } @@ -19,4 +61,10 @@ function rendererAsset(pathname) { return /^\/static\/tsjs=tsjs-(?:gpt|unified)(?:\.min)?\.js$/.test(pathname); } -module.exports = { reservationStyles, withoutEarlyRenderer, rendererAsset }; +module.exports = { + reservationStyles, + scriptBlocks, + withoutScripts, + withoutEarlyRenderer, + rendererAsset, +}; diff --git a/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs index 327b71fd14..5e2611fa0c 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs @@ -2,6 +2,8 @@ const { test } = require("node:test"); const assert = require("node:assert/strict"); const { reservationStyles, + scriptBlocks, + withoutScripts, withoutEarlyRenderer, rendererAsset, } = require("./html.cjs"); @@ -15,7 +17,11 @@ test("renderer absence removes compiled early code but retains raw protocol boot ''; assert.equal(withoutEarlyRenderer(raw + early + runtime), raw + runtime); assert.equal( - withoutEarlyRenderer(raw + early.toUpperCase() + runtime), + withoutEarlyRenderer( + raw + + "" + + runtime, + ), raw + runtime, ); for (const name of ["gpt", "unified"]) { @@ -25,6 +31,128 @@ test("renderer absence removes compiled early code but retains raw protocol boot assert(!rendererAsset("/static/tsjs=tsjs-prebid.min.js")); }); +test("script blocks preserve raw content, source offsets and decoded attributes", () => { + const prefix = "\r\n"; + const tag = + ''; + const html = prefix + tag + "\r\n

unchanged

"; + const [block] = scriptBlocks(html); + assert.deepEqual(block, { + id: "probe", + src: "/runtime.js?x=1&y=2", + content: '\r\nconst value = "<&";\r\n', + startOffset: prefix.length, + endOffset: prefix.length + tag.length, + }); + assert.equal(html.slice(block.startOffset, block.endOffset), tag); + assert.equal( + withoutScripts(html, (script) => script.id === "probe"), + prefix + "\r\n

unchanged

", + ); +}); + +test("renderer removal accepts HTML tag syntax while matching ID values exactly", () => { + const keep = + ''; + for (const close of ["", "", ""]) { + const selected = + '" + + "" + + ''; + assert.deepEqual( + scriptBlocks(html).map((script) => script.id), + ["other"], + ); + assert.equal(withoutEarlyRenderer(html), html); +}); + +test("probe and runtime removal preserve every unselected script byte", () => { + const before = '\r\n'; + const probe = + ''; + const runtime = ""; + const after = ""; + const html = before + probe + runtime + after; + assert.equal( + withoutScripts(html, (script) => + script.content.trimStart().startsWith("window.fixtureEarly="), + ), + before + runtime + after, + ); + assert.equal( + withoutScripts(html, (script) => script.id === "trustedserver-js"), + before + probe + after, + ); +}); + +test("an unclosed HTML script extends to EOF when jsdom leaves its end bound unset", () => { + for (const prefix of ["", "

before

"]) { + const content = "const value = 'open';"; + const html = prefix + "'; + const [script] = scriptBlocks(html); + assert.equal(script.content, ""); + assert.equal(script.endOffset, html.indexOf("")); + assert.equal( + withoutScripts(html, (block) => block.id === "a"), + "

after

", + ); + assert.equal( + withoutScripts(html, () => true), + "

after

", + ); +}); + +test("table foster parenting does not reorder physical source splices", () => { + const html = + "
"; + assert.deepEqual( + scriptBlocks(html).map((script) => script.id), + ["a", "b"], + ); + assert.equal( + withoutScripts(html, () => true), + "
", + ); +}); + +test("overlapping actual SVG script ranges are removed once without duplicating other bytes", () => { + const nested = + ""; + assert.equal( + withoutScripts(nested + "

keep

", () => true), + "

keep

", + ); + assert.equal( + withoutScripts(nested, (script) => script.id === "inner"), + "", + ); +}); + test("CSS-only control copies only the exact production reservation stylesheet", () => { const css = ''; diff --git a/crates/trusted-server-integration-tests/browser/cwv/run.cjs b/crates/trusted-server-integration-tests/browser/cwv/run.cjs index 2e0f7c6906..e01322d7d4 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/run.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/run.cjs @@ -11,6 +11,7 @@ const { sessionCls, distribution, observePage } = require("./metrics.cjs"); const { reservationStyles, withoutEarlyRenderer, + withoutScripts, rendererAsset, } = require("./html.cjs"); @@ -381,9 +382,8 @@ async function sample( "Renderer absence must remove the real compiled early entry", ); // This fixture-only probe calls core APIs deliberately absent in this case. - active.html = active.html.replace( - /]*>\s*window\.fixtureEarly=[\s\S]*?<\/script>/i, - "", + active.html = withoutScripts(active.html, (script) => + script.content.trimStart().startsWith("window.fixtureEarly="), ); assert( active.html.includes("publisherSlotRegistrationVersion"), @@ -397,9 +397,9 @@ async function sample( }); } if (mode === "absent") { - active.html = input.html.replace( - /]*id="trustedserver-js"[^>]*><\/script>/i, - "", + active.html = withoutScripts( + input.html, + (script) => script.id === "trustedserver-js", ); assert.notEqual( active.html, diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts index 0034a3e5be..ec7f584b65 100644 --- a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts @@ -12,7 +12,11 @@ import { runtimeUrl } from "../../helpers/state.js"; import type { PublisherController } from "../../../fixtures/frameworks/nextjs/app/publisher-registration/RegisteredSlot"; const { creative, pucPage } = require("../../publisher-registration/pages.cjs"); -const { withoutEarlyRenderer, rendererAsset } = require("../../cwv/html.cjs"); +const { + scriptBlocks, + withoutEarlyRenderer, + rendererAsset, +} = require("../../cwv/html.cjs"); const enabled = process.env.PUBLISHER_REGISTRATION_RUN === "1"; test.skip( !enabled, @@ -525,10 +529,9 @@ async function saveEvidence(page: Page, info: TestInfo) { bootstrapSha256: session.html .map( (html) => - [...html.matchAll(/]*>([\s\S]*?)<\/script>/gi)].find( - (script) => - script[1].includes("publisherSlotRegistrationVersion"), - )?.[1], + scriptBlocks(html).find((script: { content: string }) => + script.content.includes("publisherSlotRegistrationVersion"), + )?.content, ) .map((bootstrap) => (bootstrap ? sha256(bootstrap) : null)), payloads: session.payloads, diff --git a/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md b/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md index c24247bca6..037cb8d4c0 100644 --- a/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md +++ b/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md @@ -64,7 +64,7 @@ Merged main `21f43aa0138498023455789f152f5a864bbefe24` into the implementation i Fresh Node 24.20.0/Rust 1.98.1/Viceroy 0.21.0 checks passed 1,519 JS tests, 3,197 native core tests plus 20 native integrations, 61 lab cases, 109 offline contracts, 40 actual Next.js cases, six real-PUC APS cases and all 27 backend/EOF/URL gates. The new Node-labelled image and all 14 actual embedded JS module strings were verified. Independent merge correctness/architecture reviews accepted the resolutions and narrow host-lint exceptions. Standalone TypeScript and unrelated whole-workflow actionlint failures remain disclosed. Hosted CI and real GPT/GAM/field validation are not inferred from these local results. -The unchanged baseline archive and all failed browser/quota/lint attempts remain distinct from successful post-merge runs. The validation record contains final measurements, hashes, commands and evidence limits. +The unchanged baseline archive and all failed browser/quota/lint attempts remain distinct from successful post-merge runs. The first hosted run then flagged four fixture HTML-regex operations. A parser/source-range follow-up replaces them without changing production assets or measured HTML bytes. It passes 31 Node units, actual framework and focused browser checks; independent review accepts the source-order, overlap and EOF handling. Hosted scanning must assess that pushed fix separately. The validation record contains final measurements, hashes, commands and evidence limits. ## 4. Stable combined verification diff --git a/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md b/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md index a1671c2c0e..17bd744406 100644 --- a/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md +++ b/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md @@ -14,21 +14,21 @@ Conflict resolution preserves upstream asynchronous HTML EOF processing, origin ### Updated checks and artifacts -| Check | Result | Evidence under `merge/` | -| --------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------- | -| Full JS suite | 1,519 tests across 52 files passed; full lint and format passed | `js-final-{full,lint,format}.log` | -| Focused GPT/APS suite | 671 tests across nine files passed, including early/runtime APS failure relay | `gpt-focused.log` | -| Native core | Unmodified Cargo invocation passed 3,197 core tests plus two ten-test integration suites | `evidence/gates/core-native.log` | -| Fixture, launcher and CWV units | All 21 passed | `evidence/browser-units.log` | -| Updated lab | All 61 passed, including 40 repeated measurements | `evidence/cwv/` | -| Offline registration | All 109 passed | `evidence/offline-registration/` | -| Actual Rust/Next.js | All 40 passed, 20 inline and 20 shared-template | `evidence/framework/` | -| Initial-render overlap regression | All ten passed against current bundles and the explicit Rubicon/SharedId artifact | `evidence/initial-render/` | -| Real APS Universal Creative | All six passed, no skips or flaky cases | `standard/results.json` | -| Node pin guard | All 30 tests passed; all 27 jobs pin Node before compiling Rust | Coordinator output | -| Documentation proposal scripts | All 20 Node tests, shell tests and ShellCheck passed | Coordinator output and `evidence/docs-proposal.log` | -| Changed integration workflow | actionlint passed | `evidence/actionlint.log` | -| Publisher-registration launcher | ShellCheck passed | `evidence/shellcheck-publisher.log` | +| Check | Result | Evidence under `merge/` | +| --------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------- | +| Full JS suite | 1,519 tests across 52 files passed; full lint and format passed | `js-final-{full,lint,format}.log` | +| Focused GPT/APS suite | 671 tests across nine files passed, including early/runtime APS failure relay | `gpt-focused.log` | +| Native core | Unmodified Cargo invocation passed 3,197 core tests plus two ten-test integration suites | `evidence/gates/core-native.log` | +| Fixture, launcher, CWV and initial-page units | All 31 passed after the parser follow-up | `evidence/codeql-units.log` | +| Updated lab | All 61 passed, including 40 repeated measurements | `evidence/cwv/` | +| Offline registration | All 109 passed | `evidence/offline-registration/` | +| Actual Rust/Next.js | All 40 passed, 20 inline and 20 shared-template | `evidence/framework/` | +| Initial-render overlap regression | All ten passed against current bundles and the explicit Rubicon/SharedId artifact | `evidence/initial-render/` | +| Real APS Universal Creative | All six passed, no skips or flaky cases | `standard/results.json` | +| Node pin guard | All 30 tests passed; all 27 jobs pin Node before compiling Rust | Coordinator output | +| Documentation proposal scripts | All 20 Node tests, shell tests and ShellCheck passed | Coordinator output and `evidence/docs-proposal.log` | +| Changed integration workflow | actionlint passed | `evidence/actionlint.log` | +| Publisher-registration launcher | ShellCheck passed | `evidence/shellcheck-publisher.log` | With `TSJS_PREBUILT_DIR` unset, the fresh release build passed in 1m56s. All 14 complete JS module strings match the independent distribution and occur byte-for-byte in the frozen WASM. WASM SHA-256 is `dad40087f6123d73b8465d774bd3212e7b25b4d6c8ce3051579f263e0d117f23`. @@ -107,6 +107,18 @@ The URL linter reads committed trees. Its first invocation still examined the ea Independent merge correctness and architecture reviews found no defects. They compared moved APS functions and the complete EOF driver against exact main-parent code, and verified actual path/CSP/status/range/header ownership. The original reviewed fingerprint was `0a0130289e0a8e2f090bc323f604349182bf4030b64d0a6b113227b41e3a3cb2`. The final lint-only cleanup changes a fixture error message, one test comment and historical reference comments; production runtime sources remain unchanged. The new code fingerprint is `9df748852ddce84d766bab2b8241bebf3a46f31a720f62f9a0e52a70d533b270`. +### Hosted CodeQL fixture follow-up + +The first full hosted run after the merge exposed four test-fixture HTML-regex findings. The earlier case-insensitivity fix did not cover all HTML parser cases. The affected helpers now identify actual script elements with the existing jsdom dependency and source locations. They remove selected source ranges without DOM reserialization. Raw bootstrap hashes also use parsed source ranges instead of a script-end regex. + +The implementation preserves exact original text, sorts ranges by source offset and removes overlapping ranges as a union. Tests cover HTML parser reordering inside tables, nested SVG scripts, valid self-closing SVG bounds, EOF-terminated scripts, case-sensitive ID values, uppercase tags/attributes, quoted `>` characters, raw CRLF and Unicode offsets, and inert script-looking text. jsdom does not execute the input scripts or load their resources. + +All 31 combined Node tests pass, including 15 CWV units. The actual Rust/Next.js suite passed all 40 cases again with fresh configuration artifacts. An attempted reuse correctly rejected the older helper fingerprint; no verification was bypassed. Three focused CSP/renderer-absence/runtime-failure browser regressions also pass. Evidence is in `evidence/codeql-units.log`, `evidence/framework-codeql-final/` and `evidence/codeql-html-fix/`. + +An external comparison checked 86 frozen HTML files with 430 old/new comparisons. Every relevant derivative and raw-script result is byte-identical, and all 13 recorded interventions from the successful 61-case lab still match their exact hashes. Thus the existing timing/CLS results remain evidence for unchanged browser inputs; they are not presented as a newly timed 61-case execution. Production sources, all 14 runtime assets and Rust behavior did not change. Their passing checks remain applicable. + +Independent correctness review found no remaining parser-helper issues after reproducing and testing the range-order, overlap and EOF cases. The reviewed follow-up code fingerprint is `2e8faabdeb72b06e0221ac552c2c241cde5978ef2b8599654a7d6bf5d1597b34`. The earlier failed units and browser/config attempts remain separate from successful results. Hosted CodeQL must assess the pushed fix; local tests are not a claim that hosted scanning passed. + Hosted CI is tracked separately on PR1460; local results do not establish its outcome. Real GPT/GAM, customer pages, production activation and field CWV remain unverified and require separate approval. ## Historical premerge validation From 3eeec9d3f85a1c4cedb6ee080c6a8ce9a9213cc0 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 2 Nov 2026 18:47:46 -0600 Subject: [PATCH 05/13] Fix merged HTML processor test parameters --- crates/trusted-server-core/src/publisher.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 47ddc0baa0..e8e0329e64 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -10907,6 +10907,8 @@ mod tests { let (gate, decision) = gate_and_decision(&settings); let registry = IntegrationRegistry::new(&settings).expect("should build registry"); let mut processor = create_html_stream_processor(HtmlStreamProcessorParams { + enforced_csp: false, + request_path: "/", origin_host: "origin.example.com", request_host: "publisher.example.com", request_scheme: "https", @@ -19591,6 +19593,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { ad_bids_state: Arc::new(Mutex::new(None)), suppress_datadome_client_side_tag: false, gpt_diagnostics: None, + trace_bootstrap: None, shared_template_authorized, csp_nonce_observed: None, deferred_inline_marker: None, From 9449f613cb3f3ee0e156d5948c9491a08e13689a Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 2 Nov 2026 18:47:46 -0600 Subject: [PATCH 06/13] Preserve enforced CSP with synchronous GPT initialization --- .../trusted-server-core/src/html_processor.rs | 2 +- .../src/integrations/gpt.rs | 90 +++++++++++++++++-- .../src/integrations/registry.rs | 23 ++++- docs/guide/integrations/gpt.md | 8 +- ...-10-29-854-client-cwv-completion-design.md | 2 + 5 files changed, 111 insertions(+), 14 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index a01c678791..b42c4c16a9 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -424,7 +424,7 @@ pub(crate) fn create_html_processor_with_document_state( }; // First inject integration-specific config (e.g., window.__tsjs_prebid) // so it's available when the bundle's auto-init code reads it. - for insert in integrations.head_inserts(&ctx) { + for insert in integrations.head_inserts_with_response_csp(&ctx, enforced_csp) { snippet.push_str(&insert); } if let Some(bootstrap) = gpt_diagnostics diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 9c73964dcc..9f4b2a8cff 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -490,20 +490,31 @@ impl IntegrationHeadInjector for GptIntegration { /// auction via `GET /_ts/page-bids` on pushState / replaceState / popstate /// route changes (see `auction/endpoints.rs`). /// The `POST /auction` endpoint is not involved in scroll or refresh flows. - fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec { + self.head_inserts_with_response_csp(ctx, false) + } + + fn head_inserts_with_response_csp( + &self, + _ctx: &IntegrationHtmlContext<'_>, + enforced_csp: bool, + ) -> Vec { let gam_attribution_flag = if self.config.gam_attribution_enabled { "window.__tsjs_gam_attribution_enabled=true;" } else { "" }; - let mut scripts = vec![ - format!( - "" - ), - format!("", GPT_BOOTSTRAP_JS), - ]; + let mut scripts = vec![format!( + "" + )]; + // The synchronous compiled runtime installs the initializer and registration + // APIs before publisher scripts. Do not introduce a new inline hash requirement + // for the changed provisional bootstrap on pages retaining enforced CSP. + if !enforced_csp { + scripts.push(format!("", GPT_BOOTSTRAP_JS)); + } if let Some(ref url) = self.config.slim_prebid_url { // JSON-encode the URL, then escape `window.publisherSawRegistration=typeof window.tsjs.registerPublisherSlot;"; + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt", &serde_json::json!({"enabled": true})) + .expect("should enable GPT"); + let registry = IntegrationRegistry::new(&settings).expect("should build GPT registry"); + for shared in [false, true] { + let config = HtmlProcessorConfig::from_settings( + &settings, + ®istry, + "origin.example.com", + "edge.example.com", + "https", + ) + .with_enforced_csp(true) + .with_body_close(if shared { + BodyCloseInjection::Marker("".to_string()) + } else { + BodyCloseInjection::InlineBids + }); + let output = create_html_processor(config) + .process_chunk( + format!("{PUBLISHER_HEAD}").as_bytes(), + true, + ) + .expect("should transform an enforced-CSP document"); + let output = String::from_utf8(output).expect("should produce UTF-8 HTML"); + assert!( + !output.contains(GPT_BOOTSTRAP_JS) + && !output.contains("id=\"trustedserver-bootstrap\""), + "should require neither the changed provisional nor compiled inline bootstrap hash" + ); + let activation = output + .find("window.__tsjs_gpt_enabled=true;") + .expect("should retain unchanged GPT activation"); + let runtime = output + .find("id=\"trustedserver-js\"") + .expect("should retain the synchronous unified runtime"); + let publisher = output + .find(PUBLISHER_HEAD) + .expect("should retain publisher code"); + assert!( + activation < runtime && runtime < publisher, + "should install runtime APIs before publisher scripts" + ); + assert!( + output.contains("tsjs-unified.min.js") + && output[runtime..] + .split('>') + .next() + .is_some_and(|tag| !tag.contains(" defer")), + "should retain synchronous loading under enforced response CSP" + ); + } + } + #[test] fn head_inserts_includes_ts_ad_init_with_synchronous_bids_read() { let config = test_config(); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 15a6486fd8..125045813e 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -602,6 +602,17 @@ pub trait IntegrationHeadInjector: Send + Sync { /// Return HTML snippets to insert at the start of ``. fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec; + /// Return head snippets when an enforced response CSP is present. + /// + /// Integrations without a CSP-specific loading dependency retain their existing snippets. + fn head_inserts_with_response_csp( + &self, + ctx: &IntegrationHtmlContext<'_>, + _enforced_csp: bool, + ) -> Vec { + self.head_inserts(ctx) + } + /// Return attributes to add to the publisher TSJS bundle tag. fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { Vec::new() @@ -1161,9 +1172,19 @@ impl IntegrationRegistry { /// Collect HTML snippets for insertion at the start of ``. #[must_use] pub fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec { + self.head_inserts_with_response_csp(ctx, false) + } + + /// Collect head snippets using the actual response CSP decision. + #[must_use] + pub fn head_inserts_with_response_csp( + &self, + ctx: &IntegrationHtmlContext<'_>, + enforced_csp: bool, + ) -> Vec { let mut inserts = Vec::new(); for injector in &self.inner.head_injectors { - let mut next = injector.head_inserts(ctx); + let mut next = injector.head_inserts_with_response_csp(ctx, enforced_csp); if !next.is_empty() { inserts.append(&mut next); } diff --git a/docs/guide/integrations/gpt.md b/docs/guide/integrations/gpt.md index bf5cab7e68..9ebab8de49 100644 --- a/docs/guide/integrations/gpt.md +++ b/docs/guide/integrations/gpt.md @@ -110,13 +110,15 @@ flowchart TD The early entry runs inline, without another synchronous external fetch. It installs the real render bridge before core flushes queued callbacks or publisher scripts can request an eligible creative. Core's direct methods and immediate queue behavior remain available before the runtime arrives. The deferred GPT entry does not initialize core again, so early configuration and registered ad units survive takeover. -Any enforced `Content-Security-Policy` response header selects the original synchronous unified bundle instead of the new inline compiled entry. This includes headers from the actual origin response or `Settings.response_headers`, matched case-insensitively, even when empty, permissive or repeated. An origin response without CSP cannot cancel a configured policy. `Content-Security-Policy-Report-Only` alone does not select this fallback. Publishers with unchanged enforced response CSP therefore need no new compiled-script hash for this loading change: they keep the existing loading path. Policies that might permit deferral still use synchronous loading until separately evaluated. +Any enforced `Content-Security-Policy` response header selects the synchronous unified bundle instead of the new inline compiled entry. This path also omits the provisional raw inline GPT bootstrap. The synchronous runtime installs initialization and registration APIs before publisher scripts, without requiring a new inline bootstrap hash. This includes headers from the actual origin response or `Settings.response_headers`, matched case-insensitively, even when empty, permissive or repeated. An origin response without CSP cannot cancel a configured policy. `Content-Security-Policy-Report-Only` alone does not select this fallback. Publishers with unchanged enforced response CSP therefore need no new compiled-script hash for this loading change: they keep the existing loading path. Policies that might permit deferral still use synchronous loading until separately evaluated. + +If the synchronous runtime fails to load on an enforced-CSP page, the omitted provisional bootstrap cannot provide fallback delivery. This preserves script permissions, not delivery during a blocked or failed synchronous bundle fetch. Shared templates retain the loading choice and policy headers selected on the cold response and replay them on warm hits. Settings and core fingerprints invalidate older templates. This fallback checks response headers only; no meta-policy fallback is promised. It does not weaken CSP, add nonce handling or introduce an operator flag. -The existing inline GPT bootstrap remains responsible for provisional `adInit`, initial scheduling, publisher registration and navigation generations. The deferred runtime installs the richer initializer, publisher-slot handoff, SPA page-bids fetching and slim-Prebid loader. Both entries share the same guard and rendering implementations. A delayed or failed runtime does not remove the installed early renderer or authorize another initial request. +On pages without enforced response CSP, the inline GPT bootstrap remains responsible for provisional `adInit`, initial scheduling, publisher registration and navigation generations. The deferred runtime installs the richer initializer, publisher-slot handoff, SPA page-bids fetching and slim-Prebid loader. Both entries share the same guard and rendering implementations. A delayed or failed runtime does not remove the installed early renderer or authorize another initial request. -This still executes synchronous JavaScript. The raw GPT bootstrap alone is 56,429 source bytes, before the compiled early entry. Compare parser/paint timing and ad-startup timing with repeatable measurements; deferral is not a guarantee of better field Core Web Vitals. +This still executes synchronous JavaScript. The non-CSP path retains the raw GPT bootstrap as well as the compiled early entry; the acceptance report records their exact sizes for the tested revision. Compare parser/paint timing and ad-startup timing with repeatable measurements; deferral is not a guarantee of better field Core Web Vitals. The following combinations keep the original synchronous unified bundle. Unknown browser integrations also fall back rather than being silently deferred. diff --git a/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md b/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md index 4672fb4678..192e938252 100644 --- a/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md +++ b/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md @@ -50,6 +50,8 @@ Document every current integration's early dependency and selection outcome. Ini An enforced Content-Security-Policy response header from the origin or configured response headers also retains the original synchronous unified path. Presence is sufficient, even for a permissive policy; there is no new general CSP evaluator. Report-Only alone does not force fallback. This choice is independent of visitor/auction admission and retained in shared-template output. Header-only selection does not promise detection of policies introduced later in document markup. +The matched original-main acceptance comparison also exposed a changed hash in the provisional raw GPT bootstrap. On enforced-response-CSP pages, omit that provisional inline bootstrap and let the synchronous unified runtime install the APIs before publisher scripts. Retain the unchanged activation marker. Do not add a new inline permission requirement. This path has no provisional-bootstrap fallback if the synchronous runtime itself fails to load; its browser regression must record that boundary explicitly. + This rule follows an executed review counterexample: an old deployment allowing same-origin external scripts and the old inline hashes lost core APIs when the new default inline entry was denied. Documentation alone did not preserve compatibility. The conservative fallback gives up deferral on some compatible CSP pages rather than silently require new permissions. Do not weaken CSP, add nonce handling or add an operator loading-mode flag. Do not add parallel experimental loading modes or a new operator flag merely to conceal uncertainty. Keep fallback explicit in source and tests. Adding a newly supported profile later requires ordering coverage. From 2fc2acbf67b9aabf04e8184a87725b33fa02afd1 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 2 Nov 2026 18:47:46 -0600 Subject: [PATCH 07/13] Remove unused provisional bootstrap candidate guard --- .../src/integrations/gpt_bootstrap.js | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index d7ab6be93d..3aef7d1669 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -365,45 +365,6 @@ targetingKeys[record.elementId] = record.targetingKeys; } } - function registeredCandidateAllowed(ts, slotId, bid) { - var _a, _b, _c, _d, _e, _f, _g, _h; - const record = - ((_a = ts.publisherSlotRegistrationState) == null - ? void 0 - : _a.generation) === ((_b = ts.navGeneration) != null ? _b : 0) - ? ts.publisherSlotRegistrationState.records[slotId] - : void 0; - if (record) { - if ( - !record.consumed || - record.selectedCandidate !== bid || - !((_c = record.element) == null ? void 0 : _c.isConnected) || - record.element.id !== record.elementId || - document.getElementById(record.elementId) !== record.element - ) - return false; - try { - return ( - !!record.gptSlot && - !!((_g = - (_e = (_d = registrationTag()) == null ? void 0 : _d.pubads) == null - ? void 0 - : (_f = _e.call(_d)).getSlots) == null - ? void 0 - : _g.call(_f).includes(record.gptSlot)) - ); - } catch (e) { - return false; - } - } - return !((_h = ts.adSlots) == null - ? void 0 - : _h.some( - (slot) => - slot.id === slotId && - slot.initialization === "publisher_registered", - )); - } function observePublisherSlotRender(ts, native) { var _a, _b; const state = ts.publisherSlotRegistrationState; From 9530f23b1b888f070892e576c4380551d334c79e Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 2 Nov 2026 18:47:46 -0600 Subject: [PATCH 08/13] Clear registration type errors without changing runtime bundles --- .../src/integrations/gpt/publisher_registration.ts | 12 +++++++++--- .../lib/test/core/first_impression.test.ts | 4 ++-- .../test/fixtures/publisher_registration_contract.ts | 4 ++-- .../lib/test/integrations/prebid/index.test.ts | 4 +++- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts b/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts index bdea96a8cd..81701e1cad 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts @@ -111,7 +111,7 @@ export function registeredCandidateAllowed( } /** Observe only an exact current native registration's render, not an ID alias. */ -export function observePublisherSlotRender(ts: TsjsApi, native: PublisherGptSlot): void { +export function observePublisherSlotRender(ts: TsjsApi, native: object): void { const state = ts.publisherSlotRegistrationState; if (state?.generation !== (ts.navGeneration ?? 0)) return; for (const record of Object.values(state.records)) { @@ -379,8 +379,14 @@ export function installPublisherSlotRegistration( record.targetingKeys = Object.keys(targeting); for (const [key, value] of Object.entries(targeting)) native.setTargeting(key, value); first.targeting = targeting; - ts.divToSlotId = Object.assign(Object.create(null), ts.divToSlotId); - ts.prevSlotTargetingKeys = Object.assign(Object.create(null), ts.prevSlotTargetingKeys); + ts.divToSlotId = Object.assign(Object.create(null), ts.divToSlotId) as Record< + string, + string + >; + ts.prevSlotTargetingKeys = Object.assign( + Object.create(null), + ts.prevSlotTargetingKeys + ) as Record; retainPublisherSlotMappings(ts, ts.divToSlotId, ts.prevSlotTargetingKeys); try { ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( diff --git a/crates/trusted-server-js/lib/test/core/first_impression.test.ts b/crates/trusted-server-js/lib/test/core/first_impression.test.ts index 77c401f0e0..40c2bdf12d 100644 --- a/crates/trusted-server-js/lib/test/core/first_impression.test.ts +++ b/crates/trusted-server-js/lib/test/core/first_impression.test.ts @@ -27,10 +27,10 @@ describe('initial render capacity and diagnostics', () => { (id) => { document.body.firstElementChild!.id = id; const element = document.getElementById(id)!; - const ts: TsjsApi = {}; + const ts = {} as TsjsApi; const claim = claimFirstImpressionForTrustedServer(ts, element)!; expect(claim.owner).toBe('trusted_server'); - expect(Object.hasOwn(ts.firstImpression!.slots, id)).toBe(true); + expect(Object.prototype.hasOwnProperty.call(ts.firstImpression!.slots, id)).toBe(true); expect(Object.getPrototypeOf(ts.firstImpression!.slots)).toBeNull(); const token = registerPublisherFirstImpressionAuctions(ts, [id]).get(id)!; expect(consumePublisherFirstImpressionDelivery(ts, token)).toBe(true); diff --git a/crates/trusted-server-js/lib/test/fixtures/publisher_registration_contract.ts b/crates/trusted-server-js/lib/test/fixtures/publisher_registration_contract.ts index c5774d20a1..268373bbe3 100644 --- a/crates/trusted-server-js/lib/test/fixtures/publisher_registration_contract.ts +++ b/crates/trusted-server-js/lib/test/fixtures/publisher_registration_contract.ts @@ -256,7 +256,7 @@ export function publisherRegistrationContract(implementation: Implementation): v const { slot } = mount(); const handle = ts.registerPublisherSlot!({ ...options(slot), - eligibleFormats: formats as ReadonlyArray, + eligibleFormats: formats as unknown as ReadonlyArray, }); expect(handle.getStatus()).toEqual({ status: 'rejected', reason: 'invalid_arguments' }); expect(nativeDisplay).not.toHaveBeenCalled(); @@ -540,7 +540,7 @@ export function publisherRegistrationContract(implementation: Implementation): v const handle = ts.registerPublisherSlot!({ ...options(slot), slotId: '__proto__' }); await settle(); expect(handle.getStatus().status).toBe('requested'); - expect(Object.hasOwn(ts.divToSlotId!, '__proto__')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(ts.divToSlotId!, '__proto__')).toBe(true); expect(ts.divToSlotId!['__proto__']).toBe('__proto__'); expect(ts.firstImpression?.slots['__proto__']?.owner).toBe('trusted_server'); expect(Object.getPrototypeOf(ts.publisherSlotRegistrationState!.records)).toBeNull(); 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 index e20ebf69e3..23cc13615e 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -179,6 +179,8 @@ interface InjectedPrebidTestConfig { interface TestGoogletag { cmd: { push: (fn: () => void) => void }; pubads: () => unknown; + getConfig?: () => { disableInitialLoad?: boolean }; + display?: (id: string) => void; } interface ApsPrebidTestEntry { @@ -4239,7 +4241,7 @@ describe('prebid/installRefreshHandler', () => { ], bids: {}, } as TsjsApi; - testWindow.tsjs = ts; + window.tsjs = ts; const frames: FrameRequestCallback[] = []; const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { frames.push(callback); From 24a438e1e7e3f8aa2427a31968abd47d98f2cf7c Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 2 Nov 2026 18:47:46 -0600 Subject: [PATCH 09/13] Add closure acceptance tests and record warm-cache regression Keep issue 854 open: functional checks pass, but warm-cache paint and ad startup regress and dedicated real-GPT inventory is unavailable. Record all 200 measurement rows and exact-byte reuse proof. Canonicalize artifact fingerprints and make interrupted live runs fail without retaining stale success. --- .../browser/cwv/README.md | 140 +- .../browser/cwv/evidence.cjs | 146 + .../browser/cwv/evidence.test.cjs | 186 + .../browser/cwv/html.cjs | 14 + .../browser/cwv/html.test.cjs | 16 + .../browser/cwv/metrics.cjs | 37 + .../browser/cwv/metrics.test.cjs | 33 +- .../browser/cwv/options.cjs | 94 + .../browser/cwv/options.test.cjs | 88 + .../browser/cwv/proxy.cjs | 70 + .../browser/cwv/proxy.test.cjs | 125 + .../browser/cwv/run.cjs | 455 +- .../browser/package.json | 2 + .../browser/playwright.config.ts | 2 + ....publisher-registration.real-gpt.config.ts | 31 + .../publisher-registration/real-gpt.README.md | 125 + .../publisher-registration/real-gpt.cjs | 477 ++ .../publisher-registration/real-gpt.test.cjs | 384 ++ .../browser/publisher-registration/run.cjs | 6 +- .../publisher-registration.real-gpt.spec.ts | 919 ++++ .../nextjs/app/api/fixture-auction/route.ts | 23 +- .../RealGptPublisher.tsx | 347 ++ .../no-bid/page.tsx | 2 + .../publisher-registration-real-gpt/page.tsx | 37 + .../provider-control.ts | 34 + .../test/prebid-artifact-integration.test.mjs | 10 +- .../2026-11-02-854-closure-acceptance.md | 165 + .../2026-11-02-854-cwv-measurements.json | 4507 +++++++++++++++++ scripts/prepare-client-cwv-baseline.mjs | 183 + scripts/prepare-client-cwv-baseline.test.mjs | 61 + scripts/test-client-cwv-acceptance.mjs | 607 +++ scripts/test-client-cwv-acceptance.test.mjs | 268 + .../test-publisher-registration-real-gpt.mjs | 985 ++++ ...t-publisher-registration-real-gpt.test.mjs | 347 ++ scripts/test-publisher-registration.sh | 6 +- 35 files changed, 10812 insertions(+), 120 deletions(-) create mode 100644 crates/trusted-server-integration-tests/browser/cwv/evidence.cjs create mode 100644 crates/trusted-server-integration-tests/browser/cwv/evidence.test.cjs create mode 100644 crates/trusted-server-integration-tests/browser/cwv/options.cjs create mode 100644 crates/trusted-server-integration-tests/browser/cwv/options.test.cjs create mode 100644 crates/trusted-server-integration-tests/browser/cwv/proxy.cjs create mode 100644 crates/trusted-server-integration-tests/browser/cwv/proxy.test.cjs create mode 100644 crates/trusted-server-integration-tests/browser/playwright.publisher-registration.real-gpt.config.ts create mode 100644 crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.README.md create mode 100644 crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.cjs create mode 100644 crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.test.cjs create mode 100644 crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/RealGptPublisher.tsx create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/no-bid/page.tsx create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/page.tsx create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/provider-control.ts create mode 100644 docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md create mode 100644 docs/superpowers/reviews/2026-11-02-854-cwv-measurements.json create mode 100644 scripts/prepare-client-cwv-baseline.mjs create mode 100644 scripts/prepare-client-cwv-baseline.test.mjs create mode 100644 scripts/test-client-cwv-acceptance.mjs create mode 100644 scripts/test-client-cwv-acceptance.test.mjs create mode 100644 scripts/test-publisher-registration-real-gpt.mjs create mode 100644 scripts/test-publisher-registration-real-gpt.test.mjs diff --git a/crates/trusted-server-integration-tests/browser/cwv/README.md b/crates/trusted-server-integration-tests/browser/cwv/README.md index 6e8054ff20..0e5e1a5bbf 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/README.md +++ b/crates/trusted-server-integration-tests/browser/cwv/README.md @@ -51,18 +51,62 @@ node crates/trusted-server-integration-tests/browser/cwv/run.cjs \ --runs 5 --out /tmp/cwv-evidence ``` -Four comparisons alternate forward/reverse order in fresh contexts at 390x844 -and 1280x1000. Baseline and changed HTML remain untouched. The reservation-only -comparison is an explicitly recorded CSS-only isolation derivative: baseline -HTML and JS plus the exact production reservation style emitted in the changed -reserved document. It does not imply that old code understood the new config. -Every derived document and copied style has its own hash. - -The server holds each first main-runtime response for 750ms. This makes parser -blocking observable without appending CPU loops to production JS. No CPU -throttling or external network shaping is applied. Script resource timing and -FCP must demonstrate the intended blocking/deferred ordering, but no arbitrary -millisecond improvement threshold decides whether measurements pass. +Four arms alternate forward/reverse order at 390x844 and 1280x1000. Each sample +starts in a fresh context, including warm samples before priming. The arms are: + +- `baseline`: frozen pre-change production HTML and JS, without reservation. +- `loading`: changed production HTML and JS, without reservation. +- `reservation`: baseline HTML and JS plus the exact production reservation style + from the changed reserved document. This CSS-only isolation derivative does not + imply that old code understood the new config. +- `combined`: changed production HTML and JS with reservation. + +Baseline and changed measurement HTML remain untouched. Every isolation derivative +and copied style has its own hash. All arms use the same selected conditions. + +| Option | Default | Meaning | +| ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--runtime-delay-ms NUMBER` | `750` | Hold the first main-runtime HTTP response per navigation. `0` is valid. Cache hits do not contact the server and therefore have no hold. Retries are not held. | +| `--cpu-rate NUMBER` | `1` | Chromium CDP CPU slowdown rate, at least 1. | +| `--network-latency-ms NUMBER` | `0` | CDP request latency in milliseconds. | +| `--download-kbps NUMBER` | `0` | CDP download rate in decimal kilobits/second. `0` means unlimited. | +| `--upload-kbps NUMBER` | `0` | CDP upload rate in decimal kilobits/second. `0` means unlimited. | +| `--cache cold\|warm` | `cold` | Cold disables HTTP cache and serves no-store responses. Warm primes then navigates again in the same page/context. | + +Numeric options must be finite and nonnegative except CPU rate, which must be at +least 1, and runs, which must be a positive safe integer. Runtime delay must also +fit Node's timer range. Invalid options fail before browser startup. + +Warm responses cache successful exported JS, the hero image and the creative JS +for one hour. HTML, creative documents and failures remain no-store. Priming uses +the exact sample document, runtime-failure mode, viewport and selected conditions. +It waits for delivery when matched and retains its own observer/network evidence. +The measured navigation must show a native CDP HTTP-cache hit. A successful runtime +must itself hit cache. Service workers are blocked and do not count as HTTP cache. +No Playwright routes are installed because routing disables Chromium HTTP cache. +An HTTP proxy forwards only the exact publisher and creative fixture origins, +preserving response headers and browser HTTP caching. It rejects every other +origin and all CONNECT tunnels. It does not depend on browser loopback-bypass +settings. Unexpected URLs still fail assertions. The origin allowlist and proxy +requests are retained in `artifacts.json`. + +For example, run the ordinary response with warm cache and explicit throttling: + +```sh +node crates/trusted-server-integration-tests/browser/cwv/run.cjs \ + --baseline /tmp/cwv-baseline --loading /tmp/cwv-loading \ + --combined /tmp/cwv-combined --out /tmp/cwv-warm \ + --runtime-delay-ms 0 --cache warm --cpu-rate 4 \ + --network-latency-ms 150 --download-kbps 1600 --upload-kbps 750 +``` + +Default cold runs retain the existing 750ms held-response FCP check. Native +captured script-load events also check whether the parser has reached the article: +blocking runtime must load before it, deferred runtime after it. This ordering +check does not depend on a slow response. A normal 0ms response or a warm cache hit +may legitimately finish before FCP. No timing-improvement threshold decides whether +measurements pass. CPU and network shaping apply to all fixture resources, not +just the runtime, and shared-host contention can still affect results. `--correctness-only` omits the baseline comparison and repeated timing samples. CI uses this mode against freshly exported changed artifacts, then uploads all @@ -70,14 +114,30 @@ measurement evidence. Full before/after comparison is an explicit local command because CI does not reconstruct historical production builds. For the review regressions alone, add `--regressions-only` instead. This runs -three focused cases: true renderer absence, deferred-runtime failure with the -early renderer retained, and enforced old-inline-hash/self CSP. Supply the frozen +four focused cases: true renderer absence, deferred-runtime failure with the +early renderer retained, successful enforced old-inline-hash/self CSP, and failure +of the synchronous runtime under that same CSP. Supply the frozen `--baseline` export to derive hashes from genuinely pre-change inline script text. Without it, CI explicitly records hashes from the current synchronous legacy-inline output. Neither policy permits the new compiled early entry. The exporter marks the CSP document with `with_enforced_csp(true)` and the runner sends the matching enforced header. Fictional creative-inline hashes are also -included; the synchronous fallback must render without CSP violations. +included; the synchronous fallback must render without CSP violations. The normal +CSP case also checks every compiled core API, registration/scheduling APIs, shim +hook, and protocol/renderer versions with neither raw nor compiled early inline +bootstrap present. This is a reproducible check of the exact runtime bytes loaded +by the browser, not just a separately evaluated source module. + +The failed synchronous-runtime case establishes a different boundary. It requires +core, shim and renderer APIs to remain unavailable, zero native requests and no TS +delivery, while still having no blocked inline scripts. The existing core-dependent +publisher probe must produce exactly its declared `tsjs.setConfig is not a function` +error; no other page/network errors are accepted. This case does not claim a +provisional fallback exists or that failed-runtime behavior matches normal CSP +delivery. It remains a separate sample and never relaxes the successful CSP gate. + +Full mode has `8 * runs + 24` samples, correctness-only has 26, and regressions-only +has 4. The completion schema is unchanged. ## Observations and functional gates @@ -85,12 +145,24 @@ included; the synchronous fallback must render without CSP violations. candidate, resources and raw layout shifts with source geometry. - CLS is the largest session-window sum, excluding recent input, with one-second gaps and a five-second maximum window. Unit tests cover these boundaries. -- Navigation timing retains DOM interactive and DOMContentLoaded. Missing required - observers, paint metrics or declared fixture cases fail the run. +- Navigation timing retains DOM interactive and DOMContentLoaded. Native script + load events retain parser/article state. Missing required observers, paint + metrics, runtime load evidence or declared fixture cases fail the run. +- Except in `--regressions-only`, two negative controls derive from the reserved + changed document. Removing only the runtime's `defer` attribute must reproduce + parser blocking through native load/parser observations and FCP/resource timing. + Removing only the production reservation styles must produce article movement + and positive observer-reported CLS with a source inside the article. These are + separate correctness samples, never mixed into the four measurement arms. Their + exact HTML and hashes are retained. These controls execute only when the browser + runner runs; the focused Node tests alone do not establish browser behavior. - The transport fixture records exact native requests and request-time targeting. It installs before production bootstrap and does not deduplicate TS requests. - Real Universal Creative renders the server bid in an iframe. Assertions inspect - actual nested creative DOM, not just a render event. First-visible time is + actual nested creative DOM, not just a render event. Original-main baseline and + CSS-only reservation arms predate the new renderer protocol marker; they must + still pass actual Server creative, candidate-targeting and one-request checks. + Candidate arms additionally require renderer protocol version 1. First-visible time is named `firstVisibleEstimateMs`: it combines creative-ready, visible frame bounds and an animation-frame barrier, not hardware paint or a GAM impression. - Compatible reservation must preserve article geometry and zero controlled @@ -117,15 +189,41 @@ included; the synchronous fallback must render without CSP violations. ## Evidence +Export input filenames remain `manifest.json`, the declared HTML/assets and +`app.toml`. No new export format is required. + +`acceptance.json` is the machine-readable completion record, also mirrored as +`artifacts.json.outcome`. Its `schemaVersion` is 1. `status` is `running`, `passed`, +`failed` for an assertion/runtime failure, or `blocked` for a missing export, +dependency or Chromium executable. `passed` means the selected scope completed, +including report writes and browser/server cleanup. The scope is +`measurement-and-correctness`, `correctness`, or `regressions`. It does not certify +any live publisher or real-GPT behavior. `conditions`, `samples`, `profileSamples`, +timestamps and an error when unsuccessful describe the run. + +`negativeControls` has `parser-blocking` and `reservation-removed` entries. Each +entry has a `status` of `pending`, `running`, `passed`, `failed`, `blocked` or +`not-requested`; completed controls include `sampleIndex` and `htmlFile`. A +regressions-only run records `not-requested` and the reason explicitly. Validate +both the overall status and the scope/control statuses when consuming evidence. +The runner initializes these outcomes before reading exports, so a failed rerun +cannot retain an earlier passed outcome. Malformed CLI arguments fail before +an output directory is established and do not produce a completion record. + `artifacts.json` retains exact inputs, provenance, browser/Node versions, runner -hashes, settings and interventions. `samples.json` retains all raw observations -and per-sample geometry, native events and network calls. `summary.json` reports +and helper hashes, normalized conditions, cache semantics and interventions. +`samples.json` retains all raw observations and per-sample geometry, native events, +CDP requests/cache-hit flags, server responses with applied delay, network calls +and separate warmup evidence. Each sample records the selected conditions and any +negative control. `summary.json` reports sample count, median, minimum and maximum for each metric, with repeated measurement samples kept separate from functional cases. `comparisons.json` reports signed median deltas and percentages where the baseline is nonzero. Raw/gzip/Brotli sizes cover HTML, individual inline tags, all inline JS and the remaining fetched runtime. Screenshots and traces include every sample; failures -also retain observer/native state when available. +also retain observer/native state, server responses and available cache evidence. +Use a different `--out` directory for each condition set. The runner records one +selected condition set per invocation, not an implicit device/network matrix. A fast first paint can coexist with later ad startup because the retained bootstrap scheduler waits for page load. Report both, including regressions. diff --git a/crates/trusted-server-integration-tests/browser/cwv/evidence.cjs b/crates/trusted-server-integration-tests/browser/cwv/evidence.cjs new file mode 100644 index 0000000000..186775f1e1 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/evidence.cjs @@ -0,0 +1,146 @@ +const assert = require("node:assert/strict"); + +function assertRuntimeOrdering(raw, runtimeUrl, deferred) { + const load = raw.scriptLoads.find((entry) => entry.src === runtimeUrl); + assert( + load, + "Successful runtime must have a captured native script load event", + ); + assert.equal( + load.articlePresent, + deferred, + deferred + ? "Deferred runtime must load after the parser reaches the article" + : "Parser-blocking runtime must load before the parser reaches the article", + ); + return load; +} + +function assertCacheUse(requests, runtimeUrl, requireRuntime) { + const hits = requests.filter( + (entry) => entry.servedFromCache || entry.fromDiskCache, + ); + assert( + hits.length > 0, + "Warm measurement must demonstrate actual HTTP cache reuse", + ); + if (requireRuntime) + assert( + hits.some((entry) => entry.url === runtimeUrl), + "Warm successful runtime must be served from HTTP cache", + ); + return hits; +} + +function assertReservationControl(raw, cls) { + const before = raw.geometry.find((entry) => entry.phase === "before-fill"); + const after = raw.geometry.at(-1); + assert(before && after, "Reservation control requires before/after geometry"); + assert( + after.article.top > before.article.top, + "Removing reservation must move the article during fill", + ); + assert(cls > 0, "Removing reservation must produce observer-reported CLS"); + assert( + raw.shifts.some( + (entry) => + !entry.hadRecentInput && + entry.value > 0 && + entry.sources.some( + (source) => source.node === "article" || source.withinArticle, + ), + ), + "Layout-shift observer must attribute reservation-control movement to the article", + ); +} + +function assertCandidateRendererVersion(raw, profile) { + // Both isolation arms use original-main JS, which predates this protocol marker. + if (profile === "baseline" || profile === "reservation") return; + assert.equal( + raw.rendererVersion, + 1, + "Candidate renderer must remain installed when only deferred runtime fails", + ); +} + +function assertVisibleCreative(text, firstVisibleEstimateMs, expectedLabel) { + assert( + text.flat().includes(expectedLabel), + "Actual creative iframe DOM must contain the expected creative", + ); + assert( + Number.isFinite(firstVisibleEstimateMs), + "Visible ad estimate must be observed", + ); +} + +function assertCspRuntimeApis(raw, available) { + for (const name of [ + "setConfig", + "getConfig", + "requestAds", + "addAdUnits", + "renderAdUnit", + "renderAllAdUnits", + "adInit", + "scheduleInitialAdInit", + "registerPublisherSlot", + ]) + assert.equal( + raw.apis.methods[name], + available ? "function" : "undefined", + `CSP runtime API ${name}`, + ); + assert.equal( + raw.apis.shim, + available ? "function" : "undefined", + "CSP runtime shim API", + ); + assert.equal( + raw.apis.registrationVersion, + available ? 1 : null, + "CSP registration protocol availability", + ); + assert.equal( + raw.apis.rendererVersion, + available ? 1 : null, + "CSP renderer availability", + ); + if (!available) { + assert.equal( + raw.early.core, + false, + "Failed synchronous runtime must leave core unavailable", + ); + assert.equal( + raw.early.queued, + false, + "Core-dependent fixture callback must not execute", + ); + assert.deepEqual( + raw.fixture.requests, + [], + "Unavailable CSP runtime must not initiate TS delivery", + ); + assert.equal( + raw.delivered, + false, + "Unavailable CSP runtime must not report delivery", + ); + assert.equal( + raw.firstVisibleEstimateMs, + null, + "Unavailable CSP runtime must not report a visible ad", + ); + } +} + +module.exports = { + assertRuntimeOrdering, + assertCspRuntimeApis, + assertCandidateRendererVersion, + assertVisibleCreative, + assertCacheUse, + assertReservationControl, +}; diff --git a/crates/trusted-server-integration-tests/browser/cwv/evidence.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/evidence.test.cjs new file mode 100644 index 0000000000..6cb23e8f64 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/evidence.test.cjs @@ -0,0 +1,186 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { + assertRuntimeOrdering, + assertCacheUse, + assertReservationControl, + assertCspRuntimeApis, + assertCandidateRendererVersion, + assertVisibleCreative, +} = require("./evidence.cjs"); +const url = "http://127.0.0.1/runtime.js"; + +test("native load/parser evidence works without a forced response delay", () => { + for (const deferred of [true, false]) { + const raw = { + scriptLoads: [{ src: url, articlePresent: deferred, time: 2 }], + }; + assertRuntimeOrdering(raw, url, deferred); + assert.throws(() => assertRuntimeOrdering(raw, url, !deferred)); + } + assert.throws(() => assertRuntimeOrdering({ scriptLoads: [] }, url, true)); +}); + +test("warm cache evidence requires a real memory/disk hit, not a warm label or status 200", () => { + assert.throws(() => assertCacheUse([{ url, status: 200 }], url, true)); + for (const hit of [{ servedFromCache: true }, { fromDiskCache: true }]) + assert.equal(assertCacheUse([{ url, ...hit }], url, true).length, 1); + const other = [{ url: "http://127.0.0.1/hero.svg", servedFromCache: true }]; + assert.throws(() => assertCacheUse(other, url, true)); + assert.equal(assertCacheUse(other, url, false).length, 1); + assert.throws(() => + assertCacheUse([{ url, fromServiceWorker: true }], url, true), + ); +}); + +test("reservation negative control requires both geometry and attributed observer shift", () => { + const raw = { + geometry: [ + { phase: "before-fill", article: { top: 100 } }, + { phase: "finished", article: { top: 350 } }, + ], + shifts: [ + { value: 0.1, hadRecentInput: false, sources: [{ node: "article" }] }, + ], + }; + assertReservationControl(raw, 0.1); + assert.throws(() => assertReservationControl(raw, 0)); + assert.throws(() => assertReservationControl({ ...raw, shifts: [] }, 0.1)); + assert.throws(() => + assertReservationControl( + { + ...raw, + shifts: [ + { value: 0.1, hadRecentInput: true, sources: [{ node: "article" }] }, + ], + }, + 0.1, + ), + ); + assert.throws(() => + assertReservationControl( + { ...raw, geometry: [raw.geometry[0], raw.geometry[0]] }, + 0.1, + ), + ); +}); + +test("legacy arms need actual visible rendering, not the candidate-only renderer marker", () => { + for (const profile of ["baseline", "reservation"]) { + assertCandidateRendererVersion({}, profile); + assertVisibleCreative([["Server result"]], 250, "Server result"); + assert.throws(() => assertVisibleCreative([], 250, "Server result")); + assert.throws(() => + assertVisibleCreative([["Publisher result"]], 250, "Server result"), + ); + assert.throws(() => + assertVisibleCreative([["Server result"]], null, "Server result"), + ); + } + for (const profile of ["loading", "combined"]) { + assert.throws(() => assertCandidateRendererVersion({}, profile)); + assert.throws(() => + assertCandidateRendererVersion({ rendererVersion: 0 }, profile), + ); + assertCandidateRendererVersion({ rendererVersion: 1 }, profile); + } +}); + +test("CSP API proof distinguishes working compiled runtime from unavailable no-fallback boundary", () => { + for (const available of [true, false]) { + const raw = { + apis: { + methods: Object.fromEntries( + [ + "setConfig", + "getConfig", + "requestAds", + "addAdUnits", + "renderAdUnit", + "renderAllAdUnits", + "adInit", + "scheduleInitialAdInit", + "registerPublisherSlot", + ].map((name) => [name, available ? "function" : "undefined"]), + ), + shim: available ? "function" : "undefined", + registrationVersion: available ? 1 : null, + rendererVersion: available ? 1 : null, + }, + early: { core: available, queued: available }, + fixture: { requests: [] }, + delivered: false, + firstVisibleEstimateMs: null, + }; + assertCspRuntimeApis(raw, available); + assert.throws(() => assertCspRuntimeApis(raw, !available)); + if (available) { + delete raw.apis.methods.registerPublisherSlot; + assert.throws(() => assertCspRuntimeApis(raw, true)); + } else { + raw.fixture.requests.push({ index: 0 }); + assert.throws(() => assertCspRuntimeApis(raw, false)); + } + } +}); + +for (const status of ["blocked", "failed"]) + test(`setup ${status} replaces a previous successful outcome without launching a browser`, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cwv-outcome-")); + try { + const output = path.join(root, "out"); + const input = path.join(root, "input"); + fs.mkdirSync(output); + fs.writeFileSync( + path.join(output, "acceptance.json"), + JSON.stringify({ status: "passed" }), + ); + fs.writeFileSync( + path.join(output, "artifacts.json"), + JSON.stringify({ outcome: { status: "passed" } }), + ); + if (status === "failed") { + fs.mkdirSync(input); + fs.writeFileSync( + path.join(input, "manifest.json"), + JSON.stringify({ schemaVersion: 999 }), + ); + } + const result = spawnSync( + process.execPath, + [ + path.join(__dirname, "run.cjs"), + "--loading", + input, + "--combined", + input, + "--out", + output, + "--correctness-only", + ], + { encoding: "utf8" }, + ); + assert.equal(result.status, 1, result.stderr); + const outcome = JSON.parse( + fs.readFileSync(path.join(output, "acceptance.json")), + ); + const artifacts = JSON.parse( + fs.readFileSync(path.join(output, "artifacts.json")), + ); + assert.equal(outcome.status, status, result.stderr); + assert.deepEqual(artifacts.outcome, outcome); + assert(outcome.error.message); + assert(outcome.finishedAt); + assert.equal(outcome.samples, 0); + assert.equal( + outcome.negativeControls["parser-blocking"].status, + "pending", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); diff --git a/crates/trusted-server-integration-tests/browser/cwv/html.cjs b/crates/trusted-server-integration-tests/browser/cwv/html.cjs index da6ad47117..e3f5750b6f 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/html.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/html.cjs @@ -57,6 +57,19 @@ function withoutEarlyRenderer(html) { ); } +function withoutRuntimeDefer(html) { + const dom = new JSDOM(html, { includeNodeLocations: true }); + try { + const script = dom.window.document.querySelector("script#trustedserver-js"); + const location = script && dom.nodeLocation(script)?.attrs?.defer; + return location + ? html.slice(0, location.startOffset) + html.slice(location.endOffset) + : html; + } finally { + dom.window.close(); + } +} + function rendererAsset(pathname) { return /^\/static\/tsjs=tsjs-(?:gpt|unified)(?:\.min)?\.js$/.test(pathname); } @@ -66,5 +79,6 @@ module.exports = { scriptBlocks, withoutScripts, withoutEarlyRenderer, + withoutRuntimeDefer, rendererAsset, }; diff --git a/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs index 5e2611fa0c..1f464ac596 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/html.test.cjs @@ -5,6 +5,7 @@ const { scriptBlocks, withoutScripts, withoutEarlyRenderer, + withoutRuntimeDefer, rendererAsset, } = require("./html.cjs"); @@ -163,3 +164,18 @@ test("CSS-only control copies only the exact production reservation stylesheet", assert.deepEqual(reservationStyles(html), [css]); assert.deepEqual(reservationStyles(""), []); }); + +test("parser-blocking control removes only the actual runtime defer attribute", () => { + const prefix = + '\r\n'; + const suffix = + '
Keep bytes
'; + for (const attribute of ["defer", 'DEFER=""', "defer='defer'"]) { + const tag = ``; + assert.equal( + withoutRuntimeDefer(prefix + tag + suffix), + prefix + tag.replace(` ${attribute} src=`, " src=") + suffix, + ); + } + assert.equal(withoutRuntimeDefer("

No runtime

"), "

No runtime

"); +}); diff --git a/crates/trusted-server-integration-tests/browser/cwv/metrics.cjs b/crates/trusted-server-integration-tests/browser/cwv/metrics.cjs index 9ee58cbb5a..6cd6fd0647 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/metrics.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/metrics.cjs @@ -44,12 +44,29 @@ function observePage() { lcp: [], shifts: [], resources: [], + scriptLoads: [], geometry: [], firstVisibleEstimateMs: null, delivered: false, errors: [], cspViolations: [], }); + // Capturing native load events distinguishes parser-blocking from defer even + // when a 0ms response or a cache hit finishes before the first paint. + document.addEventListener( + "load", + (event) => { + if (event.target?.tagName !== "SCRIPT") return; + state.scriptLoads.push({ + id: event.target.id, + src: event.target.src, + time: performance.now(), + readyState: document.readyState, + articlePresent: !!document.getElementById("article"), + }); + }, + true, + ); const observers = []; const identifier = (node) => node?.id || node?.tagName || null; const record = (type, entries) => { @@ -69,6 +86,7 @@ function observePage() { hadRecentInput: entry.hadRecentInput, sources: (entry.sources || []).map((source) => ({ node: identifier(source.node), + withinArticle: !!source.node?.closest?.("#article"), previousRect: source.previousRect.toJSON(), currentRect: source.currentRect.toJSON(), })), @@ -116,6 +134,25 @@ function observePage() { creatives: fixture.creatives, }, early: window.fixtureEarly, + apis: { + methods: Object.fromEntries( + [ + "setConfig", + "getConfig", + "requestAds", + "addAdUnits", + "renderAdUnit", + "renderAllAdUnits", + "adInit", + "scheduleInitialAdInit", + "registerPublisherSlot", + ].map((name) => [name, typeof window.tsjs?.[name]]), + ), + shim: typeof window.__tsjs_installGptShim, + registrationVersion: + window.tsjs?.publisherSlotRegistrationVersion ?? null, + rendererVersion: window.tsjs?.tsRenderBridgeVersion ?? null, + }, registration: window.fixtureHandle?.getStatus(), rendererVersion: window.tsjs?.tsRenderBridgeVersion, candidateAdId: window.tsjs?.bids?.["fixture-header"]?.hb_adid, diff --git a/crates/trusted-server-integration-tests/browser/cwv/metrics.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/metrics.test.cjs index a37c995cea..6695f2e724 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/metrics.test.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/metrics.test.cjs @@ -1,6 +1,7 @@ const { test } = require("node:test"); const assert = require("node:assert/strict"); -const { sessionCls, distribution } = require("./metrics.cjs"); +const { JSDOM } = require("jsdom"); +const { sessionCls, distribution, observePage } = require("./metrics.cjs"); const shift = (startTime, value, hadRecentInput = false) => ({ startTime, value, @@ -51,3 +52,33 @@ test("distribution retains all finite samples, medians and ranges", () => { max: null, }); }); + +test("script load capture attaches to Document, the resource load event path endpoint", () => { + const dom = new JSDOM( + '
', + { + url: "http://publisher.example.com/", + runScripts: "outside-only", + pretendToBeVisual: true, + }, + ); + try { + dom.window.PerformanceObserver = class { + static supportedEntryTypes = []; + }; + dom.window.eval(`(${observePage.toString()})()`); + const script = dom.window.document.getElementById("trustedserver-js"); + script.dispatchEvent(new dom.window.Event("load")); + assert.equal(dom.window.cwv.scriptLoads.length, 1); + assert.equal( + dom.window.cwv.scriptLoads[0].src, + "http://publisher.example.com/runtime.js", + ); + assert.equal(dom.window.cwv.scriptLoads[0].articlePresent, true); + dom.window.document.getElementById("article").remove(); + script.dispatchEvent(new dom.window.Event("load")); + assert.equal(dom.window.cwv.scriptLoads[1].articlePresent, false); + } finally { + dom.window.close(); + } +}); diff --git a/crates/trusted-server-integration-tests/browser/cwv/options.cjs b/crates/trusted-server-integration-tests/browser/cwv/options.cjs new file mode 100644 index 0000000000..84d6c574d9 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/options.cjs @@ -0,0 +1,94 @@ +const assert = require("node:assert/strict"); +const { parseArgs } = require("node:util"); + +function parseOptions(args = process.argv.slice(2)) { + const { values } = parseArgs({ + args, + options: { + baseline: { type: "string" }, + loading: { type: "string" }, + combined: { type: "string" }, + out: { type: "string" }, + runs: { type: "string", default: "5" }, + "correctness-only": { type: "boolean" }, + "regressions-only": { type: "boolean" }, + "runtime-delay-ms": { type: "string", default: "750" }, + "cpu-rate": { type: "string", default: "1" }, + "network-latency-ms": { type: "string", default: "0" }, + "download-kbps": { type: "string", default: "0" }, + "upload-kbps": { type: "string", default: "0" }, + cache: { type: "string", default: "cold" }, + }, + }); + assert( + values.loading && values.combined && values.out, + "Required: --loading CHANGED_UNRESERVED --combined CHANGED_RESERVED --out DIR; --baseline enables four-way measurement", + ); + assert( + values.baseline || values["correctness-only"] || values["regressions-only"], + "Measurement requires --baseline; CI may use --correctness-only", + ); + assert( + !(values["correctness-only"] && values["regressions-only"]), + "Choose only one of --correctness-only and --regressions-only", + ); + for (const name of [ + "runs", + "runtime-delay-ms", + "cpu-rate", + "network-latency-ms", + "download-kbps", + "upload-kbps", + ]) { + assert(values[name].trim() !== "", `--${name} must be a number`); + values[name] = Number(values[name]); + assert(Number.isFinite(values[name]), `--${name} must be finite`); + } + assert( + Number.isSafeInteger(values.runs) && values.runs > 0, + "--runs must be a positive safe integer", + ); + assert(values["cpu-rate"] >= 1, "--cpu-rate must be at least 1"); + for (const name of [ + "runtime-delay-ms", + "network-latency-ms", + "download-kbps", + "upload-kbps", + ]) + assert(values[name] >= 0, `--${name} must be nonnegative`); + // Node timers overflow beyond this limit and silently schedule a 1ms delay. + assert( + values["runtime-delay-ms"] <= 2147483647, + "--runtime-delay-ms exceeds the Node timer range", + ); + for (const name of ["download-kbps", "upload-kbps"]) + assert( + Number.isFinite((values[name] * 1000) / 8), + `--${name} exceeds the CDP throughput range`, + ); + assert( + ["cold", "warm"].includes(values.cache), + "--cache must be cold or warm", + ); + return values; +} + +function conditions(options) { + return { + runtimeDelayMs: options["runtime-delay-ms"], + cpuRate: options["cpu-rate"], + cache: options.cache, + network: { + offline: false, + latency: options["network-latency-ms"], + downloadThroughput: + options["download-kbps"] === 0 + ? -1 + : (options["download-kbps"] * 1000) / 8, + uploadThroughput: + options["upload-kbps"] === 0 ? -1 : (options["upload-kbps"] * 1000) / 8, + }, + }; +} + +module.exports = { parseOptions, conditions }; diff --git a/crates/trusted-server-integration-tests/browser/cwv/options.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/options.test.cjs new file mode 100644 index 0000000000..7e6cc3da17 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/options.test.cjs @@ -0,0 +1,88 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { parseOptions, conditions } = require("./options.cjs"); +const required = [ + "--loading", + "loading", + "--combined", + "combined", + "--out", + "out", + "--correctness-only", +]; + +test("existing CI defaults keep a 750ms hold and cold unthrottled contexts", () => { + const options = parseOptions(required); + assert.equal(options.runs, 5); + assert.deepEqual(conditions(options), { + runtimeDelayMs: 750, + cpuRate: 1, + cache: "cold", + network: { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }, + }); +}); + +test("zero delay, warm cache and explicit CPU/network conditions are retained", () => { + const options = parseOptions([ + ...required, + "--runtime-delay-ms", + "0", + "--cache", + "warm", + "--cpu-rate", + "4", + "--network-latency-ms", + "150.5", + "--download-kbps", + "1600", + "--upload-kbps", + "750", + ]); + assert.deepEqual(conditions(options), { + runtimeDelayMs: 0, + cpuRate: 4, + cache: "warm", + network: { + offline: false, + latency: 150.5, + downloadThroughput: 200000, + uploadThroughput: 93750, + }, + }); +}); + +for (const [flag, value] of [ + ["runs", "0"], + ["runs", "1.5"], + ["runs", "9007199254740992"], + ["runtime-delay-ms", "-1"], + ["runtime-delay-ms", "2147483648"], + ["runtime-delay-ms", "NaN"], + ["cpu-rate", "0.5"], + ["cpu-rate", "Infinity"], + ["network-latency-ms", "-1"], + ["download-kbps", ""], + ["upload-kbps", " "], + ["upload-kbps", "-1"], + ["download-kbps", "1e308"], + ["cache", "hot"], +]) + test(`rejects invalid --${flag} ${JSON.stringify(value)}`, () => { + assert.throws(() => parseOptions([...required, `--${flag}`, value])); + }); + +test("requires explicit measurement inputs and rejects ambiguous modes/unknown options", () => { + assert.throws(() => parseOptions([])); + assert.throws(() => parseOptions(required.slice(0, -1)), /baseline/); + assert.throws(() => parseOptions([...required, "--regressions-only"])); + assert.throws(() => parseOptions([...required, "--typo"])); + assert.equal( + parseOptions([...required.slice(0, -1), "--baseline", "baseline"]).baseline, + "baseline", + ); +}); diff --git a/crates/trusted-server-integration-tests/browser/cwv/proxy.cjs b/crates/trusted-server-integration-tests/browser/cwv/proxy.cjs new file mode 100644 index 0000000000..7a82788304 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/proxy.cjs @@ -0,0 +1,70 @@ +const http = require("node:http"); + +// Proxy forwarding preserves Chromium's native HTTP cache. Only the two exact +// fixture origins may reach an upstream connection, regardless of browser bypass. +function fixtureProxy(origins, record) { + const allowed = new Set(origins); + const server = http.createServer((request, response) => { + let target; + try { + target = new URL(request.url); + } catch { + record({ + url: request.url, + method: request.method, + action: "rejected", + reason: "Invalid proxy URL", + }); + response.writeHead(403); + response.end("External network forbidden in CWV fixture"); + return; + } + if (!allowed.has(target.origin) || target.protocol !== "http:") { + record({ + url: target.href, + method: request.method, + action: "rejected", + reason: "Origin not allowed", + }); + response.writeHead(403); + response.end("External network forbidden in CWV fixture"); + return; + } + const entry = { + url: target.href, + method: request.method, + action: "forwarded", + }; + record(entry); + const headers = { ...request.headers, host: target.host }; + delete headers["proxy-connection"]; + const upstream = http.request( + target, + { method: request.method, headers }, + (incoming) => { + entry.status = incoming.statusCode; + response.writeHead(incoming.statusCode, incoming.headers); + incoming.pipe(response); + }, + ); + upstream.on("error", (error) => { + entry.error = error.message; + if (!response.headersSent) response.writeHead(502); + response.end("Fixture proxy upstream failed"); + }); + request.on("aborted", () => upstream.destroy()); + request.pipe(upstream); + }); + server.on("connect", (request, socket) => { + record({ + url: request.url, + method: "CONNECT", + action: "rejected", + reason: "CONNECT forbidden", + }); + socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + }); + return server; +} + +module.exports = { fixtureProxy }; diff --git a/crates/trusted-server-integration-tests/browser/cwv/proxy.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/proxy.test.cjs new file mode 100644 index 0000000000..acd0ddb998 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/proxy.test.cjs @@ -0,0 +1,125 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const http = require("node:http"); +const { fixtureProxy } = require("./proxy.cjs"); +const listen = async (server) => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return `http://127.0.0.1:${server.address().port}`; +}; +const close = (server) => new Promise((resolve) => server.close(resolve)); +const request = (proxy, url, method = "GET", body = "") => + new Promise((resolve, reject) => { + const outgoing = http.request(proxy, { path: url, method }, (response) => { + let text = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + text += chunk; + }); + response.on("end", () => + resolve({ + status: response.statusCode, + headers: response.headers, + text, + }), + ); + }); + outgoing.on("error", reject); + outgoing.end(body); + }); + +test("proxy forwards only exact fixture origins and preserves HTTP cache headers and bodies", async () => { + const observations = []; + const upstream = http.createServer((incoming, response) => { + let body = ""; + incoming.on("data", (chunk) => { + body += chunk; + }); + incoming.on("end", () => { + response.writeHead(200, { "Cache-Control": "public, max-age=3600" }); + response.end(incoming.method + " " + incoming.url + " " + body); + }); + }); + const origin = await listen(upstream); + const creative = http.createServer((_request, response) => + response.end("creative"), + ); + const creativeOrigin = await listen(creative); + let forbiddenRequests = 0; + const forbidden = http.createServer((_request, response) => { + forbiddenRequests++; + response.end("forbidden"); + }); + const forbiddenOrigin = await listen(forbidden); + const proxy = fixtureProxy([origin, creativeOrigin], (entry) => + observations.push(entry), + ); + const proxyOrigin = await listen(proxy); + try { + const response = await request( + proxyOrigin, + origin + "/runtime.js?q=1", + "POST", + "fixture-body", + ); + assert.equal(response.status, 200); + assert.equal(response.text, "POST /runtime.js?q=1 fixture-body"); + assert.equal(response.headers["cache-control"], "public, max-age=3600"); + assert.equal( + (await request(proxyOrigin, creativeOrigin + "/creative")).text, + "creative", + ); + assert.equal( + (await request(proxyOrigin, forbiddenOrigin + "/external")).status, + 403, + ); + assert.equal( + (await request(proxyOrigin, "http://publisher.example.com/external")) + .status, + 403, + ); + assert.equal((await request(proxyOrigin, "/relative")).status, 403); + assert.equal(forbiddenRequests, 0); + assert.equal( + observations.filter((entry) => entry.action === "forwarded").length, + 2, + ); + assert.equal( + observations.filter((entry) => entry.action === "rejected").length, + 3, + ); + } finally { + await Promise.all([ + close(proxy), + close(upstream), + close(creative), + close(forbidden), + ]); + } +}); + +test("proxy rejects CONNECT without opening a tunnel", async () => { + const observations = []; + const proxy = fixtureProxy([], (entry) => observations.push(entry)); + const origin = await listen(proxy); + try { + const status = await new Promise((resolve, reject) => { + const outgoing = http.request(origin, { + method: "CONNECT", + path: "publisher.example.com:443", + }); + outgoing.on("connect", (response, socket) => { + socket.destroy(); + resolve(response.statusCode); + }); + outgoing.on("error", reject); + outgoing.end(); + }); + assert.equal(status, 403); + assert.deepEqual( + observations.map(({ method, action }) => ({ method, action })), + [{ method: "CONNECT", action: "rejected" }], + ); + } finally { + await close(proxy); + } +}); diff --git a/crates/trusted-server-integration-tests/browser/cwv/run.cjs b/crates/trusted-server-integration-tests/browser/cwv/run.cjs index e01322d7d4..cf246d2cc9 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/run.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/run.cjs @@ -4,44 +4,85 @@ const http = require("node:http"); const crypto = require("node:crypto"); const zlib = require("node:zlib"); const assert = require("node:assert/strict"); -const { parseArgs } = require("node:util"); +const { parseOptions, conditions } = require("./options.cjs"); +const { + assertRuntimeOrdering, + assertCacheUse, + assertReservationControl, + assertCspRuntimeApis, + assertCandidateRendererVersion, + assertVisibleCreative, +} = require("./evidence.cjs"); +const { sessionCls, distribution, observePage } = require("./metrics.cjs"); +const { fixtureProxy } = require("./proxy.cjs"); +const options = parseOptions(); +const settings = conditions(options); +const runs = options.runs; +const out = path.resolve(options.out); +fs.mkdirSync(out, { recursive: true }); +const save = (name, value) => + fs.writeFileSync(path.join(out, name), JSON.stringify(value, null, 2)); +const acceptance = { + schemaVersion: 1, + status: "running", + scope: options["regressions-only"] + ? "regressions" + : options["correctness-only"] + ? "correctness" + : "measurement-and-correctness", + startedAt: new Date().toISOString(), + conditions: settings, + samples: 0, + profileSamples: {}, + negativeControls: Object.fromEntries( + ["parser-blocking", "reservation-removed"].map((id) => [ + id, + { + status: options["regressions-only"] ? "not-requested" : "pending", + ...(options["regressions-only"] + ? { + reason: + "--regressions-only selects the four review regressions, not the negative controls", + } + : {}), + }, + ]), + ), +}; +let evidence; +const saveOutcome = () => { + save("acceptance.json", acceptance); + save("artifacts.json", { ...evidence, outcome: acceptance }); +}; +const recordFailure = (error) => { + acceptance.status = + ["ENOENT", "MODULE_NOT_FOUND"].includes(error.code) || + /Executable doesn't exist/.test(error.message) + ? "blocked" + : "failed"; + acceptance.finishedAt = new Date().toISOString(); + acceptance.error = { + name: error.name, + message: error.message, + stack: error.stack, + }; + for (const control of Object.values(acceptance.negativeControls)) + if (control.status === "running") control.status = acceptance.status; + saveOutcome(); +}; +// Persist before reading exports/dependencies so a failed rerun cannot leave an +// older successful outcome in the same output directory. +saveOutcome(); +process.on("uncaughtExceptionMonitor", recordFailure); const { chromium } = require("playwright"); const { JSDOM } = require("jsdom"); -const { sessionCls, distribution, observePage } = require("./metrics.cjs"); const { reservationStyles, withoutEarlyRenderer, + withoutRuntimeDefer, withoutScripts, rendererAsset, } = require("./html.cjs"); - -const { values: options } = parseArgs({ - options: { - baseline: { type: "string" }, - loading: { type: "string" }, - combined: { type: "string" }, - out: { type: "string" }, - runs: { type: "string", default: "5" }, - "correctness-only": { type: "boolean" }, - "regressions-only": { type: "boolean" }, - }, -}); -assert( - options.loading && options.combined && options.out, - "Required: --loading CHANGED_UNRESERVED --combined CHANGED_RESERVED --out DIR; --baseline enables four-way measurement", -); -assert( - options.baseline || - options["correctness-only"] || - options["regressions-only"], - "Measurement requires --baseline; CI may use --correctness-only", -); -const runs = Number(options.runs); -assert(Number.isInteger(runs) && runs > 0, "--runs must be a positive integer"); -const out = path.resolve(options.out); -fs.mkdirSync(out, { recursive: true }); -const save = (name, value) => - fs.writeFileSync(path.join(out, name), JSON.stringify(value, null, 2)); const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex"); const sizes = (bytes) => ({ @@ -103,17 +144,22 @@ const profiles = { combined, ...(baseline ? { baseline, reservation: baseline } : {}), }; -const evidence = { +evidence = { node: process.version, platform: process.platform, architecture: process.arch, command: process.argv, startedAt: new Date().toISOString(), settings: { - cpuThrottling: "none; shared host may run concurrent builds", - network: "loopback; runtime response held 750ms", - cache: "fresh contexts, no-store", + ...settings, runs, + hostContention: "Shared host may run concurrent builds", + cacheSemantics: + options.cache === "warm" + ? "Fresh per-sample context, prime then navigate again in same page; static assets cacheable for 3600 seconds; documents and creatives no-store" + : "Fresh contexts, HTTP cache disabled, all responses no-store", + externalNetwork: + "HTTP proxy forwards only exact publisher/creative origins; all other origins and CONNECT rejected; no browser bypass or Playwright routing", }, inputs: { loading: loading.evidence, @@ -128,11 +174,20 @@ const evidence = { runnerSha256: sha256(fs.readFileSync(__filename)), metricsSha256: sha256(fs.readFileSync(path.join(__dirname, "metrics.cjs"))), htmlSha256: sha256(fs.readFileSync(path.join(__dirname, "html.cjs"))), - interventions: [], + optionsSha256: sha256(fs.readFileSync(path.join(__dirname, "options.cjs"))), + evidenceSha256: sha256(fs.readFileSync(path.join(__dirname, "evidence.cjs"))), + proxySha256: sha256(fs.readFileSync(path.join(__dirname, "proxy.cjs"))), + proxyRequests: [], + interventions: [ + { + type: "runtime delay, CPU/network and HTTP cache configuration", + ...settings, + }, + ], boundary: "Controlled loopback lab fixture, simulated GPT and fictional bids. First-visible is an animation-frame/DOM estimate, not hardware paint, GAM impression time or field CWV.", }; -save("artifacts.json", evidence); +saveOutcome(); function documentInput(profile, name) { const source = profiles[profile]; @@ -191,11 +246,28 @@ let publisherOrigin; let creativeOrigin; const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function respond(request, response) { - const url = new URL(request.url, publisherOrigin); + const url = new URL(request.url, `http://${request.headers.host}`); const send = (type, bytes, status = 200) => { + const cacheable = + status === 200 && + (active.input.source.manifest.assets[request.url] || + ["/hero.svg", "/fixture-puc.js"].includes(url.pathname)); + active.responses.push({ + url: url.href, + status, + cacheable: !!cacheable, + phase: active.phase, + runtimeDelayMs: + request.url === active.input.runtimeSrc && active.runtimeAttempts === 1 + ? settings.runtimeDelayMs + : 0, + }); response.writeHead(status, { "Content-Type": type, - "Cache-Control": "no-store", + "Cache-Control": + options.cache === "warm" && cacheable + ? "public, max-age=3600" + : "no-store", ...(url.pathname === active.input.requestPath ? active.responseHeaders : {}), @@ -227,7 +299,8 @@ async function respond(request, response) { if (request.url === active.input.runtimeSrc) { active.runtimeAttempts++; const retry = active.runtimeAttempts > 1; - if (!retry) await wait(750); + if (!retry && settings.runtimeDelayMs > 0) + await wait(settings.runtimeDelayMs); if (!retry && ["failed", "late-runtime"].includes(active.mode)) return send("text/plain", "Deliberate fixture runtime failure", 404); } @@ -318,15 +391,26 @@ async function sample( viewport, mode = "slow", noFill = false, + control = null, ) { + if (control) { + acceptance.negativeControls[control].status = "running"; + saveOutcome(); + } const input = documentInput(profile, name); + const cspRuntimeUnavailable = input.enforcedCsp && mode === "failed"; + // Keep the existing publisher-side probe. Its core call deliberately fails + // when the synchronous runtime is unavailable; no other page error is allowed. + const expectedErrors = cspRuntimeUnavailable + ? ["tsjs.setConfig is not a function"] + : []; assert.equal( input.deferred, !input.enforcedCsp && (profile === "loading" || profile === "combined"), `${profile} must exercise its intended production loading mode`, ); const index = samples.length; - const label = `${index}-${profile}-${name}-${viewport.width}-${mode}${noFill ? "-nofill" : ""}`; + const label = `${index}-${profile}-${name}-${viewport.width}-${mode}${noFill ? "-nofill" : ""}${control ? "-" + control : ""}`; active = { input, mode, @@ -334,8 +418,38 @@ async function sample( unexpected: [], blockedRenderer: [], responseHeaders: {}, + responses: [], + phase: options.cache === "warm" ? "warmup" : "measurement", html: input.html, }; + if (control === "parser-blocking") { + active.html = withoutRuntimeDefer(input.html); + assert.notEqual( + active.html, + input.html, + "Parser negative control must remove defer", + ); + } + if (control === "reservation-removed") { + const styles = reservationStyles(input.html); + assert( + styles.length > 0, + "Reservation negative control requires production CSS", + ); + for (const style of styles) active.html = active.html.replace(style, ""); + } + if (control) { + const file = label + ".html"; + fs.writeFileSync(path.join(out, file), active.html); + evidence.interventions.push({ + index, + profile, + name, + type: control, + file, + htmlSha256: sha256(active.html), + }); + } if (input.enforcedCsp) { const legacy = baseline ?? input.source; const legacyDoc = legacy.manifest.documents.find( @@ -373,6 +487,18 @@ async function sample( !input.html.includes('id="trustedserver-bootstrap"'), "Enforced CSP must use synchronous production fallback", ); + assert( + !input.html.includes("// Edge-injected GPT auction bootstrap."), + "Enforced CSP must not depend on the provisional raw bootstrap's inline hash", + ); + if (cspRuntimeUnavailable) + evidence.interventions.push({ + index, + profile, + name, + type: "enforced-CSP synchronous runtime failure; no provisional fallback", + expectedErrors, + }); } if (mode === "renderer-absent") { active.html = withoutEarlyRenderer(input.html); @@ -420,6 +546,10 @@ async function sample( let traceStarted = false; let result; let page; + let warmup = null; + const requests = new Map(); + const errors = []; + const network = []; try { await context.tracing.start({ screenshots: true, @@ -437,25 +567,80 @@ async function sample( }`, }); page = await context.newPage(); - const errors = []; - const network = []; + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + await cdp.send("Network.setCacheDisabled", { + cacheDisabled: options.cache === "cold", + }); + await cdp.send("Emulation.setCPUThrottlingRate", { + rate: settings.cpuRate, + }); + await cdp.send("Network.emulateNetworkConditions", settings.network); + cdp.on("Network.requestWillBeSent", ({ requestId, request }) => { + requests.set(requestId, { url: request.url, method: request.method }); + }); + cdp.on("Network.requestServedFromCache", ({ requestId }) => { + const entry = requests.get(requestId); + if (entry) entry.servedFromCache = true; + }); + cdp.on("Network.responseReceived", ({ requestId, response }) => { + const entry = requests.get(requestId); + if (entry) + Object.assign(entry, { + status: response.status, + fromDiskCache: response.fromDiskCache ?? false, + fromServiceWorker: response.fromServiceWorker ?? false, + headers: response.headers, + }); + }); page.on("pageerror", (error) => errors.push(error.message)); - await context.route("**/*", (route) => { - const url = route.request().url(); - network.push({ url, method: route.request().method() }); + page.on("request", (request) => { + const url = request.url(); + network.push({ url, method: request.method() }); if ( - url.startsWith(publisherOrigin + "/") || - url.startsWith(creativeOrigin + "/") + !url.startsWith(publisherOrigin + "/") && + !url.startsWith(creativeOrigin + "/") ) - return route.continue(); - errors.push("Unexpected external request: " + url); - return route.abort(); - }); - await page.goto(publisherOrigin + input.requestPath, { - waitUntil: "load", - timeout: 15000, + errors.push("Unexpected external request: " + url); }); - if (input.matched) + const navigate = () => + page.goto(publisherOrigin + input.requestPath, { + waitUntil: "load", + timeout: 15000, + }); + if (options.cache === "warm") { + await navigate(); + if (input.matched && !cspRuntimeUnavailable) + await page.waitForFunction(() => cwv.delivered, null, { + timeout: 10000, + }); + await wait(250); + assert.deepEqual( + errors, + expectedErrors, + "Cache priming must have only declared fixture errors", + ); + assert.deepEqual( + active.unexpected, + [], + "Cache priming URLs must be declared", + ); + warmup = { + requests: [...requests.values()], + network: [...network], + responses: [...active.responses], + raw: await page.evaluate(() => cwv.finish()), + }; + requests.clear(); + network.length = 0; + errors.length = 0; + active.runtimeAttempts = 0; + active.blockedRenderer = []; + active.responses = []; + active.phase = "measurement"; + } + await navigate(); + if (input.matched && !cspRuntimeUnavailable) await page.waitForFunction(() => cwv.delivered, null, { timeout: 10000 }); if (mode === "late-runtime") { await page.evaluate( @@ -488,7 +673,12 @@ async function sample( assert(raw.supported.includes(type), `Missing ${type} observer support`); assert(Number.isFinite(fcp), "FCP must be observed"); assert(Number.isFinite(lcp), "LCP must be observed"); - assert.equal(errors.length, 0, "No unexpected page or network errors"); + assert.deepEqual( + errors, + expectedErrors, + "Only declared fixture page errors are allowed", + ); + if (input.enforcedCsp) assertCspRuntimeApis(raw, !cspRuntimeUnavailable); if (input.enforcedCsp) assert.deepEqual( raw.cspViolations, @@ -516,23 +706,19 @@ async function sample( [new URL(input.runtimeSrc, publisherOrigin).pathname], "Actual standalone GPT renderer must be blocked", ); - } else { + } else if (!cspRuntimeUnavailable) { assert( raw.early.core && raw.early.queued, "Direct core API and queued publisher callback must work before deferred runtime", ); - assert.equal( - raw.rendererVersion, - 1, - "Real renderer must remain installed when only deferred runtime fails", - ); + assertCandidateRendererVersion(raw, profile); } assert.equal( raw.fixture.requests.length, - input.matched ? 1 : 0, - "Exactly one native initial request for a matched slot", + input.matched && !cspRuntimeUnavailable ? 1 : 0, + "Exactly one native initial request requires a matched slot and available runtime or early bridge", ); - if (input.matched) { + if (input.matched && !cspRuntimeUnavailable) { assert.equal( raw.fixture.trace.filter((entry) => entry.event === "nativeDefine") .length, @@ -590,17 +776,10 @@ async function sample( .frames() .map((frame) => frame.locator(".marker").allTextContents()), ); - assert( - text - .flat() - .includes( - mode === "renderer-absent" ? "Publisher result" : "Server result", - ), - "Actual creative iframe DOM must contain server creative", - ); - assert( - Number.isFinite(raw.firstVisibleEstimateMs), - "Visible ad estimate must be observed", + assertVisibleCreative( + text, + raw.firstVisibleEstimateMs, + mode === "renderer-absent" ? "Publisher result" : "Server result", ); } } @@ -615,22 +794,44 @@ async function sample( Number.isFinite(responseEnd), "Runtime resource timing must exist", ); - if (input.deferred) + assertRuntimeOrdering( + raw, + new URL(input.runtimeSrc, publisherOrigin).href, + input.deferred && control !== "parser-blocking", + ); + if (!input.deferred || control === "parser-blocking") assert( - fcp < responseEnd, - "Changed content must paint while runtime response is held", + fcp >= responseEnd, + "Parser-blocking runtime must finish before FCP", ); - else + if ( + input.deferred && + control !== "parser-blocking" && + options.cache === "cold" && + settings.runtimeDelayMs === 750 + ) assert( - fcp >= responseEnd, - "Baseline must exercise parser-blocking runtime", + fcp < responseEnd, + "Changed content must paint during the default 750ms runtime hold", ); + // A deferred cache hit or 0ms response may legitimately finish before FCP. + // Native script-load/parser observations above are the ordering gate. } + const cacheRequests = [...requests.values()]; + const cacheHits = + options.cache === "warm" + ? assertCacheUse( + cacheRequests, + new URL(input.runtimeSrc, publisherOrigin).href, + mode === "slow", + ) + : []; const before = raw.geometry.find((entry) => entry.phase === "before-fill"); const after = raw.geometry.at(-1); const expectedHeight = viewport.width >= 1024 ? 600 : 250; const reserved = ["combined", "reservation"].includes(profile) && + control !== "reservation-removed" && input.matched && !input.absentWrapper; if (reserved) { @@ -639,7 +840,13 @@ async function sample( expectedHeight, "Responsive reservation must match viewport", ); - if (noFill || input.creativeHeight <= expectedHeight) { + if (cspRuntimeUnavailable) { + assert.equal( + cls, + 0, + "Unavailable CSP runtime must not cause ad-induced layout shift", + ); + } else if (noFill || input.creativeHeight <= expectedHeight) { assert.equal( after.article.top, before.article.top, @@ -664,8 +871,19 @@ async function sample( "Unmatched and unreserved wrappers must remain untouched", ); } + if (control === "reservation-removed") assertReservationControl(raw, cls); result = { index, + control, + runtimeUnavailableBoundary: cspRuntimeUnavailable, + conditions: settings, + cache: { + mode: options.cache, + hits: cacheHits, + requests: cacheRequests, + warmup, + }, + responses: active.responses, stage, profile, name, @@ -699,7 +917,17 @@ async function sample( blockedRenderer: active.blockedRenderer, }; samples.push(result); + acceptance.samples = samples.length; + acceptance.profileSamples[profile] = + (acceptance.profileSamples[profile] ?? 0) + 1; + if (control) + Object.assign(acceptance.negativeControls[control], { + status: "passed", + sampleIndex: index, + htmlFile: label + ".html", + }); save("samples.json", samples); + saveOutcome(); console.log(JSON.stringify({ label, ...result.metrics })); await page.screenshot({ path: path.join(out, label + ".png"), @@ -717,7 +945,12 @@ async function sample( error: error.stack, sample: result, raw: await page?.evaluate(() => window.cwv?.finish()).catch(() => null), + conditions: settings, + cache: { mode: options.cache, requests: [...requests.values()], warmup }, + errors, + network, server: { + responses: active.responses, runtimeAttempts: active.runtimeAttempts, unexpected: active.unexpected, }, @@ -737,7 +970,28 @@ async function sample( creativeOrigin = await listen(); let browser; try { - browser = await chromium.launch({ headless: true }); + const allowedOrigins = [publisherOrigin, creativeOrigin]; + evidence.interventions.push({ + type: "exact-origin forwarding HTTP proxy", + allowedOrigins, + rejected: "all other origins and CONNECT; no browser bypass", + }); + const proxy = fixtureProxy(allowedOrigins, (entry) => { + evidence.proxyRequests.push( + Object.assign(entry, { + sampleIndex: samples.length, + phase: active?.phase ?? "startup", + }), + ); + }); + servers.push(proxy); + await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve)); + browser = await chromium.launch({ + headless: true, + proxy: { + server: `http://127.0.0.1:${proxy.address().port}`, + }, + }); evidence.browser = browser.version(); evidence.origins = { publisherOrigin, creativeOrigin }; const mobile = { width: 390, height: 844 }; @@ -772,7 +1026,26 @@ async function sample( await sample(browser, "combined", "registered", mobile, "renderer-absent"); await sample(browser, "combined", "registered", mobile, "failed"); await sample(browser, "combined", "csp", mobile); + await sample(browser, "combined", "csp", mobile, "failed"); if (!options["regressions-only"]) { + await sample( + browser, + "combined", + "inline", + mobile, + "slow", + false, + "parser-blocking", + ); + await sample( + browser, + "combined", + "inline", + mobile, + "slow", + false, + "reservation-removed", + ); for (const mode of ["slow", "failed", "absent", "late-runtime"]) { await sample(browser, "combined", "inline", mobile, mode); await sample(browser, "combined", "registered", mobile, mode); @@ -793,7 +1066,7 @@ async function sample( } const groups = {}; for (const entry of samples) { - const key = `${entry.stage}/${entry.profile}/${entry.name}/${entry.viewport.width}/${entry.mode}/${entry.noFill ? "nofill" : "fill"}`; + const key = `${entry.stage}/${entry.profile}/${entry.name}/${entry.viewport.width}/${entry.mode}/${entry.noFill ? "nofill" : "fill"}${entry.control ? "/" + entry.control : ""}`; (groups[key] ??= []).push(entry.metrics); } const summary = Object.fromEntries( @@ -846,13 +1119,23 @@ async function sample( ); } finally { evidence.finishedAt = new Date().toISOString(); - save("artifacts.json", evidence); + saveOutcome(); await browser?.close(); await Promise.all( servers.map((server) => new Promise((resolve) => server.close(resolve))), ); } + acceptance.status = "passed"; + acceptance.finishedAt = new Date().toISOString(); + acceptance.reports = { + artifacts: "artifacts.json", + samples: "samples.json", + summary: "summary.json", + comparisons: "comparisons.json", + }; + saveOutcome(); })().catch((error) => { + recordFailure(error); console.error(error); process.exitCode = 1; }); diff --git a/crates/trusted-server-integration-tests/browser/package.json b/crates/trusted-server-integration-tests/browser/package.json index 7c03b5d7b3..2d1517a1b5 100644 --- a/crates/trusted-server-integration-tests/browser/package.json +++ b/crates/trusted-server-integration-tests/browser/package.json @@ -9,6 +9,8 @@ "test:publisher-registration:fixture": "node --test publisher-registration/*.test.cjs", "test:cwv:unit": "node --test cwv/*.test.cjs", "test:cwv": "node cwv/run.cjs", + "test:cwv:acceptance": "node ../../../scripts/test-client-cwv-acceptance.mjs", + "test:publisher-registration:real-gpt": "node ../../../scripts/test-publisher-registration-real-gpt.mjs", "test:publisher-registration": "node publisher-registration/run.cjs --implementation all", "test:publisher-registration:framework": "../../../scripts/test-publisher-registration.sh" }, diff --git a/crates/trusted-server-integration-tests/browser/playwright.config.ts b/crates/trusted-server-integration-tests/browser/playwright.config.ts index 8a1ef3b5bd..6e3e33f2cb 100644 --- a/crates/trusted-server-integration-tests/browser/playwright.config.ts +++ b/crates/trusted-server-integration-tests/browser/playwright.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", + // Real GPT requires dedicated inventory and its explicit acceptance launcher. + testIgnore: ["**/publisher-registration.real-gpt.spec.ts"], globalSetup: "./global-setup.ts", globalTeardown: "./global-teardown.ts", timeout: 30_000, diff --git a/crates/trusted-server-integration-tests/browser/playwright.publisher-registration.real-gpt.config.ts b/crates/trusted-server-integration-tests/browser/playwright.publisher-registration.real-gpt.config.ts new file mode 100644 index 0000000000..4cf616beff --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/playwright.publisher-registration.real-gpt.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from "@playwright/test"; + +const { loadInventory } = require("./publisher-registration/real-gpt.cjs"); +loadInventory(process.env.PUBLISHER_REGISTRATION_REAL_GPT_INVENTORY_FILE); + +export default defineConfig({ + testDir: "./tests/nextjs", + testMatch: "publisher-registration.real-gpt.spec.ts", + workers: 1, + retries: 0, + timeout: 90_000, + expect: { timeout: 20_000 }, + use: { + browserName: "chromium", + headless: true, + viewport: { width: 900, height: 800 }, + trace: "on", + screenshot: "on", + serviceWorkers: "block", + }, + outputDir: process.env.PUBLISHER_REGISTRATION_REAL_GPT_RESULTS, + reporter: [ + ["list"], + [ + "json", + { + outputFile: `${process.env.PUBLISHER_REGISTRATION_REAL_GPT_RESULTS || "test-results/real-gpt"}/results.json`, + }, + ], + ], +}); diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.README.md b/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.README.md new file mode 100644 index 0000000000..5f6490aab2 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.README.md @@ -0,0 +1,125 @@ +# Real GPT local acceptance + +This gate is separate from the deterministic GPT fixture tests. It starts the actual Rust WASM in Viceroy, the Next.js fixture container, real GPT through the production first-party proxy, and the existing local OpenRTB provider fixture. It never installs `gpt-fixture.js` or synthesizes a GPT response. + +No live acceptance or original-main reproduction has been executed without operator-supplied dedicated inventory. Missing inventory or original-main artifacts produce a nonzero `blocked` outcome, not a skip. A local pass does not validate a deployed service. + +## Required inventory + +Supply an external JSON file. Do not commit inventory, creative IDs, customer data, or credentials. There are no inventory defaults. The following describes the fields, but is deliberately invalid until an inventory owner supplies values and authorizes traffic. + +```json +{ + "schemaVersion": 1, + "dedicatedTestInventory": true, + "authorizeGoogleTraffic": false, + "registered": { + "path": "", + "tsCreativeId": "", + "ordinaryCreativeId": "", + "tsMarker": "Fixture publisher registration winner", + "ordinaryMarker": "" + }, + "unrelated": { + "path": "" + } +} +``` + +The two paths must differ. This fixture supports a numeric GAM network segment and ordinary slash-separated unit names. Configure dedicated 300x250 inventory as follows: + +- The registered ad unit must return the known TS test creative when the first request carries `hb_bidder=fixture-bidder`, `hb_adid=fixture-win-fixture-header`, and its price targeting. That GAM creative must use the real TS-compatible PUC/creative bridge. The local provider returns visible ADM text `Fixture publisher registration winner`, not a debug creative. +- Without candidate targeting, the registered ad unit must return the supplied ordinary creative ID and visible marker. +- The unrelated ad unit must return that same ordinary creative ID and marker when explicitly refreshed. It must not request anything before that explicit refresh. +- Do not enable inventory-side automatic refresh. No GAM credentials are needed by the launcher. An inventory owner must configure the creative/line-item selection before authorizing Google traffic. + +The launcher uses the real default GPT bootstrap URL. Google is never mapped to the local provider. Only the fictional auction provider's production-predicted backend names map to loopback. + +## Artifacts and preparation + +Use the repository-pinned Node, Rust, Viceroy, and Docker. Install browser dependencies and Chromium through the coordinator's normal setup. `--prebid DIR` takes the existing verified external Prebid artifact directory containing `manifest.json`, its real bundle, SHA-256, and SHA-384 SRI. `--dist DIR` must be the matched current production TSJS bundle directory. + +`--wasm PATH` accepts an already built current WASM with its existing `.publisher-registration.json` source/byte stamp. The launcher verifies it against the current source and supplied dist. Omit `--wasm` to let this launcher build and stamp the current WASM. It does not rebuild TSJS dist implicitly. + +The original-main baseline is mandatory for full live acceptance. Supply `--baseline-wasm PATH --baseline-dist DIR`, built from original main `51730bdaf`, not a current build using old options. The baseline source archive can contain the separately recorded fixture-only build adaptations needed to export artifacts. Its sibling `.publisher-registration-baseline.json` stamp records the original full revision, immutable WASM hash, matched baseline dist hash, and optionally the baseline source-manifest hash. + +Immediately after the coordinator's original-main build, stamp it from the repository root: + +```bash +LC_ALL=C \ +BASELINE_WASM=/absolute/original-main.wasm \ +BASELINE_DIST=/absolute/original-main-dist \ +BASELINE_SOURCE_MANIFEST=/absolute/baseline-source.json \ +BASELINE_REVISION="$(git rev-parse 51730bdaf)" \ +node <<'NODE' +const fs = require('node:fs'); +const h = require('./crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.cjs'); +const e = process.env; +fs.writeFileSync(`${e.BASELINE_WASM}.publisher-registration-baseline.json`, JSON.stringify({ + schemaVersion: 1, + gitRevision: e.BASELINE_REVISION, + wasmSha256: h.sha256(fs.readFileSync(e.BASELINE_WASM)), + bundleSha256: h.distFingerprint(e.BASELINE_DIST), + sourceManifestSha256: h.sha256(fs.readFileSync(e.BASELINE_SOURCE_MANIFEST)) +}, null, 2)); +NODE +``` + +`distFingerprint` matches `cd DIST; find . -maxdepth 1 -name 'tsjs-*.js' -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum`. The explicit `LC_ALL=C` makes this independent of the invoking shell's locale. It hashes the concatenated ` ./\n` lines, not concatenated bundle bytes. + +Prepare without starting a runtime or contacting Google: + +```bash +node scripts/test-publisher-registration-real-gpt.mjs \ + --inventory /absolute/dedicated-inventory.json \ + --dist /absolute/current-dist --wasm /absolute/current.wasm \ + --prebid /absolute/verified-prebid \ + --baseline-wasm /absolute/original-main.wasm \ + --baseline-dist /absolute/original-main-dist \ + --out /absolute/evidence --prepare +``` + +Preparation generates readable candidate and legacy `app.toml`, invokes the existing `generate-viceroy-config` and `publisher_registration_backends` tools, and builds/reuses the source-fingerprinted Next.js image. The legacy app omits `initialization` entirely. Both app configs set the exact operator-supplied `gam_unit_path`. `--image TAG` is optional and must have a matching `org.trusted-server.fixture-source` label. Real inventory enters only through `docker run` server environment, never the image build. + +Run the same command without `--prepare` for full live acceptance. `--origin-port PORT` changes the default loopback origin port 8888. Preparation can invoke Cargo and Docker builds; coordinate ownership before running it. + +`--preflight` validates inventory and original-main artifact provenance only. `--list` lists the candidate and baseline tests without starting a runtime. Both require `--inventory`, and report blocked if baseline artifacts are absent. Neither is evidence that a browser test passed. + +## Cases and assertions + +The original-main case holds actual Next.js hydration scripts until the original automatic SSR placement renders the known TS creative. It then releases hydration and mounts a changed final element ID with a narrower native size list and enabled initial loading. The observer requires two real request memberships, public lifecycle correlation, and an explicitly completed final render. It reports original failure only when the old disconnected native slot and final slot remain live, or the final request renders ordinary/no-fill instead of the initial TS creative. Unknown creatives and observation timeouts do not count as reproduction. Expected React hydration errors are retained in baseline evidence. + +The current opt-in cases cover: + +- eligible first-request TS targeting and known creative, with unrelated native inventory untouched; +- no-bid and incompatible eligibility ordinary fallback; +- failed and late deferred runtime with the actual early renderer retained, eligible TS delivery before takeover, and no repeated request after takeover; +- eligible TS-first delivery followed by a controlled actual-provider no-bid transition, fresh real Prebid/provider exchanges for both registered and unrelated ad units, candidate-targeting retirement, and an actual two-member SRA refresh; +- React SSR-placeholder replacement before final registration; +- cancellation before scheduled dispatch, then successful native destruction while the element is connected, with actual `getSlots()` absence before DOM removal; +- Next.js navigation, teardown, and ordinary no-bid replacement. + +The two deferred-runtime fault cases leave the actual early compiled entry, renderer, and script guard intact. They fail/hold only the deferred GPT runtime asset. The known TS creative and eligible targeting must render before a late runtime is released. The late case then waits for actual real-Prebid/refresh-wrapper initialization and rejects another initial request. All cases require first-party bootstrap and `pubads_impl` responses with `X-GPT-Proxy: true`, and reject direct GPT-script bypass. + +The observer decodes GET/URL-encoded POST and SRA inventory/targeting. It accounts for every per-slot network membership using distinct configured paths and public `slotRequested` events, then checks creative ID and a visible marker inside the corresponding real GPT frame. The marker and every containing iframe must have positive viewport-intersecting geometry and visible, nontransparent, noncollapsed DOM ancestors. Hidden frame text alone is not evidence. Unknown encodings, repeated paths in one SRA request, undecodable membership, extra refreshes, wrong mounts, unknown/wrong creatives, missing markers, and no-fill fail. There are no timing-based success thresholds. Polling timeouts bound observation; they do not prove the absence of an event indefinitely. + +The publisher authors real `pbjs.addAdUnits` entries with fixture-bidder parameters before refresh. `PUT /api/fixture-auction` accepts only `{ "noBid": true|false }` and changes actual local provider behavior without navigating or resetting the consumed native slot. Every provider exchange records its effective mode. `DELETE` resets both the ledger and forced mode; the existing `/no-bid` path continues to work without a control setting. + +Pure observer tests include extra-refresh and wrong-creative rejection, malformed/ambiguous SRA, original-main false-reproduction rejection, strict options, config generation, dist fingerprint compatibility, and default/path/forced/reset provider controls: + +```bash +node --test crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.test.cjs \ + scripts/test-publisher-registration-real-gpt.test.mjs +``` + +## Evidence and remaining coverage + +`outcome.json` records `status`, `mode`, `liveAcceptancePassed`, `originalFailureObserved`, separate candidate/baseline outcomes, and explicit total/passed/failed/skipped counts. Before reading preparation inputs or owning resources, the launcher atomically replaces any previous root outcome with `status=running` and both proof flags false. Full acceptance requires a terminal passing outcome for both runs, positive test counts, zero skips, a proven original failure, and all candidate assertions. Preparation, listing, or preflight always leaves `liveAcceptancePassed=false`. + +SIGINT and SIGTERM abort outstanding commands/readiness requests and unwind the owned runtime once, then write a failed outcome with exit 130 or 143 and the received signal. Subprocesses run asynchronously in process groups created by this launcher; it never signals unrelated groups. An in-flight Docker start acknowledgement finishes before cleanup uses its reserved unique name, avoiding a race with daemon-side container creation. `commands.json` records owned command PIDs; each runtime's `runtime.json` records its container name and Viceroy PID/group. Cleanup retains Docker logs and its stop result. + +SIGKILL, host shutdown, or a second external force-kill cannot run JavaScript cleanup. An invocation killed after startup invalidation remains `running` with `liveAcceptancePassed=false`, never an old success. Its resources may still need manual cleanup. Verify the recorded container/process identities before removing them. The focused SIGKILL regression cleans its own local discovery subprocess; it does not claim automatic resource cleanup after SIGKILL. + +The evidence directory retains source/artifact hashes, toolchain, command outcomes, provider preflight, generated configs/mappings, Docker/Viceroy logs, raw browser request URLs and bodies, raw GPT/ad/document/script response bytes with hashes, public lifecycle snapshots, raw provider ledger, original-main reproduction details, screenshots, and Playwright traces. Failures retain the same observations and `failure.txt`. Treat these artifacts as sensitive because live test inventory appears in them. + +Live execution remains blocked until dedicated inventory is provided. This suite does not cover arbitrary consent matrices, viewport breakpoint races, repeated same-path inventory in one SRA batch, every navigation/cancellation interleaving, or field CWV. The baseline narrows sizes; it does not independently cover every reordered-size variant. These gaps must not be reported as passing evidence. diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.cjs b/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.cjs new file mode 100644 index 0000000000..7dd9670042 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.cjs @@ -0,0 +1,477 @@ +const assert = require("node:assert/strict"); +const { createHash } = require("node:crypto"); +const { readFileSync, readdirSync } = require("node:fs"); +const { resolve, join } = require("node:path"); + +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); +// allow-domain: securepubads.g.doubleclick.net +const GPT_SCRIPT = "https://securepubads.g.doubleclick.net/tag/js/gpt.js"; +const CANDIDATE_KEYS = [ + "hb_pb", + "hb_bidder", + "hb_adid", + "hb_cache_host", + "hb_cache_path", +]; + +function inventory(input) { + assert.equal(input.schemaVersion, 1, "inventory schemaVersion must be 1"); + assert.equal( + input.dedicatedTestInventory, + true, + "dedicated test inventory is required", + ); + assert.equal( + input.authorizeGoogleTraffic, + true, + "explicit Google traffic authorization is required", + ); + const path = (value) => { + assert.match( + value ?? "", + /^\/[0-9]+\/[A-Za-z0-9_./-]+$/, + "invalid dedicated GAM ad unit path", + ); + assert.ok(!value.includes(".."), "GAM paths cannot contain traversal"); + return value; + }; + const registered = input.registered; + assert.ok( + registered && input.unrelated, + "registered and unrelated inventory are required", + ); + path(registered.path); + path(input.unrelated.path); + assert.notEqual( + registered.path, + input.unrelated.path, + "distinct paths are required for unambiguous network correlation", + ); + for (const key of ["tsCreativeId", "ordinaryCreativeId"]) { + assert.ok( + Number.isSafeInteger(registered[key]) && registered[key] > 0, + `${key} must be a positive GAM creative ID`, + ); + } + assert.notEqual( + registered.tsCreativeId, + registered.ordinaryCreativeId, + "TS and ordinary creative IDs must differ", + ); + for (const key of ["tsMarker", "ordinaryMarker"]) { + assert.ok( + typeof registered[key] === "string" && registered[key].trim(), + `${key} must be a visible creative text marker`, + ); + } + assert.notEqual( + registered.tsMarker, + registered.ordinaryMarker, + "creative markers must differ", + ); + return { + schemaVersion: 1, + dedicatedTestInventory: true, + authorizeGoogleTraffic: true, + registered: { ...registered }, + unrelated: { path: input.unrelated.path }, + gptScriptUrl: GPT_SCRIPT, + }; +} + +// Canonical locale-independent launcher order: cd DIST; find . -maxdepth 1 +// -name 'tsjs-*.js' -type f | LC_ALL=C sort; sha256sum each './filename', +// then sha256sum those output bytes. Bundle filenames are ASCII. +function distFingerprint(directory) { + const files = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /^tsjs-.*\.js$/.test(entry.name)) + .map((entry) => entry.name) + .sort(); + assert.ok(files.length, "no TSJS bundles found"); + return sha256( + files + .map( + (file) => `${sha256(readFileSync(join(directory, file)))} ./${file}\n`, + ) + .join(""), + ); +} + +function loadInventory(file) { + assert.ok( + file, + "--inventory FILE is required; no live-inventory defaults or skips exist", + ); + return inventory(JSON.parse(readFileSync(resolve(file), "utf8"))); +} + +function options(argv) { + const result = {}; + for (let index = 0; index < argv.length; index++) { + const flag = argv[index]; + assert.ok( + [ + "--inventory", + "--dist", + "--wasm", + "--prebid", + "--image", + "--out", + "--origin-port", + "--baseline-wasm", + "--baseline-dist", + "--prepare", + "--list", + "--preflight", + ].includes(flag), + `unknown option ${flag}`, + ); + const key = flag.slice(2); + assert.ok(!(key in result), `duplicate option ${flag}`); + if (["prepare", "list", "preflight"].includes(key)) result[key] = true; + else { + assert.ok( + argv[index + 1] && !argv[index + 1].startsWith("--"), + `missing value for ${flag}`, + ); + result[key] = argv[++index]; + } + } + assert.equal( + Boolean(result["baseline-wasm"]), + Boolean(result["baseline-dist"]), + "--baseline-wasm and --baseline-dist must be supplied together", + ); + loadInventory(result.inventory); + if (!result.list && !result.preflight) { + for (const key of ["dist", "prebid", "out"]) + assert.ok(result[key], `--${key} is required`); + } + if (result["origin-port"] !== undefined) { + assert.match(result["origin-port"], /^\d+$/); + const port = Number(result["origin-port"]); + assert.ok(port > 0 && port <= 65535, "invalid origin port"); + } + return result; +} + +function targeting(raw) { + const result = {}; + for (const [key, value] of new URLSearchParams(raw)) { + assert.ok(!(key in result), `ambiguous duplicate targeting ${key}`); + result[key] = value.split(",").sort(); + } + return result; +} + +function decodeAdRequest(request, configuredPaths) { + const url = new URL(request.url); + assert.ok(url.pathname.endsWith("/gampad/ads"), "not a GPT ad request"); + const params = new URLSearchParams(url.search); + if (request.method === "POST") { + assert.ok( + request.postData && !request.postData.trimStart().startsWith("{"), + "undecodable GPT POST body", + ); + for (const [key, value] of new URLSearchParams(request.postData)) { + assert.ok(!params.has(key), `ambiguous query/body parameter ${key}`); + params.append(key, value); + } + } else assert.equal(request.method, "GET", "unsupported GPT request method"); + for (const key of new Set(params.keys())) + assert.equal( + params.getAll(key).length, + 1, + `duplicate GPT parameter ${key}`, + ); + const global = targeting(params.get("cust_params") || ""); + let paths; + let perSlot; + if (params.has("enc_prev_ius")) { + assert.ok(!params.has("iu"), "ambiguous single/SRA inventory"); + const parts = (params.get("iu_parts") || "").split(","); + assert.ok(parts.every(Boolean), "missing SRA inventory parts"); + paths = params + .get("enc_prev_ius") + .split(",") + .map((encoded) => { + assert.match( + encoded, + /^\/?\d+(?:\/\d+)*$/, + "undecodable SRA inventory indexes", + ); + const indexes = encoded.replace(/^\//, "").split("/").map(Number); + assert.ok( + indexes.every((index) => index < parts.length), + "SRA inventory index out of bounds", + ); + return "/" + indexes.map((index) => parts[index]).join("/"); + }); + perSlot = params.has("prev_scp") + ? params.get("prev_scp").split("|") + : paths.map(() => ""); + assert.equal( + perSlot.length, + paths.length, + "SRA targeting/slot count mismatch", + ); + } else { + assert.ok(params.get("iu"), "undecodable GPT request without inventory"); + paths = [params.get("iu")]; + perSlot = [params.get("scp") || ""]; + } + assert.equal( + new Set(paths).size, + paths.length, + "repeated GAM path is ambiguous in one request", + ); + return paths.map((path, index) => { + assert.ok( + configuredPaths.includes(path), + `unexpected or undecodable inventory ${path}`, + ); + return { + path, + targeting: { ...global, ...targeting(perSlot[index]) }, + requestIndex: request.index, + }; + }); +} + +function candidateTargeting(value) { + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key.startsWith("hb_") || key === "ts_initial") + .map(([key, values]) => [key, [...values].sort()]), + ); +} + +// One public slotRequested event must account for every decoded per-slot request. +// This deliberately rejects unrecognised GPT encodings rather than guessing. +function assertEvidence({ requests, events, expected, paths }) { + const decoded = requests.flatMap((request) => + decodeAdRequest(request, paths), + ); + const requested = events.filter((event) => event.type === "slotRequested"); + assert.equal( + decoded.length, + requested.length, + "network/public request count mismatch", + ); + for (const path of paths) { + const actual = decoded.filter((entry) => entry.path === path); + const publicEvents = requested.filter((entry) => entry.path === path); + const wanted = expected.filter((entry) => entry.path === path); + assert.equal( + actual.length, + wanted.length, + `extra or missing real request for ${path}`, + ); + assert.equal( + publicEvents.length, + wanted.length, + `extra or missing slotRequested for ${path}`, + ); + const renders = events.filter( + (event) => event.type === "slotRenderEnded" && event.path === path, + ); + assert.equal(renders.length, wanted.length, `render count for ${path}`); + for (let index = 0; index < wanted.length; index++) { + const expectation = wanted[index]; + const native = publicEvents[index]; + assert.equal( + native.id, + expectation.id, + "request belongs to wrong final mount", + ); + assert.deepEqual( + candidateTargeting(actual[index].targeting), + candidateTargeting(native.targeting), + "network targeting disagrees with public lifecycle", + ); + if (expectation.candidate) { + for (const key of ["hb_pb", "hb_bidder", "hb_adid"]) + assert.ok( + actual[index].targeting[key]?.length, + `first eligible request lacks ${key}`, + ); + assert.deepEqual(actual[index].targeting.hb_bidder, ["fixture-bidder"]); + assert.deepEqual(actual[index].targeting.hb_adid, [ + "fixture-win-fixture-header", + ]); + } else { + for (const key of CANDIDATE_KEYS) + assert.ok( + !actual[index].targeting[key]?.length, + `ordinary request retained ${key}`, + ); + } + assert.equal( + renders[index].id, + expectation.id, + "render belongs to wrong mount", + ); + assert.equal( + renders[index].isEmpty, + false, + "test inventory returned no fill", + ); + assert.equal( + renders[index].creativeId, + expectation.creativeId, + "wrong GAM creative", + ); + assert.ok( + expectation.markerObserved, + "expected creative text marker was not observed", + ); + } + } + return { decoded, publicRequestCount: requested.length }; +} + +function appConfig(template, input, prebid, originPort, legacy = false) { + const config = inventory(input); + assert.match(prebid.sha256, /^[a-f0-9]{64}$/); + assert.match(prebid.sri, /^sha384-[A-Za-z0-9+/=]+$/); + let text = template.split("[[creative_opportunities.slot]]")[0]; + text = text + .replace( + 'origin_url = "http://127.0.0.1:8888"', + `origin_url = "http://127.0.0.1:${originPort}"`, + ) + .replace( + '"ads.example.com"', + JSON.stringify(new URL(config.gptScriptUrl).hostname), + ) + .replace( + 'script_url = "https://ads.example.com/gpt.js"', + `script_url = ${JSON.stringify(config.gptScriptUrl)}`, + ) + .replace("rewrite_script = false", "rewrite_script = true") + .replace( + 'external_bundle_url = "https://assets.example.com/fixture-prebid.js"', + `external_bundle_url = "https://assets.example.com/fixture-prebid.js"\nexternal_bundle_sha256 = "${prebid.sha256}"\nexternal_bundle_sri = "${prebid.sri}"`, + ) + .replace( + 'gam_network_id = "123456789"', + `gam_network_id = ${JSON.stringify(config.registered.path.split("/")[1])}`, + ); + text += `[[creative_opportunities.slot]]\nid = "fixture-header"\n${legacy ? "" : 'initialization = "publisher_registered"\n'}gam_unit_path = ${JSON.stringify(config.registered.path)}\ndiv_id = "${legacy ? "real-gpt-ssr" : "real-gpt-"}"\npage_patterns = ["/publisher-registration-real-gpt", "/publisher-registration-real-gpt/*"]\nformats = [{ width = 300, height = 250 }${legacy ? ", { width = 300, height = 600 }" : ""}]\n\n[creative_opportunities.slot.providers.prebid.bidders.fixture-bidder]\nplacement = "fixture-header"\n\n[debug]\ninject_adm_for_testing = false\n`; + assert.ok(text.includes("rewrite_script = true")); + assert.ok( + !text.includes("ads.example.com"), + "fake GPT backend must not appear in dedicated config", + ); + return text; +} + +function assertBaselineEvidence({ + requests, + events, + paths, + liveSlots, + registered, + initialMarkerObserved, + finalMarkerObserved, +}) { + const decoded = requests.flatMap((request) => + decodeAdRequest(request, paths), + ); + const requested = events.filter((event) => event.type === "slotRequested"); + assert.equal( + decoded.length, + requested.length, + "baseline network/public count mismatch", + ); + assert.equal( + decoded.filter((slot) => slot.path === registered.path).length, + 2, + "baseline requires exactly original and final real request memberships", + ); + assert.equal( + decoded.filter((slot) => slot.path !== registered.path).length, + 0, + "unrelated baseline inventory was requested", + ); + const members = requested.filter((event) => event.path === registered.path); + assert.deepEqual( + members.map((event) => event.id), + ["real-gpt-ssr", "real-gpt-baseline-final"], + ); + for (let index = 0; index < decoded.length; index++) + assert.deepEqual( + candidateTargeting(decoded[index].targeting), + candidateTargeting(members[index].targeting), + "baseline network/lifecycle targeting mismatch", + ); + const renders = events.filter( + (event) => + event.type === "slotRenderEnded" && event.path === registered.path, + ); + assert.equal( + renders.length, + 2, + "baseline requires explicit public completion, not a no-render timeout", + ); + assert.equal(renders[0].id, "real-gpt-ssr"); + assert.equal( + renders[0].creativeId, + registered.tsCreativeId, + "original SSR must render known TS creative before replacement", + ); + assert.equal(renders[0].isEmpty, false); + assert.equal( + initialMarkerObserved, + true, + "original SSR marker must have actually rendered", + ); + assert.equal(renders[1].id, "real-gpt-baseline-final"); + if (!renders[1].isEmpty) { + assert.ok( + [registered.tsCreativeId, registered.ordinaryCreativeId].includes( + renders[1].creativeId, + ), + "unknown creative is inventory failure, not original-issue proof", + ); + assert.equal( + finalMarkerObserved, + true, + "nonempty final creative needs its configured marker", + ); + } + const matching = liveSlots.filter((slot) => slot.path === registered.path); + const duplicatedMembership = + matching.some((slot) => slot.id === "real-gpt-ssr" && !slot.connected) && + matching.some( + (slot) => slot.id === "real-gpt-baseline-final" && slot.connected, + ); + const lostFinalTsCreative = + renders[1].isEmpty || + renders[1].creativeId === registered.ordinaryCreativeId; + assert.ok( + duplicatedMembership || lostFinalTsCreative, + "original failure was not reproduced by actual real GPT evidence", + ); + return { + originalFailureObserved: true, + duplicatedMembership, + lostFinalTsCreative, + decoded, + renders, + liveSlots, + }; +} + +module.exports = { + GPT_SCRIPT, + sha256, + distFingerprint, + inventory, + loadInventory, + options, + decodeAdRequest, + assertEvidence, + assertBaselineEvidence, + appConfig, +}; diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.test.cjs b/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.test.cjs new file mode 100644 index 0000000000..317c568370 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.test.cjs @@ -0,0 +1,384 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const { mkdtempSync, writeFileSync, rmSync, readFileSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); +const { + inventory, + options, + decodeAdRequest, + assertEvidence, + assertBaselineEvidence, + appConfig, + distFingerprint, +} = require("./real-gpt.cjs"); + +const input = { + schemaVersion: 1, + dedicatedTestInventory: true, + authorizeGoogleTraffic: true, + registered: { + path: "/123456789/registered", + tsCreativeId: 101, + ordinaryCreativeId: 102, + tsMarker: "Fixture publisher registration winner", + ordinaryMarker: "Ordinary test creative", + }, + unrelated: { path: "/123456789/unrelated" }, +}; +const candidate = { + hb_adid: ["fixture-win-fixture-header"], + hb_bidder: ["fixture-bidder"], + hb_pb: ["1.20"], +}; +const single = (extra = {}) => ({ + index: 0, + method: "GET", + url: + "https://publisher.example.com/gampad/ads?" + + new URLSearchParams({ + iu: input.registered.path, + cust_params: + "hb_adid=fixture-win-fixture-header&hb_bidder=fixture-bidder&hb_pb=1.20", + ...extra, + }), + postData: null, +}); +const events = () => [ + { + type: "slotRequested", + path: input.registered.path, + id: "real-gpt-final", + targeting: candidate, + }, + { + type: "slotRenderEnded", + path: input.registered.path, + id: "real-gpt-final", + creativeId: 101, + isEmpty: false, + }, +]; +const evidence = () => ({ + requests: [single()], + events: events(), + paths: [input.registered.path, input.unrelated.path], + expected: [ + { + path: input.registered.path, + id: "real-gpt-final", + candidate: true, + creativeId: 101, + markerObserved: true, + }, + ], +}); + +test("inventory has no permissive default and requires distinct dedicated creative evidence", () => { + assert.deepEqual(inventory(input).registered, input.registered); + for (const patch of [ + { authorizeGoogleTraffic: false }, + { dedicatedTestInventory: false }, + { schemaVersion: 2 }, + { registered: { ...input.registered, ordinaryCreativeId: 101 } }, + { registered: { ...input.registered, tsMarker: "" } }, + { unrelated: { path: input.registered.path } }, + ]) { + assert.throws(() => inventory({ ...input, ...patch })); + } + assert.throws(() => options([]), /inventory/); +}); + +test("options reject missing inventory, missing values, unknown/duplicate flags and bad ports", () => { + const dir = mkdtempSync(join(tmpdir(), "real-gpt-options-")); + try { + const file = join(dir, "inventory.json"); + writeFileSync(file, JSON.stringify(input)); + assert.equal(options(["--inventory", file, "--list"]).list, true); + for (const tail of [ + ["--unknown"], + ["--out"], + ["--list", "--list"], + ["--origin-port", "0", "--list"], + ["--origin-port", "NaN", "--list"], + [], + ]) { + assert.throws(() => options(["--inventory", file, ...tail])); + } + } finally { + rmSync(dir, { recursive: true }); + } +}); + +test("decodes single GET and URL encoded POST without inventing slot membership", () => { + const decoded = decodeAdRequest(single(), [input.registered.path]); + assert.deepEqual(decoded[0].targeting, candidate); + const post = { + index: 1, + method: "POST", + url: "https://publisher.example.com/gampad/ads", + postData: new URL(single().url).search.slice(1), + }; + assert.deepEqual( + decodeAdRequest(post, [input.registered.path])[0].targeting, + candidate, + ); + assert.throws( + () => decodeAdRequest({ ...post, postData: "{}" }, []), + /undecodable/, + ); +}); + +test("SRA counts every encoded inventory and aligns per-slot targeting", () => { + const request = { + index: 0, + method: "GET", + url: + "https://publisher.example.com/gampad/ads?" + + new URLSearchParams({ + iu_parts: "123456789,registered,unrelated", + enc_prev_ius: "/0/1,/0/2", + prev_scp: + "hb_pb=1.20&hb_bidder=fixture-bidder&hb_adid=fixture-win-fixture-header|publisher_section=fiction", + }), + }; + const decoded = decodeAdRequest(request, [ + input.registered.path, + input.unrelated.path, + ]); + assert.equal(decoded.length, 2); + assert.equal(decoded[1].path, input.unrelated.path); + assert.deepEqual(decoded[0].targeting, candidate); + const facts = evidence(); + facts.requests = [request]; + facts.events.push( + { + type: "slotRequested", + path: input.unrelated.path, + id: "real-gpt-unrelated", + targeting: { publisher_section: ["fiction"] }, + }, + { + type: "slotRenderEnded", + path: input.unrelated.path, + id: "real-gpt-unrelated", + creativeId: 102, + isEmpty: false, + }, + ); + facts.expected.push({ + path: input.unrelated.path, + id: "real-gpt-unrelated", + candidate: false, + creativeId: 102, + markerObserved: true, + }); + assert.equal(assertEvidence(facts).decoded.length, 2); +}); + +test("rejects undecodable, duplicate, ambiguous and unknown request inventories", () => { + for (const query of [ + "", + "iu=/123456789/registered&iu=/123456789/registered", + "iu=/123456789/other", + "iu_parts=123456789,registered&enc_prev_ius=/0/9", + "iu_parts=123456789,registered&enc_prev_ius=/0/1,/0/1", + "iu_parts=123456789,registered&enc_prev_ius=/0/1&prev_scp=a=1|b=2", + "iu_parts=123456789,registered&enc_prev_ius=opaque", + ]) { + assert.throws(() => + decodeAdRequest( + { + index: 0, + method: "GET", + url: `https://publisher.example.com/gampad/ads?${query}`, + }, + [input.registered.path], + ), + ); + } +}); + +test("observer accepts only matching real network, public targeting and creative evidence", () => { + assert.equal(assertEvidence(evidence()).publicRequestCount, 1); + for (const change of [ + (facts) => facts.requests.push(single()), + (facts) => facts.events.push(...events()), + (facts) => (facts.events[1].creativeId = 102), + (facts) => (facts.events[1].isEmpty = true), + (facts) => (facts.events[0].id = "removed-placeholder"), + (facts) => (facts.events[0].targeting = {}), + (facts) => (facts.expected[0].markerObserved = false), + (facts) => (facts.expected[0].candidate = false), + (facts) => (facts.requests[0] = single({ cust_params: "" })), + ]) { + const facts = evidence(); + change(facts); + assert.throws(() => assertEvidence(facts)); + } +}); + +test("generated dedicated app config retains real proxy/provider and excludes fake GPT and auto slots", () => { + const template = readFileSync( + join( + __dirname, + "../../fixtures/configs/trusted-server.publisher-registration.toml", + ), + "utf8", + ); + const app = appConfig( + template, + input, + { sha256: "a".repeat(64), sri: "sha384-YWJj" }, + 9999, + ); + assert.match(app, /rewrite_script = true/); + assert.match(app, /origin_url = "http:\/\/127.0.0.1:9999"/); + assert.match( + app, + /endpoint = "https:\/\/auction.example.com\/api\/fixture-auction"/, + ); + assert.match(app, /publisher-registration-real-gpt/); + assert.match(app, /initialization = "publisher_registered"/); + assert.doesNotMatch(app, /fixture-auto|ads.example.com|debug_bid/); + assert.equal( + (app.match(/\[\[creative_opportunities.slot\]\]/g) || []).length, + 1, + ); + assert.ok(app.includes(`gam_unit_path = "${input.registered.path}"`)); + const legacy = appConfig( + template, + input, + { sha256: "a".repeat(64), sri: "sha384-YWJj" }, + 9999, + true, + ); + assert.doesNotMatch(legacy, /initialization/); + assert.match(legacy, /div_id = "real-gpt-ssr"/); + assert.match(legacy, /height = 600/); +}); + +test("dist fingerprint uses canonical C order despite inherited en_US.UTF-8 locale", () => { + const { spawnSync } = require("node:child_process"); + const dir = mkdtempSync(join(tmpdir(), "real-gpt-dist-")); + try { + writeFileSync(join(dir, "tsjs-z.js"), "z"); + writeFileSync(join(dir, "tsjs-a.js"), "a"); + writeFileSync(join(dir, "tsjs-gpt.js"), "gpt"); + writeFileSync(join(dir, "tsjs-gpt_diagnostics.js"), "diagnostics"); + writeFileSync(join(dir, "ignored.txt"), "not a bundle"); + const shell = spawnSync( + "bash", + [ + "-c", + "find . -maxdepth 1 -name 'tsjs-*.js' -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}'", + ], + { + cwd: dir, + encoding: "utf8", + env: { ...process.env, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }, + }, + ); + assert.equal(shell.status, 0); + assert.equal(distFingerprint(dir), shell.stdout.trim()); + } finally { + rmSync(dir, { recursive: true }); + } +}); + +test("original-main observer demands known SSR creative and observed original failure", () => { + const baseline = () => ({ + requests: [single(), { ...single({ cust_params: "" }), index: 1 }], + events: [ + { ...events()[0], id: "real-gpt-ssr" }, + { ...events()[1], id: "real-gpt-ssr" }, + { + type: "slotRequested", + path: input.registered.path, + id: "real-gpt-baseline-final", + targeting: {}, + }, + { + type: "slotRenderEnded", + path: input.registered.path, + id: "real-gpt-baseline-final", + creativeId: 102, + isEmpty: false, + }, + ], + paths: [input.registered.path, input.unrelated.path], + registered: input.registered, + liveSlots: [ + { path: input.registered.path, id: "real-gpt-ssr", connected: false }, + { + path: input.registered.path, + id: "real-gpt-baseline-final", + connected: true, + }, + ], + initialMarkerObserved: true, + finalMarkerObserved: true, + }); + assert.equal( + assertBaselineEvidence(baseline()).originalFailureObserved, + true, + ); + for (const change of [ + (facts) => facts.requests.push(single()), + (facts) => (facts.initialMarkerObserved = false), + (facts) => (facts.events[1].creativeId = 102), + (facts) => (facts.events[3].creativeId = 999), + (facts) => (facts.finalMarkerObserved = false), + (facts) => { + facts.events[3].creativeId = 101; + facts.liveSlots.shift(); + }, + ]) { + const facts = baseline(); + change(facts); + assert.throws(() => assertBaselineEvidence(facts)); + } +}); + +test("actual provider control preserves defaults/path no-bid, forces transition, and resets", () => { + const { + FixtureAuctionControl, + } = require("../../fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/provider-control.ts"); + const control = new FixtureAuctionControl(); + assert.deepEqual(control.snapshot(), { noBid: false }); + assert.deepEqual(control.forPath("/publisher-registration"), { + noBid: false, + forcedNoBid: false, + source: "default", + }); + assert.deepEqual(control.forPath("/publisher-registration/no-bid"), { + noBid: true, + forcedNoBid: false, + source: "path", + }); + control.set({ noBid: true }); + assert.deepEqual(control.forPath("/publisher-registration-real-gpt"), { + noBid: true, + forcedNoBid: true, + source: "control", + }); + for (const value of [ + null, + [], + {}, + { noBid: "true" }, + { noBid: true, extra: true }, + ]) + assert.throws(() => control.set(value)); + assert.equal( + control.snapshot().noBid, + true, + "invalid control must not mutate mode", + ); + control.reset(); + assert.deepEqual(control.snapshot(), { noBid: false }); + assert.equal(control.forPath("/publisher-registration").noBid, false); + assert.equal(control.forPath("/publisher-registration/no-bid").noBid, true); + control.set({ noBid: false }); + assert.equal(control.forPath("/publisher-registration/no-bid").noBid, true); +}); diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs b/crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs index 2237be1120..7ca03d6bae 100644 --- a/crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs @@ -12,6 +12,7 @@ const { values: options } = parseArgs({ implementation: { type: "string", default: "all" }, out: { type: "string" }, prebid: { type: "string" }, + dist: { type: "string" }, }, }); assert( @@ -58,7 +59,9 @@ const manifest = JSON.parse( fs.readFileSync(path.join(prebidDirectory, "manifest.json"), "utf8"), ); const external = path.join(prebidDirectory, manifest.filename); -const dist = path.join(repo, "crates/trusted-server-js/dist"); +const dist = options.dist + ? path.resolve(options.dist) + : path.join(repo, "crates/trusted-server-js/dist"); const bootstrap = path.join( repo, "crates/trusted-server-core/src/integrations/gpt_bootstrap.js", @@ -119,6 +122,7 @@ const artifacts = { encoding: "utf8", }), node: process.version, + selectedDist: dist, externalPrebidManifest: manifest, pucVersion: require( path.join( diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts new file mode 100644 index 0000000000..af34e25d07 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts @@ -0,0 +1,919 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + expect, + test, + type Page, + type ElementHandle, + type Frame, + type Route, + type TestInfo, +} from "@playwright/test"; +import type { Controller } from "../../../fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/RealGptPublisher"; + +const { + loadInventory, + sha256, + assertEvidence, +} = require("../../publisher-registration/real-gpt.cjs"); +const { rendererAsset } = require("../../cwv/html.cjs"); +const baselineMode = + process.env.PUBLISHER_REGISTRATION_REAL_GPT_MODE === "legacy"; +const inventory = loadInventory( + process.env.PUBLISHER_REGISTRATION_REAL_GPT_INVENTORY_FILE, +); +const paths = [inventory.registered.path, inventory.unrelated.path]; +type Browser = Window & { + realGptPublisher: Controller; + realGptObservation: { events: Array> }; + tsjs?: { + tsRenderBridgeVersion?: number; + navGeneration?: number; + publisherSlotRegistrationVersion?: number; + }; + googletag: { apiReady?: boolean; pubads(): { __tsRefreshWrapped?: boolean } }; + pbjs?: { registerBidAdapter?: unknown }; +}; +type Network = { + index: number; + url: string; + method: string; + postData: string | null; + resourceType: string; + failure?: string; +}; +type Session = { + network: Network[]; + responses: Array>; + errors: string[]; + pending: Promise[]; + captureErrors: string[]; + interventions: Array>; + runtimeRoutes: Route[]; + hydrationRoutes: Route[]; + releaseHydration(): Promise; + releaseRuntime(): Promise; +}; +const sessions = new WeakMap(); +const baseUrl = () => { + expect( + process.env.PUBLISHER_REGISTRATION_REAL_GPT_BASE_URL, + "Use the real GPT launcher, not the ordinary browser suite", + ).toBeTruthy(); + return process.env.PUBLISHER_REGISTRATION_REAL_GPT_BASE_URL!; +}; +const publisher = (page: Page) => + page.evaluate(() => (window as unknown as Browser).realGptObservation.events); + +function consent() { + const bytes = Buffer.alloc(32); + const bits = (offset: number, width: number, value: number) => { + const binary = value.toString(2).padStart(width, "0"); + for (let index = 0; index < width; index++) + if (binary[index] === "1") + bytes[(offset + index) >> 3] |= 1 << (7 - ((offset + index) % 8)); + }; + const now = Math.floor(Date.now() / 100); + bits(0, 6, 2); + bits(6, 36, now); + bits(42, 36, now); + bits(152, 24, 1 << 23); + return bytes.toString("base64url"); +} + +async function install( + page: Page, + info: TestInfo, + runtime: "normal" | "failed" | "late" = "normal", +) { + const session: Session = { + network: [], + responses: [], + errors: [], + pending: [], + captureErrors: [], + interventions: [], + runtimeRoutes: [], + hydrationRoutes: [], + async releaseHydration() { + holdHydration = false; + for (const route of session.hydrationRoutes.splice(0)) + await route.continue(); + }, + async releaseRuntime() { + holdRuntime = false; + for (const route of session.runtimeRoutes.splice(0)) + await route.continue(); + }, + }; + sessions.set(page, session); + const reset = await page.request.delete( + `${process.env.PUBLISHER_REGISTRATION_REAL_GPT_ORIGIN}/api/fixture-auction`, + ); + expect( + reset.ok(), + "actual local provider ledger must reset before each case", + ).toBe(true); + let holdRuntime = runtime === "late"; + let holdHydration = baselineMode; + await page.addInitScript(() => { + if (window !== window.top) return; + const browser = window as unknown as { + realGptObservation: Browser["realGptObservation"]; + googletag?: { + cmd: { push(callback: () => void): unknown }; + pubads?(): { + addEventListener( + type: string, + callback: (event: { + slot: { + getSlotElementId(): string; + getAdUnitPath(): string; + getTargetingKeys(): string[]; + getTargeting(key: string): string[]; + }; + creativeId?: number; + sourceAgnosticCreativeId?: number; + isEmpty?: boolean; + size?: number[]; + }) => void, + ): void; + }; + }; + }; + browser.realGptObservation = { events: [] }; + browser.googletag = browser.googletag || { cmd: [] }; + browser.googletag.cmd.push(() => { + for (const type of [ + "slotRequested", + "slotResponseReceived", + "slotRenderEnded", + "slotOnload", + ]) + browser.googletag!.pubads!().addEventListener(type, (event) => { + const slot = event.slot; + browser.realGptObservation.events.push({ + type, + time: performance.now(), + id: slot.getSlotElementId(), + path: slot.getAdUnitPath(), + targeting: Object.fromEntries( + slot + .getTargetingKeys() + .map((key) => [key, slot.getTargeting(key)]), + ), + creativeId: event.creativeId ?? event.sourceAgnosticCreativeId, + isEmpty: event.isEmpty, + size: event.size, + }); + }); + }); + }); + const indexes = new WeakMap(); + page.on("request", (request) => { + const index = session.network.length; + indexes.set(request, index); + session.network.push({ + index, + url: request.url(), + method: request.method(), + postData: request.postData(), + resourceType: request.resourceType(), + }); + }); + page.on("requestfailed", (request) => { + const index = indexes.get(request); + if (index !== undefined) + session.network[index].failure = request.failure()?.errorText; + }); + page.on("pageerror", (error) => session.errors.push(error.message)); + page.on("response", (response) => { + const url = new URL(response.url()); + if ( + response.request().resourceType() !== "script" && + response.request().resourceType() !== "document" && + !url.pathname.endsWith("/gampad/ads") && + url.pathname !== "/_ts/page-bids" + ) + return; + const index = indexes.get(response.request()); + session.pending.push( + (async () => { + const bytes = await response.body(); + const filename = `network-${index}-${sha256(bytes)}.bin`; + writeFileSync(info.outputPath(filename), bytes); + session.responses.push({ + index, + url: response.url(), + status: response.status(), + headers: response.headers(), + sha256: sha256(bytes), + artifact: filename, + }); + })().catch((error) => { + session.captureErrors.push(`${response.url()}: ${String(error)}`); + }), + ); + }); + await page + .context() + .addCookies([{ name: "euconsent-v2", value: consent(), url: baseUrl() }]); + const prebidDir = process.env.PUBLISHER_REGISTRATION_PREBID_DIR; + expect(prebidDir, "--prebid verified actual bundle is required").toBeTruthy(); + const manifest = JSON.parse( + readFileSync(resolve(prebidDir!, "manifest.json"), "utf8"), + ); + const bytes = readFileSync(resolve(prebidDir!, manifest.filename)); + expect(sha256(bytes)).toBe(manifest.sha256); + await page.route("**/*", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin !== new URL(baseUrl()).origin) return route.continue(); + if ( + holdHydration && + url.pathname.startsWith("/_next/") && + request.resourceType() === "script" + ) { + session.hydrationRoutes.push(route); + return; + } + if (url.pathname === "/integrations/prebid/bundle.js") { + expect(url.searchParams.get("v")).toBe(manifest.sha256); + return route.fulfill({ + contentType: "application/javascript", + body: bytes, + }); + } + if ((runtime === "failed" || holdRuntime) && rendererAsset(url.pathname)) { + session.interventions.push({ type: runtime, url: url.href }); + if (holdRuntime) { + session.runtimeRoutes.push(route); + return; + } + return route.abort("failed"); + } + return route.continue(); + }); + return session; +} + +async function ready(page: Page, runtime = true, variant = "") { + await page.goto(`${baseUrl()}/publisher-registration-real-gpt${variant}`, { + waitUntil: "commit", + }); + await expect + .poll(() => + page.evaluate(() => { + const browser = window as unknown as Browser; + return ( + !!browser.realGptPublisher?.ready && + browser.googletag.apiReady === true && + browser.tsjs?.publisherSlotRegistrationVersion === 1 + ); + }), + ) + .toBe(true); + if (runtime) + await expect + .poll(() => + page.evaluate(() => { + const browser = window as unknown as Browser; + return ( + browser.tsjs?.tsRenderBridgeVersion === 1 && + typeof browser.pbjs?.registerBidAdapter === "function" && + browser.googletag.pubads().__tsRefreshWrapped === true + ); + }), + ) + .toBe(true); + expect( + (await publisher(page)).filter((event) => event.type === "slotRequested"), + ).toHaveLength(0); + expect( + sessions + .get(page)! + .network.filter((entry) => + new URL(entry.url).pathname.endsWith("/gampad/ads"), + ), + ).toHaveLength(0); + expect(await page.locator("#real-gpt-placeholder").count()).toBe(1); +} + +async function mount(page: Page, eligible = true, id = "real-gpt-final") { + await page.evaluate( + ({ id, eligible }) => + (window as unknown as Browser).realGptPublisher.mount(id, eligible), + { id, eligible }, + ); + await expect(page.locator(`#${id}`)).toHaveCount(1); +} + +async function visiblyPainted(element: ElementHandle) { + return element.evaluate((node) => { + if (!(node instanceof HTMLElement) || !node.isConnected || document.hidden) + return false; + const rect = node.getBoundingClientRect(); + let left = Math.max(0, rect.left); + let top = Math.max(0, rect.top); + let right = Math.min(innerWidth, rect.right); + let bottom = Math.min(innerHeight, rect.bottom); + for ( + let ancestor: HTMLElement | null = node; + ancestor; + ancestor = ancestor.parentElement + ) { + const css = getComputedStyle(ancestor); + if ( + css.display === "none" || + css.visibility === "hidden" || + css.visibility === "collapse" || + Number(css.opacity) === 0 || + css.contentVisibility === "hidden" + ) + return false; + const clip = ancestor.getBoundingClientRect(); + if (css.overflowX !== "visible") { + left = Math.max(left, clip.left); + right = Math.min(right, clip.right); + } + if (css.overflowY !== "visible") { + top = Math.max(top, clip.top); + bottom = Math.min(bottom, clip.bottom); + } + } + return right > left && bottom > top; + }); +} + +async function marker(page: Page, text: string, id: string) { + for (const element of await page.locator(`#${id} iframe`).elementHandles()) { + const root = await element.contentFrame(); + if (!root) continue; + const frames = [root]; + for (let index = 0; index < frames.length; index++) { + const frame = frames[index]; + frames.push(...frame.childFrames()); + try { + let ancestorsVisible = true; + for ( + let ancestor: Frame | null = frame; + ancestor && ancestor !== page.mainFrame(); + ancestor = ancestor.parentFrame() + ) { + if (!(await visiblyPainted(await ancestor.frameElement()))) { + ancestorsVisible = false; + break; + } + } + if (!ancestorsVisible) continue; + for (const textElement of await frame + .getByText(text, { exact: false }) + .elementHandles()) { + if (await visiblyPainted(textElement)) return true; + } + } catch { + /* Detached, hidden, collapsed and offscreen frames are not evidence. */ + } + } + } + return false; +} + +async function nativeSlots(page: Page) { + return page.evaluate(() => { + const tag = ( + window as unknown as { + googletag: { + pubads(): { + getSlots(): Array<{ + getSlotElementId(): string; + getAdUnitPath(): string; + }>; + }; + }; + } + ).googletag; + return tag + .pubads() + .getSlots() + .map((slot) => ({ + id: slot.getSlotElementId(), + path: slot.getAdUnitPath(), + connected: !!document.getElementById(slot.getSlotElementId()) + ?.isConnected, + })); + }); +} + +async function delivered( + page: Page, + candidate: boolean, + id = "real-gpt-final", + count = 1, +) { + const expectedMarker = candidate + ? inventory.registered.tsMarker + : inventory.registered.ordinaryMarker; + await expect + .poll( + async () => + (await publisher(page)).filter( + (event) => event.type === "slotRenderEnded" && event.id === id, + ).length, + ) + .toBe(count); + await expect + .poll(() => marker(page, expectedMarker, id), { + message: `Creative marker ${expectedMarker} must render inside an actual GPT frame`, + }) + .toBe(true); + return { + path: + id === "real-gpt-unrelated" + ? inventory.unrelated.path + : inventory.registered.path, + id, + candidate, + creativeId: candidate + ? inventory.registered.tsCreativeId + : inventory.registered.ordinaryCreativeId, + markerObserved: true, + }; +} + +async function verify(page: Page, expected: Array>) { + const session = sessions.get(page)!; + const events = await publisher(page); + const requests = session.network.filter((entry) => + new URL(entry.url).pathname.endsWith("/gampad/ads"), + ); + assertEvidence({ requests, events, expected, paths }); + await verifyProxy(page); +} + +async function verifyProxy(page: Page) { + const session = sessions.get(page)!; + await Promise.all(session.pending); + const scripts = session.responses.filter((entry) => + new URL(String(entry.url)).pathname.startsWith("/integrations/gpt/"), + ); + expect( + scripts.some( + (entry) => + new URL(String(entry.url)).pathname === "/integrations/gpt/script", + ), + "actual Rust GPT bootstrap proxy response", + ).toBe(true); + expect( + scripts.some((entry) => String(entry.url).includes("pubads_impl")), + "actual GPT dynamic script guard cascade", + ).toBe(true); + for (const script of scripts) { + expect(script.status).toBe(200); + expect((script.headers as Record)["x-gpt-proxy"]).toBe( + "true", + ); + } + const bypassed = session.network.filter( + (entry) => + entry.resourceType === "script" && + new URL(entry.url).origin !== new URL(baseUrl()).origin && + /(?:\/tag\/js\/gpt\.js|\/pubads_impl)/.test(new URL(entry.url).pathname), + ); + expect( + bypassed, + "GPT must execute through production proxy/interception, not direct external scripts", + ).toEqual([]); + expect(session.captureErrors).toEqual([]); + const unexpectedErrors = baselineMode + ? session.errors.filter( + (message) => + !/Hydration failed|hydrating|Text content does not match/i.test( + message, + ), + ) + : session.errors; + expect(unexpectedErrors).toEqual([]); +} + +test.afterEach(async ({ page }, info) => { + const session = sessions.get(page); + if (!session) return; + try { + // Always retain failure evidence, even if a renderer or inventory assertion failed. + const events = await publisher(page).catch(() => []); + const providerResponse = await page.request.get( + `${process.env.PUBLISHER_REGISTRATION_REAL_GPT_ORIGIN}/api/fixture-auction`, + ); + const providerText = await providerResponse.text(); + writeFileSync(info.outputPath("provider-raw.json"), providerText); + const provider = JSON.parse(providerText); + await Promise.all(session.pending); + const evidence = { + status: info.status, + expectedStatus: info.expectedStatus, + inventory, + network: session.network, + responses: session.responses, + events, + provider, + errors: session.errors, + captureErrors: session.captureErrors, + interventions: session.interventions, + }; + const file = info.outputPath("real-gpt-evidence.json"); + writeFileSync(file, JSON.stringify(evidence, null, 2)); + writeFileSync( + info.outputPath("real-gpt-evidence.sha256"), + `${sha256(readFileSync(file))} real-gpt-evidence.json\n`, + ); + await info.attach("real GPT network and public lifecycle", { + path: file, + contentType: "application/json", + }); + expect(providerResponse.ok()).toBe(true); + expect( + provider.exchanges.length, + "each scenario must actually reach local provider through Rust, not silently fall back on transport failure", + ).toBeGreaterThan(0); + for (const exchange of provider.exchanges) { + expect(exchange.path).toBe("/api/fixture-auction"); + for (const impression of exchange.request.imp) { + expect([ + "fixture-header", + "real-gpt-final", + "real-gpt-unrelated", + "real-gpt-final-replacement", + "real-gpt-navigation-final", + "real-gpt-ssr", + "real-gpt-baseline-final", + ]).toContain(impression.id); + expect(impression.ext.prebid.bidder["fixture-bidder"]).toBeTruthy(); + } + const noBid = exchange.mode.noBid; + expect( + exchange.mode.forcedNoBid || + new URL(exchange.request.site.page).pathname.endsWith("/no-bid"), + ).toBe(noBid); + if (noBid) expect(exchange.response.seatbid).toEqual([]); + else + expect(exchange.response.seatbid[0].bid[0].adm).toContain( + "Fixture publisher registration winner", + ); + } + } finally { + await session.releaseHydration(); + await session.releaseRuntime(); + } +}); + +if (!baselineMode) { + test("eligible first request uses TS creative and leaves unrelated inventory untouched", async ({ + page, + }, info) => { + await install(page, info); + await ready(page); + await mount(page); + const initial = await delivered(page, true); + await verify(page, [initial]); + }); + + for (const scenario of ["no-bid", "ineligible"] as const) + test(`${scenario} uses ordinary GAM fallback on the first request`, async ({ + page, + }, info) => { + await install(page, info); + await ready(page, true, scenario === "no-bid" ? "/no-bid" : ""); + await mount(page, scenario !== "ineligible"); + await verify(page, [await delivered(page, false)]); + }); + + for (const runtime of ["failed", "late"] as const) + test(`${runtime} deferred runtime preserves eligible early TS delivery without a repeat at takeover`, async ({ + page, + }, info) => { + const session = await install(page, info, runtime); + await ready(page, false); + await mount(page); + expect( + await page.evaluate( + () => (window as unknown as Browser).tsjs?.tsRenderBridgeVersion, + ), + "actual early renderer must survive the deferred-runtime fault", + ).toBe(1); + const initial = await delivered(page, true); + expect( + session.interventions.some((entry) => entry.type === runtime), + ).toBe(true); + if (runtime === "late") { + await session.releaseRuntime(); + await expect + .poll(() => + page.evaluate(() => { + const browser = window as unknown as Browser; + return ( + typeof browser.pbjs?.registerBidAdapter === "function" && + browser.googletag.pubads().__tsRefreshWrapped === true + ); + }), + ) + .toBe(true); + // The completed main-runtime installation is the observation boundary, not a sleep. + } + await verify(page, [initial]); + }); + + test("eligible TS-first candidate retires on real no-bid publisher refresh with SRA isolation", async ({ + page, + }, info) => { + const session = await install(page, info); + await ready(page); + await mount(page); + const initial = await delivered(page, true); + await verify(page, [initial]); + const providerUrl = `${process.env.PUBLISHER_REGISTRATION_REAL_GPT_ORIGIN}/api/fixture-auction`; + const beforeResponse = await page.request.get(providerUrl); + const before = await beforeResponse.json(); + expect(before.exchanges.length).toBeGreaterThan(0); + expect( + before.exchanges.every( + (exchange: { mode: { noBid: boolean } }) => + exchange.mode.noBid === false, + ), + ).toBe(true); + const transition = await page.request.put(providerUrl, { + data: { noBid: true }, + }); + expect(transition.ok()).toBe(true); + session.interventions.push({ + type: "actualProviderNoBidTransition", + priorExchanges: before.exchanges.length, + response: await transition.json(), + }); + await page.evaluate(() => + (window as unknown as Browser).realGptPublisher.refresh(true), + ); + const subsequent = await delivered(page, false, "real-gpt-final", 2); + const unrelated = await delivered(page, false, "real-gpt-unrelated"); + await verify(page, [initial, subsequent, unrelated]); + const afterResponse = await page.request.get(providerUrl); + const after = await afterResponse.json(); + const fresh = after.exchanges.slice(before.exchanges.length); + expect( + fresh.length, + "refresh must produce new actual provider exchanges, not reuse the initial SSR ledger", + ).toBeGreaterThan(0); + const freshImpressions = new Set(); + for (const exchange of fresh) { + expect(exchange.mode).toMatchObject({ + noBid: true, + forcedNoBid: true, + source: "control", + }); + expect(exchange.response.seatbid).toEqual([]); + for (const impression of exchange.request.imp) { + freshImpressions.add(impression.id); + expect(impression.ext.prebid.bidder["fixture-bidder"]).toBeTruthy(); + } + } + expect([...freshImpressions].sort()).toEqual([ + "real-gpt-final", + "real-gpt-unrelated", + ]); + writeFileSync( + info.outputPath("refresh-provider-transition.json"), + JSON.stringify({ before, fresh }, null, 2), + ); + const { + decodeAdRequest, + } = require("../../publisher-registration/real-gpt.cjs"); + expect( + session.network.some( + (request) => + new URL(request.url).pathname.endsWith("/gampad/ads") && + decodeAdRequest(request, paths).length === 2, + ), + "must observe actual two-slot SRA network request", + ).toBe(true); + expect( + session.network.some( + (request) => new URL(request.url).pathname === "/auction", + ), + "later refresh must run the actual Prebid client auction", + ).toBe(true); + }); + + test("replacing the SSR placeholder before registration uses only the final native mount", async ({ + page, + }, info) => { + await install(page, info); + await ready(page); + const placeholder = await page + .locator("#real-gpt-placeholder") + .elementHandle(); + await page.evaluate(() => + (window as unknown as Browser).realGptPublisher.replacePlaceholder(), + ); + await expect(page.locator("#real-gpt-placeholder")).toHaveAttribute( + "data-revision", + "1", + ); + expect(await placeholder!.evaluate((element) => element.isConnected)).toBe( + false, + ); + await mount(page, true, "real-gpt-final-replacement"); + await verify(page, [ + await delivered(page, true, "real-gpt-final-replacement"), + ]); + }); + + test("publisher cancellation before dispatch destroys connected native slot without a request", async ({ + page, + }, info) => { + await install(page, info); + await ready(page); + // Hold only the dispatch scheduling primitive. Do not replace GPT or server bids. + await page.evaluate(() => { + const browser = window as unknown as Browser & { + realGptFrames?: FrameRequestCallback[]; + }; + browser.realGptFrames = []; + window.requestAnimationFrame = (callback) => { + browser.realGptFrames!.push(callback); + return browser.realGptFrames!.length; + }; + }); + await mount(page); + await expect + .poll(() => + page.evaluate( + () => (window as unknown as Browser).realGptPublisher.handles.length, + ), + ) + .toBe(1); + await page.evaluate(async () => { + const browser = window as unknown as Browser & { + realGptFrames: FrameRequestCallback[]; + }; + await browser.realGptPublisher.remove(); + for (const callback of browser.realGptFrames.splice(0)) + callback(performance.now()); + }); + await expect(page.locator("#real-gpt-final")).toHaveCount(0); + await verify(page, []); + const destroyed = (await publisher(page)).filter( + (event) => event.type === "destroyBeforeRemove", + ); + expect(destroyed).toHaveLength(1); + expect(destroyed[0]).toMatchObject({ + id: "real-gpt-final", + connected: true, + destroyed: true, + nativeStillRegistered: false, + }); + expect( + (await nativeSlots(page)).filter( + (slot) => slot.path === inventory.registered.path, + ), + ).toEqual([]); + }); + + test("Next navigation retires old native placement and admits ordinary no-bid replacement", async ({ + page, + }, info) => { + await install(page, info); + await ready(page); + await mount(page); + const initial = await delivered(page, true); + await page.evaluate(() => + (window as unknown as Browser).realGptPublisher.navigate(), + ); + await expect(page.getByTestId("real-gpt-route")).toHaveText( + "/publisher-registration-real-gpt/no-bid", + ); + await expect + .poll(() => + page.evaluate( + () => (window as unknown as Browser).tsjs?.navGeneration ?? 0, + ), + ) + .toBeGreaterThan(0); + // Registration waits for the actual successful current SPA page-bids response. + await mount(page, true, "real-gpt-navigation-final"); + const next = await delivered(page, false, "real-gpt-navigation-final"); + await verify(page, [initial, next]); + const destroyed = (await publisher(page)).filter( + (event) => event.type === "destroyBeforeRemove", + ); + expect(destroyed).toHaveLength(1); + expect(destroyed[0]).toMatchObject({ + id: "real-gpt-final", + connected: true, + destroyed: true, + nativeStillRegistered: false, + }); + const live = await nativeSlots(page); + expect( + live.filter((slot) => slot.path === inventory.registered.path), + ).toEqual([ + { + id: "real-gpt-navigation-final", + path: inventory.registered.path, + connected: true, + }, + ]); + expect( + live.filter((slot) => slot.path === inventory.unrelated.path), + ).toEqual([ + { + id: "real-gpt-unrelated", + path: inventory.unrelated.path, + connected: true, + }, + ]); + }); +} else { + test("original main real GPT reproduces duplicate native membership or lost final TS creative after subtree replacement", async ({ + page, + }, info) => { + const session = await install(page, info); + await page.goto(`${baseUrl()}/publisher-registration-real-gpt`, { + waitUntil: "commit", + }); + await expect + .poll( + async () => + (await publisher(page)).filter( + (event) => + event.type === "slotRenderEnded" && event.id === "real-gpt-ssr", + ).length, + ) + .toBe(1); + await expect + .poll(() => marker(page, inventory.registered.tsMarker, "real-gpt-ssr")) + .toBe(true); + const initialMarkerObserved = true; + expect( + session.hydrationRoutes.length, + "original SSR creative must precede real Next hydration", + ).toBeGreaterThan(0); + await session.releaseHydration(); + await expect + .poll(() => + page.evaluate( + () => (window as unknown as Browser).realGptPublisher?.ready, + ), + ) + .toBe(true); + await mount(page, true, "real-gpt-baseline-final"); + await expect + .poll( + async () => + (await publisher(page)).filter( + (event) => + event.type === "slotRenderEnded" && + event.id === "real-gpt-baseline-final", + ).length, + ) + .toBe(1); + const events = await publisher(page); + const finalRender = events.find( + (event) => + event.type === "slotRenderEnded" && + event.id === "real-gpt-baseline-final", + )!; + let finalMarkerObserved = false; + if (!finalRender.isEmpty) { + const text = + finalRender.creativeId === inventory.registered.tsCreativeId + ? inventory.registered.tsMarker + : inventory.registered.ordinaryMarker; + await expect + .poll(() => marker(page, text, "real-gpt-baseline-final")) + .toBe(true); + finalMarkerObserved = true; + } + const liveSlots = await nativeSlots(page); + const { + assertBaselineEvidence, + } = require("../../publisher-registration/real-gpt.cjs"); + const reproduction = assertBaselineEvidence({ + requests: session.network.filter((request) => + new URL(request.url).pathname.endsWith("/gampad/ads"), + ), + events, + paths, + liveSlots, + registered: inventory.registered, + initialMarkerObserved, + finalMarkerObserved, + }); + const reproductionFile = info.outputPath("original-main-reproduction.json"); + writeFileSync(reproductionFile, JSON.stringify(reproduction, null, 2)); + await info.attach("original main reproduction", { + path: reproductionFile, + contentType: "application/json", + }); + await verifyProxy(page); + }); +} diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/fixture-auction/route.ts b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/fixture-auction/route.ts index fa9d4a55fa..75584d9e2a 100644 --- a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/fixture-auction/route.ts +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/fixture-auction/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { FixtureAuctionControl } from "../../publisher-registration-real-gpt/provider-control"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -18,18 +19,32 @@ type Exchange = { path: string; request: AuctionRequest; response: object; + mode: ReturnType; }; // Module state belongs to this isolated fixture container, not a browser bid map. const exchanges: Exchange[] = []; +const control = new FixtureAuctionControl(); export async function GET() { - return NextResponse.json({ exchanges }); + return NextResponse.json({ exchanges, control: control.snapshot() }); +} + +export async function PUT(request: NextRequest) { + try { + return NextResponse.json({ control: control.set(await request.json()) }); + } catch { + return NextResponse.json( + { error: "fixture control must be exactly {noBid:boolean}" }, + { status: 400 }, + ); + } } export async function DELETE() { exchanges.length = 0; - return NextResponse.json({ exchanges }); + control.reset(); + return NextResponse.json({ exchanges, control: control.snapshot() }); } export async function POST(request: NextRequest) { @@ -54,7 +69,8 @@ export async function POST(request: NextRequest) { const pagePath = new URL( auction.site?.page || "https://publisher.example.com/", ).pathname; - const noBid = pagePath.endsWith("/no-bid"); + const mode = control.forPath(pagePath); + const noBid = mode.noBid; const height = pagePath.endsWith("/tall") ? 600 : 250; const response = { id: auction.id, @@ -82,6 +98,7 @@ export async function POST(request: NextRequest) { path: request.nextUrl.pathname, request: auction, response, + mode, }); console.log( JSON.stringify({ fixtureAuction: exchanges[exchanges.length - 1] }), diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/RealGptPublisher.tsx b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/RealGptPublisher.tsx new file mode 100644 index 0000000000..6fe6d4c82b --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/RealGptPublisher.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { useLayoutEffect, useRef, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; + +export type RealGptInventory = { + registered: { path: string }; + unrelated: { path: string }; +}; +type Slot = { + addService(service: Service): Slot; + setTargeting(key: string, value: string): Slot; + getSlotElementId(): string; + getAdUnitPath(): string; + getTargetingKeys(): string[]; + getTargeting(key: string): string[]; +}; +type Service = { + disableInitialLoad(): void; + enableSingleRequest(): void; + refresh(slots: Slot[]): void; + getSlots(): Slot[]; + addEventListener( + type: string, + callback: (event: { + slot: Slot; + creativeId?: number; + sourceAgnosticCreativeId?: number; + isEmpty?: boolean; + size?: number[]; + }) => void, + ): void; +}; +type Handle = { + cancel(): void; + getStatus(): { status: string; reason?: string }; +}; +type Tag = { + cmd: { push(callback: () => void): unknown }; + pubads(): Service; + defineSlot(path: string, sizes: number[][], id: string): Slot | null; + destroySlots(slots: Slot[]): boolean; + display(id: string): void; + enableServices(): void; +}; +type Current = Window & { + googletag: Tag; + tsjs?: { + navGeneration?: number; + registerPublisherSlot(options: { + slotId: string; + gptSlot: Slot; + navigationGeneration: number; + eligibleFormats: number[][]; + }): Handle; + }; + pbjs?: { + addAdUnits( + units: Array<{ + code: string; + mediaTypes: { banner: { sizes: number[][] } }; + bids: Array<{ bidder: string; params: { placement: string } }>; + }>, + ): void; + removeAdUnit(code: string): void; + }; + realGptPublisher?: Controller; + realGptObservation?: { events: Array> }; +}; +export type Controller = { + ready: boolean; + events: Array>; + slots: Slot[]; + handles: Handle[]; + mount(id?: string, eligible?: boolean): void; + replacePlaceholder(): void; + remove(): Promise; + refresh(includeUnrelated?: boolean): void; + cancel(): void; + navigate(): Promise; + status(): unknown; +}; +const current = () => window as unknown as Current; + +export default function RealGptPublisher({ + inventory, + legacy = false, +}: { + inventory: RealGptInventory; + legacy?: boolean; +}) { + const pathname = usePathname(); + const router = useRouter(); + const [placement, setPlacement] = useState<{ + id: string; + eligible: boolean; + }>(); + const [placeholderRevision, setPlaceholderRevision] = useState(0); + const live = useRef<{ slot: Slot; handle?: Handle }>(); + const unrelated = useRef(); + const authoredUnits = useRef(new Set()); + const controller = useRef(); + const record = (type: string, details: Record = {}) => { + controller.current!.events.push({ + type, + time: performance.now(), + ...details, + }); + }; + if (!controller.current) + controller.current = { + ready: false, + events: [], + slots: [], + handles: [], + mount(id = "real-gpt-final", eligible = true) { + setPlacement({ id, eligible }); + }, + replacePlaceholder() { + setPlaceholderRevision((revision) => revision + 1); + }, + async remove() { + const instance = live.current; + if (instance) { + instance.handle?.cancel(); + record("cancelBeforeDestroy", { + id: instance.slot.getSlotElementId(), + connected: !!document.getElementById( + instance.slot.getSlotElementId(), + )?.isConnected, + }); + await new Promise((resolve, reject) => + current().googletag.cmd.push(() => { + const destroyed = current().googletag.destroySlots([ + instance.slot, + ]); + const nativeStillRegistered = current() + .googletag.pubads() + .getSlots() + .includes(instance.slot); + record("destroyBeforeRemove", { + destroyed, + nativeStillRegistered, + id: instance.slot.getSlotElementId(), + connected: !!document.getElementById( + instance.slot.getSlotElementId(), + )?.isConnected, + }); + if (!destroyed || nativeStillRegistered) { + reject(new Error("Actual GPT native slot destruction failed")); + return; + } + resolve(); + }), + ); + const code = instance.slot.getSlotElementId(); + if (authoredUnits.current.delete(code)) + current().pbjs?.removeAdUnit(code); + live.current = undefined; + } + setPlacement(undefined); + }, + refresh(includeUnrelated = false) { + if (!live.current) throw new Error("No live registered slot"); + const targets = [ + live.current.slot, + ...(includeUnrelated && unrelated.current ? [unrelated.current] : []), + ]; + const pbjs = current().pbjs; + if (!pbjs?.addAdUnits) + throw new Error( + "Actual Prebid library is unavailable for publisher refresh", + ); + for (const slot of targets) { + const code = slot.getSlotElementId(); + if (authoredUnits.current.has(code)) continue; + pbjs.addAdUnits([ + { + code, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [ + { + bidder: "fixture-bidder", + params: { placement: "fixture-header" }, + }, + ], + }, + ]); + authoredUnits.current.add(code); + record("publisherPrebidUnit", { code, bidder: "fixture-bidder" }); + } + current().googletag.pubads().refresh(targets); + }, + cancel() { + live.current?.handle?.cancel(); + }, + async navigate() { + await this.remove(); + router.push("/publisher-registration-real-gpt/no-bid"); + }, + status() { + return live.current?.handle?.getStatus() ?? null; + }, + }; + + useLayoutEffect(() => { + const browser = current(); + if (browser.realGptObservation) + controller.current!.events = browser.realGptObservation.events; + browser.realGptPublisher = controller.current; + browser.googletag.cmd.push(() => { + const service = browser.googletag.pubads(); + if (!legacy) { + service.disableInitialLoad(); + service.enableSingleRequest(); + } + for (const type of browser.realGptObservation + ? [] + : [ + "slotRequested", + "slotResponseReceived", + "slotRenderEnded", + "slotOnload", + ]) { + service.addEventListener(type, (event) => { + const slot = event.slot; + record(type, { + id: slot.getSlotElementId(), + path: slot.getAdUnitPath(), + targeting: Object.fromEntries( + slot + .getTargetingKeys() + .map((key) => [key, slot.getTargeting(key)]), + ), + creativeId: event.creativeId ?? event.sourceAgnosticCreativeId, + isEmpty: event.isEmpty, + size: event.size, + }); + }); + } + if (!legacy) { + unrelated.current = browser.googletag + .defineSlot( + inventory.unrelated.path, + [[300, 250]], + "real-gpt-unrelated", + ) + ?.addService(service); + if (!unrelated.current) + throw new Error("Dedicated unrelated GPT slot definition failed"); + } + browser.googletag.enableServices(); + if (!legacy) browser.googletag.display("real-gpt-unrelated"); + controller.current!.ready = true; + record("serviceReady"); + }); + return () => { + live.current?.handle?.cancel(); + if (live.current) browser.googletag.destroySlots([live.current.slot]); + if (unrelated.current) + browser.googletag.destroySlots([unrelated.current]); + for (const code of Array.from(authoredUnits.current)) + browser.pbjs?.removeAdUnit(code); + authoredUnits.current.clear(); + delete browser.realGptPublisher; + }; + }, []); + + useLayoutEffect(() => { + if (!placement) return; + const browser = current(); + let disposed = false; + const generation = browser.tsjs?.navGeneration ?? 0; + browser.googletag.cmd.push(() => { + if (disposed) return; + if (!legacy && !browser.tsjs?.registerPublisherSlot) + throw new Error("Actual Rust registration bootstrap is unavailable"); + const slot = browser.googletag + .defineSlot(inventory.registered.path, [[300, 250]], placement.id) + ?.addService(browser.googletag.pubads()); + if (!slot) throw new Error("Dedicated GPT slot definition failed"); + slot.setTargeting("publisher_section", "fiction"); + if (legacy) { + live.current = { slot }; + controller.current!.slots.push(slot); + record("legacyFinalNative", { + id: placement.id, + formats: [[300, 250]], + }); + browser.googletag.display(placement.id); + return; + } + const handle = browser.tsjs!.registerPublisherSlot({ + slotId: "fixture-header", + gptSlot: slot, + navigationGeneration: generation, + eligibleFormats: placement.eligible ? [[300, 250]] : [], + }); + live.current = { slot, handle }; + controller.current!.slots.push(slot); + controller.current!.handles.push(handle); + record("registered", { + id: placement.id, + generation, + status: handle.getStatus(), + }); + }); + return () => { + disposed = true; + }; + }, [placement]); + + return ( +
+

Dedicated real GPT publisher registration acceptance

+

{pathname}

+ + + +
+ {placement ? ( +
+ ) : ( +
+ Publisher has not registered +
+ )} +
+
+
+ ); +} diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/no-bid/page.tsx b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/no-bid/page.tsx new file mode 100644 index 0000000000..e66fbf49a2 --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/no-bid/page.tsx @@ -0,0 +1,2 @@ +export { default } from "../page"; +export const dynamic = "force-dynamic"; diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/page.tsx b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/page.tsx new file mode 100644 index 0000000000..0cc8848b2a --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration-real-gpt/page.tsx @@ -0,0 +1,37 @@ +import RealGptPublisher, { type RealGptInventory } from "./RealGptPublisher"; + +export const dynamic = "force-dynamic"; + +export default function RealGptPage() { + const raw = process.env.PUBLISHER_REGISTRATION_REAL_GPT_INVENTORY; + if (!raw) + throw new Error( + "Dedicated real GPT inventory must be supplied by the acceptance launcher", + ); + const inventory = JSON.parse(raw) as RealGptInventory & { + authorizeGoogleTraffic?: boolean; + dedicatedTestInventory?: boolean; + gptScriptUrl?: string; + }; + if ( + inventory.authorizeGoogleTraffic !== true || + inventory.dedicatedTestInventory !== true || + !inventory.gptScriptUrl + ) { + throw new Error("Dedicated real GPT inventory authorization is missing"); + } + return ( + <> + " )]; - // The synchronous compiled runtime installs the initializer and registration - // APIs before publisher scripts. Do not introduce a new inline hash requirement - // for the changed provisional bootstrap on pages retaining enforced CSP. - if !enforced_csp { + // The supported synchronous unified runtime installs the initializer and + // registration APIs before publisher scripts. Retain this compatibility + // implementation only where composition still requires it. + if include_provisional { scripts.push(format!("", GPT_BOOTSTRAP_JS)); } @@ -1241,7 +1243,7 @@ mod tests { } #[test] - fn head_inserts_embed_the_publisher_registration_v1_bootstrap() { + fn legacy_head_inserts_embed_the_publisher_registration_v1_bootstrap() { let integration = GptIntegration::new(test_config()); let document_state = IntegrationDocumentState::default(); let context = IntegrationHtmlContext { @@ -1270,26 +1272,25 @@ mod tests { inserts[1].contains("ts.registerPublisherSlot ="), "should include the publisher registration entrypoint" ); - // The shared Vitest registration contract executes this included file. - // This check protects Rust delivery of that artifact, not JS execution. + // The shared Vitest registration contract executes this retained file. + // This is legacy reference coverage, not proof of emitted candidate HTML. } #[test] - fn publisher_registration_bootstrap_preserves_document_injection_order() { + fn publisher_registration_runtime_preserves_document_injection_order() { const PUBLISHER_HEAD: &str = ""; const SLOT_SCRIPT: &str = ""; const SHARED_SEAM: &str = ""; - let settings = create_test_settings(); - let integration = GptIntegration::new(test_config()); - let registry = IntegrationRegistry::from_rewriters_with_head_injectors( - Vec::new(), - Vec::new(), - vec![integration], - ); + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt", &serde_json::json!({"enabled": true})) + .expect("should enable the production GPT profile"); + let registry = IntegrationRegistry::new(&settings).expect("should build GPT registry"); let html = format!("{PUBLISHER_HEAD}

example-body

"); - for shared in [false, true] { + for (shared, chunk_size) in [(false, 1), (true, 1), (false, 8192), (true, 8192)] { let config = HtmlProcessorConfig::from_settings( &settings, ®istry, @@ -1308,7 +1309,7 @@ mod tests { PipelineConfig { input_compression: Compression::None, output_compression: Compression::None, - chunk_size: 8192, + chunk_size, }, processor, ); @@ -1320,9 +1321,6 @@ mod tests { let enabled = output .find("window.__tsjs_gpt_enabled=true;") .expect("should retain the GPT activation insert"); - let bootstrap = output - .find(GPT_BOOTSTRAP_JS) - .expect("should retain the complete inline bootstrap"); let bundle = output .find("id=\"trustedserver-js\"") .expect("should retain the main bundle tag"); @@ -1339,25 +1337,24 @@ mod tests { "var b=JSON.parse(" }) .expect("should retain the selected body payload seam"); - // The included head bootstrap also mentions in a comment. let body_close = output .rfind("") .expect("should retain the body close"); assert!( - enabled < bootstrap - && bootstrap + GPT_BOOTSTRAP_JS.len() < bundle + enabled < bundle && bundle < publisher && publisher < body && body < payload && payload < body_close, "should install the complete API before publisher head callbacks while preserving head/body order, shared={shared}" ); - assert_eq!( - output.matches(GPT_BOOTSTRAP_JS).count(), - 1, - "should inject the registration bootstrap once" + assert!( + !output.contains(GPT_BOOTSTRAP_JS) + && !output.contains("id=\"trustedserver-bootstrap\""), + "should install registration from the cacheable runtime, not duplicate inline initializers" ); + assert!(output.contains("tsjs-unified.min.js?v=")); if shared { assert!( !output.contains(SLOT_SCRIPT), @@ -1438,6 +1435,8 @@ mod tests { } } + // These direct-injector tests retain the raw initializer's compatibility + // contract. They do not describe the supported production profile's output. #[test] fn head_inserts_includes_ts_ad_init_with_synchronous_bids_read() { let config = test_config(); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 125045813e..85508306a4 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -602,13 +602,13 @@ pub trait IntegrationHeadInjector: Send + Sync { /// Return HTML snippets to insert at the start of ``. fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec; - /// Return head snippets when an enforced response CSP is present. + /// Return head snippets with the provisional initializer loading decision. /// - /// Integrations without a CSP-specific loading dependency retain their existing snippets. - fn head_inserts_with_response_csp( + /// Integrations without a provisional initializer retain their existing snippets. + fn head_inserts_with_provisional_bootstrap( &self, ctx: &IntegrationHtmlContext<'_>, - _enforced_csp: bool, + _include_provisional: bool, ) -> Vec { self.head_inserts(ctx) } @@ -1182,9 +1182,15 @@ impl IntegrationRegistry { ctx: &IntegrationHtmlContext<'_>, enforced_csp: bool, ) -> Vec { + // Supported GPT profiles install these APIs from the synchronous unified + // bundle. Other profiles retain their provisional initializer unless the + // actual enforced response CSP requires the existing no-inline path. + let include_provisional = + !enforced_csp && !crate::tsjs::supports_unified_gpt(&self.js_module_ids()); let mut inserts = Vec::new(); for injector in &self.inner.head_injectors { - let mut next = injector.head_inserts_with_response_csp(ctx, enforced_csp); + let mut next = + injector.head_inserts_with_provisional_bootstrap(ctx, include_provisional); if !next.is_empty() { inserts.append(&mut next); } @@ -2381,6 +2387,73 @@ mod tests { ); } + #[test] + fn provisional_gpt_initializer_policy_preserves_other_profiles_and_actual_csp() { + for gpt_enabled in [false, true] { + for didomi_enabled in [false, true] { + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .integrations + .insert_config( + "gpt", + &serde_json::json!({ + "enabled": gpt_enabled, + "slim_prebid_url": "https://assets.example/slim-prebid.js" + }), + ) + .expect("should configure GPT"); + settings + .integrations + .insert_config("didomi", &serde_json::json!({"enabled": didomi_enabled})) + .expect("should configure an order-sensitive integration"); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let document_state = IntegrationDocumentState::default(); + let context = IntegrationHtmlContext { + request_host: "publisher.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + document_state: &document_state, + }; + let legacy: Vec = registry + .inner + .head_injectors + .iter() + .flat_map(|injector| injector.head_inserts(&context)) + .collect(); + for enforced_csp in [false, true] { + let inserts = registry.head_inserts_with_response_csp(&context, enforced_csp); + assert_eq!( + inserts + .join("") + .contains("// Edge-injected GPT auction bootstrap."), + gpt_enabled && didomi_enabled && !enforced_csp, + "should select provisional initialization from real CSP and module dependencies" + ); + if !enforced_csp && (!gpt_enabled || didomi_enabled) { + assert_eq!( + inserts, legacy, + "should preserve unrelated profiles byte-for-byte" + ); + } + if gpt_enabled { + let activation = inserts + .iter() + .position(|insert| insert.contains("window.__tsjs_gpt_enabled=true;")) + .expect("should retain GPT activation"); + let slim_prebid = inserts + .iter() + .position(|insert| insert.contains("window.__tsjs_slim_prebid_url=")) + .expect("should retain slim-Prebid configuration"); + assert!( + activation < slim_prebid, + "should retain GPT configuration order" + ); + } + } + } + } + } + #[test] fn js_module_ids_defer_prebid_and_include_core_js_only_modules() { let settings = crate::test_support::tests::create_test_settings(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index e8e0329e64..1a1aaf7e09 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -18103,20 +18103,18 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { } #[tokio::test] - async fn enforced_csp_origin_header_selects_sync_loading_independent_of_admission() { - for (policy_header, policy_value, enforced) in [ - (None, "", false), + async fn origin_policy_headers_preserve_unified_loading_independent_of_admission() { + for (policy_header, policy_value) in [ + (None, ""), ( Some("content-security-policy-report-only"), "script-src 'none'", - false, ), - (Some("content-security-policy"), "", true), - (Some("content-security-policy"), "default-src *", true), + (Some("content-security-policy"), ""), + (Some("content-security-policy"), "default-src *"), ( Some("Content-Security-Policy"), "script-src 'self' 'sha256-old-policy'", - true, ), ] { for admitted in [false, true] { @@ -18156,14 +18154,13 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { } let document = String::from_utf8(body_of(response).await).expect("should produce HTML"); - assert_eq!( + assert!( document.contains("tsjs-unified.min.js?v="), - enforced, - "origin header presence should control loading, admitted={admitted} header={policy_header:?}" + "should retain cacheable unified loading, admitted={admitted} header={policy_header:?}" ); - assert_eq!( - document.contains("id=\"trustedserver-bootstrap\""), - !enforced + assert!( + !document.contains("id=\"trustedserver-bootstrap\""), + "should not embed an uncacheable early bundle" ); if !admitted { assert!( @@ -18176,12 +18173,13 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { } #[tokio::test] - async fn enforced_csp_loading_choice_survives_shared_template_cold_and_warm_paths() { + async fn unified_loading_preserves_policy_on_shared_template_cold_and_warm_paths() { for finalizer in [Finalizer::Streaming, Finalizer::Buffered] { - for (policy_header, enforced) in [ - ("content-security-policy", true), - ("content-security-policy-report-only", false), + for policy_header in [ + "content-security-policy", + "content-security-policy-report-only", ] { + let enforced = policy_header == "content-security-policy"; let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); let mut configured = settings_with_mode("esi"); @@ -18221,10 +18219,10 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { ); let document = String::from_utf8(body_of(response).await) .expect("should produce HTML"); - assert_eq!(document.contains("tsjs-unified.min.js?v="), enforced); - assert_eq!( - document.contains("id=\"trustedserver-bootstrap\""), - !enforced + assert!(document.contains("tsjs-unified.min.js?v=")); + assert!( + !document.contains("id=\"trustedserver-bootstrap\""), + "should retain cacheable loading on both cold and warm templates" ); } assert_eq!( diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index d6c10b5e0d..e8c4e02c60 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -1,38 +1,24 @@ -use trusted_server_js::{concatenated_hash, module_bundle, single_module_hash}; +use trusted_server_js::{concatenated_hash, single_module_hash}; use crate::integrations::IntegrationRegistry; /// Publisher runtime tags, preserving synchronous delivery for early dependencies. /// -/// GPT-only pages install the real early APIs inline and defer the standalone -/// GPT runtime. Other integrations keep their established execution order until -/// their early dependencies have equivalent browser coverage. Enforced response -/// CSP or active diagnostics also requires the established synchronous path. +/// The cacheable unified bundle installs core and GPT APIs before publisher +/// scripts. Separately deferred integrations retain their established order. +/// Supported GPT profiles have no inline initializer if this critical request fails. #[must_use] -pub(crate) fn publisher_script_tags( - integrations: &IntegrationRegistry, - synchronous_required: bool, -) -> String { - let attributes = integrations.tsjs_script_tag_attributes(); - if !supports_deferred_gpt(&integrations.js_module_ids(), synchronous_required) { - return tsjs_script_tag_with_attributes( - &integrations.js_module_ids_immediate(), - &attributes, - ); - } - - let bootstrap = module_bundle("bootstrap").expect("should embed the early publisher bootstrap"); - let attributes = trusted_script_attributes(&attributes); - format!( - "\ - ", - tsjs_single_module_script_src("gpt"), +pub(crate) fn publisher_script_tags(integrations: &IntegrationRegistry) -> String { + tsjs_script_tag_with_attributes( + &integrations.js_module_ids_immediate(), + &integrations.tsjs_script_tag_attributes(), ) } -fn supports_deferred_gpt(module_ids: &[&str], synchronous_required: bool) -> bool { - !synchronous_required - && module_ids.contains(&"gpt") +/// GPT profiles whose unified runtime replaces the provisional initializer. +/// Other integrations retain their existing head snippets pending caller coverage. +pub(crate) fn supports_unified_gpt(module_ids: &[&str]) -> bool { + module_ids.contains(&"gpt") && module_ids .iter() .all(|id| matches!(*id, "creative" | "gpt" | "prebid")) @@ -148,6 +134,7 @@ pub fn tsjs_deferred_script_tags(module_ids: &[&str]) -> String { #[cfg(test)] mod tests { use super::*; + use trusted_server_js::module_bundle; fn hash_query_value(src: &str) -> &str { src.split_once("?v=") @@ -164,11 +151,10 @@ mod tests { } #[test] - fn deferred_gpt_requires_a_proven_profile_without_active_diagnostics() { - assert!(supports_deferred_gpt(&["creative", "gpt"], false)); - assert!(supports_deferred_gpt(&["creative", "gpt", "prebid"], false)); - assert!(!supports_deferred_gpt(&["creative"], false)); - assert!(!supports_deferred_gpt(&["creative", "gpt"], true)); + fn unified_gpt_initializer_replacement_requires_a_supported_module_profile() { + assert!(supports_unified_gpt(&["creative", "gpt"])); + assert!(supports_unified_gpt(&["creative", "gpt", "prebid"])); + assert!(!supports_unified_gpt(&["creative"])); for blocked in [ "datadome", "google_tag_manager", @@ -181,14 +167,14 @@ mod tests { "future_integration", ] { assert!( - !supports_deferred_gpt(&["creative", "gpt", blocked], false), - "should retain synchronous loading for {blocked}", + !supports_unified_gpt(&["creative", "gpt", blocked]), + "should retain provisional initialization for {blocked}", ); } } #[test] - fn synchronous_loading_requirement_preserves_the_existing_publisher_tag() { + fn publisher_runtime_preserves_the_existing_synchronous_tag() { let mut settings = crate::test_support::tests::create_test_settings(); settings .integrations @@ -196,17 +182,94 @@ mod tests { .expect("should enable GPT"); let registry = IntegrationRegistry::new(&settings).expect("should build GPT registry"); assert_eq!( - publisher_script_tags(®istry, true), + publisher_script_tags(®istry), tsjs_script_tag_with_attributes( ®istry.js_module_ids_immediate(), ®istry.tsjs_script_tag_attributes(), ), "should retain the exact synchronous tag instead of introducing a CSP hash requirement", ); - assert!( - publisher_script_tags(®istry, false).contains("id=\"trustedserver-bootstrap\""), - "should still defer a supported profile without a synchronous requirement" + } + + #[test] + fn supported_gpt_profiles_use_one_versioned_synchronous_unified_runtime() { + for prebid_enabled in [false, true] { + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .integrations + .insert_config( + "gpt", + &serde_json::json!({"enabled": true, "gam_attribution_enabled": true}), + ) + .expect("should enable GPT attribution"); + settings + .integrations + .insert_config( + "prebid", + &serde_json::json!({ + "enabled": prebid_enabled, + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js" + }), + ) + .expect("should configure separately deferred Prebid"); + let registry = IntegrationRegistry::new(&settings).expect("should build GPT registry"); + let output = publisher_script_tags(®istry); + assert_eq!( + output, + tsjs_script_tag_with_attributes( + ®istry.js_module_ids_immediate(), + &[("data-ts-gam-attribution", "true")], + ), + "should synchronously load precisely the modules served by the unified route" + ); + assert_eq!(output.matches("'; +const config = ""; +const html = `${config}${runtime}
`; + +test("screen independently requires synchronous versioned unified with config before it", () => { + for (const profile of ["baseline", "loading", "reservation", "combined"]) + assertScreenDocument(html, profile); + for (const changed of [ + html.replace('id="trustedserver-js"', 'id="trustedserver-js" defer'), + html.replace('id="trustedserver-js"', 'id="trustedserver-js" async'), + html.replace( + 'id="trustedserver-js"', + 'id="trustedserver-js" type="module"', + ), + html.replace("?v=example", ""), + html.replace("tsjs-unified", "tsjs-gpt"), + html.replace(config + runtime, runtime + config), + html.replace(config, ""), + html.replace(runtime, runtime + runtime), + html.replace( + config, + config + '', + ), + html.replace( + config, + config + + "", + ), + ]) + assert.throws(() => assertScreenDocument(changed, "combined")); +}); + +test("original-main allows its actual raw bootstrap instead of using candidate HTML", () => { + const legacy = html.replace( + config, + "", + ); + assertScreenDocument(legacy, "baseline"); + assertScreenDocument(legacy, "reservation"); + assert.throws(() => assertScreenDocument(legacy, "loading")); +}); + +function probeFixture() { + const listeners = {}; + const context = vm.createContext({ + window: { cwv: {} }, + performance: { now: () => 1 }, + document: { + getElementById: () => null, + addEventListener: (name, callback) => { + listeners[name] = callback; + }, + }, + }); + vm.runInContext(`(${installEarlyProbe.toString()})();`, context); + const { window } = context; + const callback = window.tsjs.que[0]; + for (const name of [ + "setConfig", + "getConfig", + "requestAds", + "addAdUnits", + "renderAdUnit", + "renderAllAdUnits", + ]) + window.tsjs[name] = () => ({}); + callback(); + for (const name of [ + "adInit", + "scheduleInitialAdInit", + "registerPublisherSlot", + ]) + window.tsjs[name] = () => {}; + window.__tsjs_installGptShim = () => {}; + window.tsjs.publisherSlotRegistrationVersion = 1; + window.tsjs.tsRenderBridgeVersion = 1; + listeners.load({ target: { id: "trustedserver-js" } }); + return JSON.parse(JSON.stringify(window.cwv)); +} + +test("pre-core queue drain observes core methods without assuming GPT has initialized", () => { + const raw = probeFixture(); + assert.equal( + raw.preCoreQueue.callbacks[0].apis.methods.registerPublisherSlot, + "undefined", + ); + assert.equal( + raw.preCoreQueue.runtimeLoads[0].apis.methods.registerPublisherSlot, + "function", + ); + assertEarlyProbe(raw, "combined"); + assertEarlyProbe(raw, "baseline"); +}); + +test("early API evidence fails closed for dropped/duplicate queue callbacks and missing GPT APIs", () => { + for (const change of [ + (raw) => { + raw.preCoreQueue.callbacks = []; + }, + (raw) => { + raw.preCoreQueue.callbacks.push(raw.preCoreQueue.callbacks[0]); + }, + (raw) => { + raw.preCoreQueue.callbacks[0].coreCallSucceeded = false; + }, + (raw) => { + raw.preCoreQueue.callbacks[0].articlePresent = true; + }, + (raw) => { + raw.preCoreQueue.runtimeLoads = []; + }, + (raw) => { + raw.preCoreQueue.runtimeLoads[0].apis.methods.registerPublisherSlot = + "undefined"; + }, + ]) { + const raw = probeFixture(); + change(raw); + assert.throws(() => assertEarlyProbe(raw, "combined")); + } +}); + +test("screen executes precisely eight times runs with alternating four-way order", () => { + const cases = screenCases(3); + assert.equal(cases.length, 24); + assert.deepEqual( + cases.slice(0, 8).map((entry) => entry.profile), + [ + "baseline", + "loading", + "reservation", + "combined", + "combined", + "reservation", + "loading", + "baseline", + ], + ); + assert.deepEqual( + [...new Set(cases.map((entry) => entry.viewport.width))], + [390, 1280], + ); + const samples = cases.map((entry) => ({ + ...entry, + stage: "measurement", + mode: "slow", + control: null, + })); + assertScreenSamples(samples, 3); + assert.throws(() => assertScreenSamples([], 3)); + assert.throws(() => assertScreenSamples(samples.slice(1), 3)); + assert.throws(() => assertScreenSamples([...samples, samples[0]], 3)); + assert.throws(() => assertScreenSamples([...samples].reverse(), 3)); +}); + +test("outage diagnostic declares exactly four complete normal/CSP success/failure cases", () => { + const cases = outageCases(); + assert.deepEqual( + cases.map(({ name, mode }) => [name, mode]), + [ + ["inline", "slow"], + ["inline", "failed"], + ["csp", "slow"], + ["csp", "failed"], + ], + ); + const samples = cases.map((entry) => ({ + ...entry, + stage: "correctness", + control: null, + })); + assertOutageSamples(samples); + assert.throws(() => assertOutageSamples([])); + assert.throws(() => assertOutageSamples(samples.slice(1))); + assert.throws(() => assertOutageSamples([...samples, samples[0]])); + assert.throws(() => assertOutageSamples([...samples].reverse())); +}); + +test("critical outage evidence requires failed transport, undrained queue and completed parser", () => { + const runtimeUrl = + "https://publisher.example.com/static/tsjs=tsjs-unified.min.js?v=example"; + const raw = () => ({ + preCoreQueue: { callbacks: [], runtimeLoads: [] }, + scriptLoads: [], + navigation: [{ domContentLoadedEventEnd: 1 }], + geometry: [{ article: { top: 1 } }], + }); + const responses = () => [{ url: runtimeUrl, status: 404 }]; + assertCriticalOutage(raw(), responses(), runtimeUrl); + for (const change of [ + (raw) => raw.preCoreQueue.callbacks.push({}), + (raw) => raw.preCoreQueue.runtimeLoads.push({}), + (raw) => raw.scriptLoads.push({ src: runtimeUrl }), + (raw) => { + raw.navigation[0].domContentLoadedEventEnd = 0; + }, + (raw) => { + raw.geometry[0].article = null; + }, + ]) { + const changed = raw(); + change(changed); + assert.throws(() => assertCriticalOutage(changed, responses(), runtimeUrl)); + } + for (const changed of [ + [], + [{ url: runtimeUrl, status: 200 }], + [...responses(), ...responses()], + ]) + assert.throws(() => assertCriticalOutage(raw(), changed, runtimeUrl)); +}); + +test("public full acceptance verifier rejects both diagnostic scopes", async () => { + const { pathToFileURL } = require("node:url"); + const { validateMeasurement, conditions } = await import( + pathToFileURL( + path.resolve( + __dirname, + "../../../../scripts/test-client-cwv-acceptance.mjs", + ), + ).href + ); + for (const scope of ["diagnostic-screen", "diagnostic-outages"]) + assert.throws( + () => + validateMeasurement( + { schemaVersion: 1, status: "passed", scope }, + conditions[0], + 1, + ), + (error) => + error.actual === scope && + error.expected === "measurement-and-correctness", + ); +}); + +for (const scope of ["diagnostic-screen", "diagnostic-outages"]) + test(`missing ${scope} exports invalidate stale passing outcome before browser startup`, () => { + const out = fs.mkdtempSync(path.join(os.tmpdir(), "cwv-diagnostic-")); + try { + fs.writeFileSync( + path.join(out, "acceptance.json"), + JSON.stringify({ + status: "passed", + scope: "measurement-and-correctness", + }), + ); + const missing = path.join(out, "missing"); + const result = spawnSync( + process.execPath, + [ + path.join(__dirname, "run.cjs"), + `--${scope}`, + "--runtime-delay-ms", + "0", + "--baseline", + missing, + "--loading", + missing, + "--combined", + missing, + "--out", + out, + ], + { encoding: "utf8" }, + ); + assert.equal(result.status, 1, result.stderr); + const outcome = JSON.parse( + fs.readFileSync(path.join(out, "acceptance.json")), + ); + assert.equal(outcome.status, "blocked"); + assert.equal(outcome.scope, scope); + assert.equal(outcome.samples, 0); + assert.equal(outcome.diagnostic.fullAcceptancePassed, false); + assert.equal( + outcome.diagnostic.expectedSamples, + scope === "diagnostic-screen" ? 40 : 4, + ); + if (scope === "diagnostic-outages") + assert.equal(outcome.diagnostic.changedOutageBehaviorApproved, false); + assert.match(outcome.error.message, /ENOENT/); + for (const control of Object.values(outcome.negativeControls)) { + assert.equal(control.status, "not-requested"); + assert.match( + control.reason, + scope === "diagnostic-screen" + ? /outside this screen/ + : /observed, not approved/, + ); + } + assert.deepEqual( + JSON.parse(fs.readFileSync(path.join(out, "artifacts.json"))).outcome, + outcome, + ); + } finally { + fs.rmSync(out, { recursive: true, force: true }); + } + }); diff --git a/crates/trusted-server-integration-tests/browser/cwv/options.cjs b/crates/trusted-server-integration-tests/browser/cwv/options.cjs index 84d6c574d9..6ce5d1ab55 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/options.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/options.cjs @@ -12,6 +12,8 @@ function parseOptions(args = process.argv.slice(2)) { runs: { type: "string", default: "5" }, "correctness-only": { type: "boolean" }, "regressions-only": { type: "boolean" }, + "diagnostic-screen": { type: "boolean" }, + "diagnostic-outages": { type: "boolean" }, "runtime-delay-ms": { type: "string", default: "750" }, "cpu-rate": { type: "string", default: "1" }, "network-latency-ms": { type: "string", default: "0" }, @@ -32,6 +34,22 @@ function parseOptions(args = process.argv.slice(2)) { !(values["correctness-only"] && values["regressions-only"]), "Choose only one of --correctness-only and --regressions-only", ); + if (values["diagnostic-outages"]) { + assert(values.baseline, "--diagnostic-outages requires --baseline"); + assert( + !values["diagnostic-screen"] && + !values["correctness-only"] && + !values["regressions-only"], + "--diagnostic-outages cannot select another scope", + ); + } + if (values["diagnostic-screen"]) { + assert(values.baseline, "--diagnostic-screen requires --baseline"); + assert( + !values["correctness-only"] && !values["regressions-only"], + "--diagnostic-screen cannot select full-mode correctness or regressions", + ); + } for (const name of [ "runs", "runtime-delay-ms", @@ -44,6 +62,12 @@ function parseOptions(args = process.argv.slice(2)) { values[name] = Number(values[name]); assert(Number.isFinite(values[name]), `--${name} must be finite`); } + if (values["diagnostic-screen"]) + assert.equal( + values["runtime-delay-ms"], + 0, + "--diagnostic-screen requires explicit --runtime-delay-ms 0", + ); assert( Number.isSafeInteger(values.runs) && values.runs > 0, "--runs must be a positive safe integer", diff --git a/crates/trusted-server-integration-tests/browser/cwv/options.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/options.test.cjs index 7e6cc3da17..8bb386706c 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/options.test.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/options.test.cjs @@ -76,6 +76,62 @@ for (const [flag, value] of [ assert.throws(() => parseOptions([...required, `--${flag}`, value])); }); +test("diagnostic screen requires baseline, zero hold and its own exclusive scope", () => { + const measurement = [...required.slice(0, -1), "--baseline", "baseline"]; + const screen = [ + ...measurement, + "--diagnostic-screen", + "--runtime-delay-ms", + "0", + ]; + const options = parseOptions(screen); + assert.equal(options["diagnostic-screen"], true); + assert.equal(options["runtime-delay-ms"], 0); + assert.equal(options.baseline, "baseline"); + assert.throws( + () => parseOptions([...measurement, "--diagnostic-screen"]), + /runtime-delay-ms 0/, + ); + assert.throws( + () => + parseOptions([ + ...required.slice(0, -1), + "--diagnostic-screen", + "--runtime-delay-ms", + "0", + ]), + /baseline/, + ); + for (const mode of ["--correctness-only", "--regressions-only"]) + assert.throws(() => parseOptions([...screen, mode]), /cannot select/); +}); + +test("outage diagnostic requires baseline and is exclusive without changing other defaults", () => { + const measurement = [...required.slice(0, -1), "--baseline", "baseline"]; + const args = [ + ...measurement, + "--diagnostic-outages", + "--runtime-delay-ms", + "0", + ]; + const options = parseOptions(args); + assert.equal(options["diagnostic-outages"], true); + assert.equal(options["diagnostic-screen"], undefined); + assert.equal(options["runtime-delay-ms"], 0); + assert.equal(options.cache, "cold"); + assert.throws( + () => parseOptions([...required.slice(0, -1), "--diagnostic-outages"]), + /baseline/, + ); + for (const mode of [ + "--diagnostic-screen", + "--correctness-only", + "--regressions-only", + ]) + assert.throws(() => parseOptions([...args, mode]), /another scope/); + assert.equal(parseOptions([...measurement, "--diagnostic-outages"]).runs, 5); +}); + test("requires explicit measurement inputs and rejects ambiguous modes/unknown options", () => { assert.throws(() => parseOptions([])); assert.throws(() => parseOptions(required.slice(0, -1)), /baseline/); diff --git a/crates/trusted-server-integration-tests/browser/cwv/run.cjs b/crates/trusted-server-integration-tests/browser/cwv/run.cjs index cf246d2cc9..aa2cd84476 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/run.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/run.cjs @@ -16,6 +16,9 @@ const { const { sessionCls, distribution, observePage } = require("./metrics.cjs"); const { fixtureProxy } = require("./proxy.cjs"); const options = parseOptions(); +const diagnosticScreen = options["diagnostic-screen"]; +const diagnosticOutages = options["diagnostic-outages"]; +const unifiedDiagnostic = diagnosticScreen || diagnosticOutages; const settings = conditions(options); const runs = options.runs; const out = path.resolve(options.out); @@ -25,11 +28,15 @@ const save = (name, value) => const acceptance = { schemaVersion: 1, status: "running", - scope: options["regressions-only"] - ? "regressions" - : options["correctness-only"] - ? "correctness" - : "measurement-and-correctness", + scope: diagnosticScreen + ? "diagnostic-screen" + : diagnosticOutages + ? "diagnostic-outages" + : options["regressions-only"] + ? "regressions" + : options["correctness-only"] + ? "correctness" + : "measurement-and-correctness", startedAt: new Date().toISOString(), conditions: settings, samples: 0, @@ -38,11 +45,17 @@ const acceptance = { ["parser-blocking", "reservation-removed"].map((id) => [ id, { - status: options["regressions-only"] ? "not-requested" : "pending", - ...(options["regressions-only"] + status: + unifiedDiagnostic || options["regressions-only"] + ? "not-requested" + : "pending", + ...(unifiedDiagnostic || options["regressions-only"] ? { - reason: - "--regressions-only selects the four review regressions, not the negative controls", + reason: diagnosticScreen + ? "Diagnostic synchronous-unified measurement only; deferred parser control is inapplicable and reservation-removal correctness is outside this screen. Full acceptance remains unexecuted." + : diagnosticOutages + ? "Four critical-runtime availability diagnostics only; parser-defer and reservation-removal controls are not requested. Changed outage behavior is observed, not approved." + : "--regressions-only selects the four review regressions, not the negative controls", } : {}), }, @@ -76,6 +89,39 @@ saveOutcome(); process.on("uncaughtExceptionMonitor", recordFailure); const { chromium } = require("playwright"); const { JSDOM } = require("jsdom"); +const { + baselineRevision, + expectedArchitecture, + assertScreenDocument, + installEarlyProbe, + assertEarlyProbe, + screenCases, + assertScreenSamples, + outageCases, + assertOutageSamples, + assertCriticalOutage, +} = require("./diagnostic.cjs"); +if (diagnosticScreen) { + acceptance.diagnostic = { + expectedArchitecture, + expectedSamples: 8 * runs, + fullAcceptancePassed: false, + excluded: + "Full correctness, controls, framework lifecycle, CSP/outage matrix and real-GPT acceptance are not executed by this screen. Missing authorized Google inventory remains blocked outside this scope.", + }; + saveOutcome(); +} +if (diagnosticOutages) { + acceptance.diagnostic = { + expectedArchitecture, + expectedSamples: 4, + fullAcceptancePassed: false, + changedOutageBehaviorApproved: false, + excluded: + "Observes successful and failed critical unified loading with and without enforced CSP. Loss of inline fallback is a changed outage boundary, not compatibility approval. No retry/recovery, full correctness, controller, framework or live acceptance is established. Missing authorized Google inventory remains blocked outside this scope.", + }; + saveOutcome(); +} const { reservationStyles, withoutEarlyRenderer, @@ -139,6 +185,12 @@ function snapshot(directory, label) { const loading = snapshot(options.loading, "loading"); const combined = snapshot(options.combined, "combined"); const baseline = options.baseline && snapshot(options.baseline, "baseline"); +if (unifiedDiagnostic) + assert.equal( + baseline.manifest.sourceRevision, + baselineRevision, + "Diagnostic baseline must remain pinned original-main, not the failing optimized PR snapshot", + ); const profiles = { loading, combined, @@ -177,6 +229,9 @@ evidence = { optionsSha256: sha256(fs.readFileSync(path.join(__dirname, "options.cjs"))), evidenceSha256: sha256(fs.readFileSync(path.join(__dirname, "evidence.cjs"))), proxySha256: sha256(fs.readFileSync(path.join(__dirname, "proxy.cjs"))), + diagnosticSha256: sha256( + fs.readFileSync(path.join(__dirname, "diagnostic.cjs")), + ), proxyRequests: [], interventions: [ { @@ -381,7 +436,9 @@ function installDelivery({ height, noFill }) { const samples = []; let stage = - options["correctness-only"] || options["regressions-only"] + options["correctness-only"] || + options["regressions-only"] || + diagnosticOutages ? "correctness" : "measurement"; async function sample( @@ -398,15 +455,26 @@ async function sample( saveOutcome(); } const input = documentInput(profile, name); - const cspRuntimeUnavailable = input.enforcedCsp && mode === "failed"; + if (diagnosticOutages) + assert.equal( + input.enforcedCsp, + name === "csp", + "Outage matrix must separately exercise normal and enforced-CSP production documents", + ); + const runtimeUnavailable = + (input.enforcedCsp || diagnosticOutages) && mode === "failed"; // Keep the existing publisher-side probe. Its core call deliberately fails // when the synchronous runtime is unavailable; no other page error is allowed. - const expectedErrors = cspRuntimeUnavailable + const expectedErrors = runtimeUnavailable ? ["tsjs.setConfig is not a function"] : []; + if (unifiedDiagnostic) assertScreenDocument(input.html, profile); + const expectedDeferred = unifiedDiagnostic + ? false + : !input.enforcedCsp && (profile === "loading" || profile === "combined"); assert.equal( input.deferred, - !input.enforcedCsp && (profile === "loading" || profile === "combined"), + expectedDeferred, `${profile} must exercise its intended production loading mode`, ); const index = samples.length; @@ -491,7 +559,7 @@ async function sample( !input.html.includes("// Edge-injected GPT auction bootstrap."), "Enforced CSP must not depend on the provisional raw bootstrap's inline hash", ); - if (cspRuntimeUnavailable) + if (runtimeUnavailable) evidence.interventions.push({ index, profile, @@ -561,6 +629,7 @@ async function sample( await context.addInitScript({ content: `if(window===window.top){ (${observePage.toString()})(); + ${unifiedDiagnostic ? `(${installEarlyProbe.toString()})();` : ""} window.publisherFixtureOptions={initialLoad:${JSON.stringify(input.registered ? "disabled" : "enabled")}}; ${fixtureSource.replaceAll("https://creative.example.com", creativeOrigin)} (${installDelivery.toString()})(${JSON.stringify({ height: input.creativeHeight, noFill })}); @@ -610,7 +679,7 @@ async function sample( }); if (options.cache === "warm") { await navigate(); - if (input.matched && !cspRuntimeUnavailable) + if (input.matched && !runtimeUnavailable) await page.waitForFunction(() => cwv.delivered, null, { timeout: 10000, }); @@ -640,7 +709,7 @@ async function sample( active.phase = "measurement"; } await navigate(); - if (input.matched && !cspRuntimeUnavailable) + if (input.matched && !runtimeUnavailable) await page.waitForFunction(() => cwv.delivered, null, { timeout: 10000 }); if (mode === "late-runtime") { await page.evaluate( @@ -678,7 +747,20 @@ async function sample( expectedErrors, "Only declared fixture page errors are allowed", ); - if (input.enforcedCsp) assertCspRuntimeApis(raw, !cspRuntimeUnavailable); + if (input.enforcedCsp || diagnosticOutages) + assertCspRuntimeApis(raw, !runtimeUnavailable); + if (diagnosticOutages && runtimeUnavailable) { + assertCriticalOutage( + raw, + active.responses, + new URL(input.runtimeSrc, publisherOrigin).href, + ); + assert.equal( + active.runtimeAttempts, + 1, + "Critical outage must issue one failed runtime request without retry", + ); + } if (input.enforcedCsp) assert.deepEqual( raw.cspViolations, @@ -706,19 +788,22 @@ async function sample( [new URL(input.runtimeSrc, publisherOrigin).pathname], "Actual standalone GPT renderer must be blocked", ); - } else if (!cspRuntimeUnavailable) { + } else if (!runtimeUnavailable) { assert( raw.early.core && raw.early.queued, - "Direct core API and queued publisher callback must work before deferred runtime", + unifiedDiagnostic + ? "Direct core API and queued publisher callback must work at the first publisher script" + : "Direct core API and queued publisher callback must work before deferred runtime", ); assertCandidateRendererVersion(raw, profile); + if (unifiedDiagnostic) assertEarlyProbe(raw, profile); } assert.equal( raw.fixture.requests.length, - input.matched && !cspRuntimeUnavailable ? 1 : 0, + input.matched && !runtimeUnavailable ? 1 : 0, "Exactly one native initial request requires a matched slot and available runtime or early bridge", ); - if (input.matched && !cspRuntimeUnavailable) { + if (input.matched && !runtimeUnavailable) { assert.equal( raw.fixture.trace.filter((entry) => entry.event === "nativeDefine") .length, @@ -797,15 +882,15 @@ async function sample( assertRuntimeOrdering( raw, new URL(input.runtimeSrc, publisherOrigin).href, - input.deferred && control !== "parser-blocking", + expectedDeferred && control !== "parser-blocking", ); - if (!input.deferred || control === "parser-blocking") + if (!expectedDeferred || control === "parser-blocking") assert( fcp >= responseEnd, "Parser-blocking runtime must finish before FCP", ); if ( - input.deferred && + expectedDeferred && control !== "parser-blocking" && options.cache === "cold" && settings.runtimeDelayMs === 750 @@ -840,11 +925,13 @@ async function sample( expectedHeight, "Responsive reservation must match viewport", ); - if (cspRuntimeUnavailable) { + if (runtimeUnavailable) { assert.equal( cls, 0, - "Unavailable CSP runtime must not cause ad-induced layout shift", + diagnosticOutages + ? "Unavailable critical runtime must not cause ad-induced layout shift" + : "Unavailable CSP runtime must not cause ad-induced layout shift", ); } else if (noFill || input.creativeHeight <= expectedHeight) { assert.equal( @@ -875,7 +962,20 @@ async function sample( result = { index, control, - runtimeUnavailableBoundary: cspRuntimeUnavailable, + runtimeUnavailableBoundary: runtimeUnavailable, + ...(diagnosticOutages + ? { + diagnosticOutage: { + policy: input.enforcedCsp + ? "enforced-old-hash-self-csp" + : "normal", + expectedRuntimeAvailable: !runtimeUnavailable, + changedOutageBehaviorApproved: false, + interpretation: + "Observed critical-script availability boundary, not compatibility approval or recovery proof", + }, + } + : {}), conditions: settings, cache: { mode: options.cache, @@ -996,7 +1096,21 @@ async function sample( evidence.origins = { publisherOrigin, creativeOrigin }; const mobile = { width: 390, height: 844 }; const desktop = { width: 1280, height: 1000 }; - if (!options["correctness-only"] && !options["regressions-only"]) { + if (diagnosticOutages) { + for (const { profile, name, viewport, mode } of outageCases()) + await sample(browser, profile, name, viewport, mode); + assertOutageSamples(samples); + } else if (diagnosticScreen) { + for (const { profile, name, viewport } of screenCases(runs)) + await sample(browser, profile, name, viewport); + assertScreenSamples(samples, runs); + assert( + samples + .filter((entry) => ["baseline", "loading"].includes(entry.profile)) + .every((entry) => entry.metrics.cls > 0), + "Unreserved diagnostic arms must reproduce ad-induced layout shift", + ); + } else if (!options["correctness-only"] && !options["regressions-only"]) { for (const viewport of [mobile, desktop]) { for (let run = 0; run < runs; run++) { const order = ["baseline", "loading", "reservation", "combined"]; @@ -1013,56 +1127,64 @@ async function sample( "Unreserved controls must visibly reproduce ad-induced layout shift", ); } - stage = "correctness"; - if (options["correctness-only"]) { - for (const viewport of [mobile, desktop]) { - const control = await sample(browser, "loading", "inline", viewport); - assert( - control.metrics.cls > 0, - "Unreserved control must visibly shift after fill", - ); + if (!unifiedDiagnostic) { + stage = "correctness"; + if (options["correctness-only"]) { + for (const viewport of [mobile, desktop]) { + const control = await sample(browser, "loading", "inline", viewport); + assert( + control.metrics.cls > 0, + "Unreserved control must visibly shift after fill", + ); + } } - } - await sample(browser, "combined", "registered", mobile, "renderer-absent"); - await sample(browser, "combined", "registered", mobile, "failed"); - await sample(browser, "combined", "csp", mobile); - await sample(browser, "combined", "csp", mobile, "failed"); - if (!options["regressions-only"]) { await sample( browser, "combined", - "inline", + "registered", mobile, - "slow", - false, - "parser-blocking", + "renderer-absent", ); - await sample( - browser, - "combined", - "inline", - mobile, - "slow", - false, - "reservation-removed", - ); - for (const mode of ["slow", "failed", "absent", "late-runtime"]) { - await sample(browser, "combined", "inline", mobile, mode); - await sample(browser, "combined", "registered", mobile, mode); + await sample(browser, "combined", "registered", mobile, "failed"); + await sample(browser, "combined", "csp", mobile); + await sample(browser, "combined", "csp", mobile, "failed"); + if (!options["regressions-only"]) { + await sample( + browser, + "combined", + "inline", + mobile, + "slow", + false, + "parser-blocking", + ); + await sample( + browser, + "combined", + "inline", + mobile, + "slow", + false, + "reservation-removed", + ); + for (const mode of ["slow", "failed", "absent", "late-runtime"]) { + await sample(browser, "combined", "inline", mobile, mode); + await sample(browser, "combined", "registered", mobile, mode); + } + for (const name of [ + "shared", + "absent-wrapper", + "unmatched", + "child-replacement", + "unknown-height", + ]) + await sample(browser, "combined", name, mobile); + await sample(browser, "combined", "shared", desktop); + await sample(browser, "loading", "unknown-height", mobile); + for (const width of [1023, 1024]) + await sample(browser, "combined", "tall", { width, height: 1000 }); + await sample(browser, "combined", "inline", mobile, "slow", true); } - for (const name of [ - "shared", - "absent-wrapper", - "unmatched", - "child-replacement", - "unknown-height", - ]) - await sample(browser, "combined", name, mobile); - await sample(browser, "combined", "shared", desktop); - await sample(browser, "loading", "unknown-height", mobile); - for (const width of [1023, 1024]) - await sample(browser, "combined", "tall", { width, height: 1000 }); - await sample(browser, "combined", "inline", mobile, "slow", true); } const groups = {}; for (const entry of samples) { @@ -1115,7 +1237,11 @@ async function sample( } save("comparisons.json", comparisons); console.log( - "CWV functional contracts passed; raw timings and ranges retained, no timing-improvement threshold applied.", + diagnosticScreen + ? "Diagnostic screen completed; raw timings retained. Full correctness, negative controls, framework/live gates and combined acceptance remain unexecuted." + : diagnosticOutages + ? "Four availability diagnostics completed. Changed critical-outage behavior is observed, not approved. Full acceptance remains unexecuted." + : "CWV functional contracts passed; raw timings and ranges retained, no timing-improvement threshold applied.", ); } finally { evidence.finishedAt = new Date().toISOString(); diff --git a/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs index 127b122def..891e876b87 100644 --- a/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs @@ -20,7 +20,7 @@ let dom; beforeAll(async () => { outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-iife-state-')); - for (const name of ['core', 'creative', 'permutive']) { + for (const name of ['core', 'creative', 'permutive', 'gpt']) { const entryPath = name === 'core' ? path.join(libDir, 'src', 'core', 'index.ts') @@ -43,6 +43,7 @@ function createPage({ logLevel, debug = false, storedDebug = false, + beforeRuntime, } = {}) { dom = new JSDOM('
', { url: `https://publisher.example.com/${debug ? '?tsdebug=1' : ''}`, @@ -69,6 +70,7 @@ function createPage({ if (logLevel) { window.tsjs = { que: [() => window.tsjs.setConfig({ logLevel })] }; } + beforeRuntime?.(window); // Match bundle.rs: core first and independent IIFEs joined into one script. window.eval(modules.map((name) => bundles.get(name)).join(';\n')); return { window, info, debugLog }; @@ -101,6 +103,58 @@ function requestContext(window) { } describe('shared state across production IIFEs', () => { + it('installs the unified GPT runtime without either inline initializer', () => { + const queued = []; + const configuration = { logLevel: 'silent' }; + let slots; + const { window } = createPage({ + modules: ['core', 'creative', 'gpt'], + beforeRuntime(browser) { + browser.__tsjs_gpt_enabled = true; + slots = []; + browser.tsjs = { + adSlots: slots, + que: [ + function () { + queued.push({ + context: this, + core: ['setConfig', 'getConfig', 'requestAds', 'addAdUnits'].map( + (name) => typeof browser.tsjs[name] + ), + }); + browser.tsjs.setConfig(configuration); + }, + ], + }; + }, + }); + expect(queued).toHaveLength(1); + expect(queued[0].context).toBe(window.tsjs); + expect(queued[0].core).toEqual(Array(4).fill('function')); + expect(window.tsjs.getConfig().logLevel).toBe('silent'); + expect(window.tsjs.adSlots).toBe(slots); + // Core drains its queue before the GPT IIFE. All GPT APIs must exist + // when the complete synchronous script returns to publisher code. + for (const name of ['adInit', 'scheduleInitialAdInit', 'registerPublisherSlot']) { + expect(typeof window.tsjs[name], name).toBe('function'); + } + expect(window.tsjs.publisherSlotRegistrationVersion).toBe(1); + expect(window.tsjs.tsRenderBridgeVersion).toBe(1); + expect(typeof window.__tsjs_installGptShim).toBe('function'); + expect(window.googletag.cmd.__tsPushed).toBe(true); + const later = vi.fn(); + window.tsjs.que.push(later); + expect(later).toHaveBeenCalledOnce(); + expect(later.mock.instances[0]).toBe(window.tsjs); + expect(queued).toHaveLength(1); + + const script = window.document.createElement('script'); + script.src = 'https://securepubads.g.doubleclick.net/tag/js/gpt.js'; + window.document.head.appendChild(script); + expect(script.src).toBe('https://publisher.example.com/integrations/gpt/tag/js/gpt.js'); + expect(window.fetch).not.toHaveBeenCalled(); + }); + it('sends Permutive context from an integration bundle through core requestAds', () => { const { window } = createPage(); expect(requestContext(window)).toEqual({ permutive_segments: ['111', '222'] }); diff --git a/docs/guide/integrations/gpt.md b/docs/guide/integrations/gpt.md index 9ebab8de49..46c6e2bb85 100644 --- a/docs/guide/integrations/gpt.md +++ b/docs/guide/integrations/gpt.md @@ -93,36 +93,36 @@ Successful proxy responses include the `X-GPT-Proxy: true` header for debugging. ## Client-Side Shim -The GPT integration shares early interception and rendering code with its deferred runtime. Other integration combinations retain the synchronous unified TSJS bundle. +The draft loading candidate uses the existing cacheable unified TSJS bundle. It removes the duplicated inline runtime from the supported GPT profile. This is synchronous loading, not a claim that the main bundle can safely defer. Final acceptance and real-GPT validation remain pending. ### Loading order -For pages without an enforced response Content Security Policy, GPT with core, creative and optionally the separately deferred Prebid integration uses this order: - ```mermaid flowchart TD - A[Integration configuration and existing inline GPT bootstrap] --> B[Compiled inline GPT guard, attribution and render bridge] - B --> C[Real core APIs and creative guards] - C --> D[Publisher scripts continue parsing] - D --> E[Deferred standalone GPT runtime] - E --> F[Deferred Prebid shim] + A[Reservation CSS and integration configuration] --> B[Versioned synchronous unified TSJS bundle] + B --> C[Publisher scripts and document parsing] + C --> D[Publisher registration and admitted payload] + D --> E[Initial registered delivery] + C --> F[Existing deferred integrations and window load] + F --> G[Existing automatic initializer readiness checks] + G --> H[Initial automatic delivery] ``` -The early entry runs inline, without another synchronous external fetch. It installs the real render bridge before core flushes queued callbacks or publisher scripts can request an eligible creative. Core's direct methods and immediate queue behavior remain available before the runtime arrives. The deferred GPT entry does not initialize core again, so early configuration and registered ad units survive takeover. +Core, creative and GPT execute in the existing unified module order. Core drains its prequeued callbacks before the GPT IIFE executes. Those callbacks can use core APIs; GPT registration, scheduling and rendering APIs are installed when the complete unified script returns to publisher code. The first publisher script after the runtime can use the installed APIs without waiting for a second GPT asset. -Any enforced `Content-Security-Policy` response header selects the synchronous unified bundle instead of the new inline compiled entry. This path also omits the provisional raw inline GPT bootstrap. The synchronous runtime installs initialization and registration APIs before publisher scripts, without requiring a new inline bootstrap hash. This includes headers from the actual origin response or `Settings.response_headers`, matched case-insensitively, even when empty, permissive or repeated. An origin response without CSP cannot cancel a configured policy. `Content-Security-Policy-Report-Only` alone does not select this fallback. Publishers with unchanged enforced response CSP therefore need no new compiled-script hash for this loading change: they keep the existing loading path. Policies that might permit deferral still use synchronous loading until separately evaluated. +With GPT, creative and optionally the separately deferred Prebid integration, configuration remains inline but neither the raw provisional GPT initializer nor the compiled early bundle is embedded in HTML. The external unified URL includes its content hash so browsers can cache the runtime independently of each document. No operator loading-mode flag or automatic retry is added. -If the synchronous runtime fails to load on an enforced-CSP page, the omitted provisional bootstrap cannot provide fallback delivery. This preserves script permissions, not delivery during a blocked or failed synchronous bundle fetch. +Profiles containing another browser integration retain their previous provisional initializer unless an enforced response CSP requires its omission. Their synchronous runtime order is unchanged. The profile allowlist controls omission of the initializer, not permission to defer the whole bundle. The retained implementations and versioned handover helpers are not removed as part of this loading change. -Shared templates retain the loading choice and policy headers selected on the cold response and replay them on warm hits. Settings and core fingerprints invalidate older templates. This fallback checks response headers only; no meta-policy fallback is promised. It does not weaken CSP, add nonce handling or introduce an operator flag. +Enforced `Content-Security-Policy` response headers retain the existing synchronous loading and script permissions. This includes actual origin headers and `Settings.response_headers`, matched case-insensitively, even when empty, permissive or repeated. An origin response without CSP cannot cancel a configured policy. Report-Only remains Report-Only. No policy is weakened and no new compiled-inline hash is required. Shared templates preserve the actual policy on cold and warm paths; settings and core fingerprints invalidate older templates. There is no new meta-policy evaluator or nonce handling. -On pages without enforced response CSP, the inline GPT bootstrap remains responsible for provisional `adInit`, initial scheduling, publisher registration and navigation generations. The deferred runtime installs the richer initializer, publisher-slot handoff, SPA page-bids fetching and slim-Prebid loader. Both entries share the same guard and rendering implementations. A delayed or failed runtime does not remove the installed early renderer or authorize another initial request. +If the sole critical unified script fails to load in the supported profile, the page has no TS initialization APIs or TS delivery. Core-dependent publisher code can report an error. There is no inline fallback or retry. This extends the previously tested enforced-CSP outage boundary to supported ordinary pages and remains an explicit acceptance decision. It is not equivalent to the earlier design, where a failed deferred GPT asset left the inline renderer working. -This still executes synchronous JavaScript. The non-CSP path retains the raw GPT bootstrap as well as the compiled early entry; the acceptance report records their exact sizes for the tested revision. Compare parser/paint timing and ad-startup timing with repeatable measurements; deferral is not a guarantee of better field Core Web Vitals. +The automatic initializer retains its existing load, visibility and animation-frame checks. Registered delivery still requires actual publisher readiness and payload admission. Compare paint and ad-startup separately; a parser-blocking runtime cannot be described as proven deferral, and lab gains do not establish field Core Web Vitals. -The following combinations keep the original synchronous unified bundle. Unknown browser integrations also fall back rather than being silently deferred. +These integrations retain their early initialization dependencies: -| Integration | Early dependency retained by synchronous fallback | +| Integration | Early dependency | | ---------------------- | ----------------------------------------------------------------------- | | DataDome | Dynamic SDK script and preload interception | | Google Tag Manager | Script interception plus fetch and beacon rewriting | @@ -135,7 +135,7 @@ The following combinations keep the original synchronous unified bundle. Unknown | Active GPT diagnostics | GPT observation before publisher scripts | | Enforced response CSP | Existing script permissions and synchronous loading | -Rust-only integrations do not add browser modules to this list. Pages without GPT keep synchronous unified loading. Prebid's external bundle and shim retain their existing deferred execution order. Trusted GAM attribution and APS rendering-mode attributes stay on both executing entries, including the inline early tag. +Rust-only integrations do not add browser modules to this list. Pages without GPT keep synchronous unified loading. Prebid's external bundle and shim retain their existing execution order. Trusted GAM attribution and APS rendering-mode attributes remain on the executing unified tag. Active diagnostics still install their listeners after core and before publisher scripts. ### Script Guard diff --git a/docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md b/docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md index 33f5848438..32a3415bcd 100644 --- a/docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md +++ b/docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md @@ -1,10 +1,35 @@ # Issue 854 closure acceptance -Status: Tests implemented and locally exercised. **Do not merge or close the issue.** The matched comparison reproduces a material warm-cache paint/ad-startup regression, and real-GPT acceptance remains blocked on dedicated test inventory. +Status, 2026-11-03: The cacheable synchronous candidate improves the reproduced cold/warm lab timings. **Keep the PR draft. Do not merge or close the issue.** Acceptance of synchronous loading and the changed critical-script outage boundary remains pending, and real-GPT inventory is still missing. The November 2 contract and failed inline/deferred measurements below remain historical evidence. Issue: https://github.com/IABTechLab/trusted-server/issues/854 PR: https://github.com/IABTechLab/trusted-server/pull/1460 +## November 3 recovery candidate + +The supported GPT profile now uses the existing versioned synchronous unified bundle, with configuration before execution and neither raw nor compiled early bootstrap embedded in HTML. Other browser-module profiles retain their provisional initializer unless actual enforced CSP requires omission. Diagnostics, trace, attribution/APS metadata, reservation and separately deferred integration order remain intact. No production flag, retry, codec or public registration-protocol change is introduced. + +The change has a recorded red/green loader regression. Fresh checks pass 3,376 native core tests, 20 integrations, 30 doctests, 60 Axum tests, 28 parity tests and all 23 selected build/test/lint/runtime gates, including eight target-specific Clippy aliases, adapter test aliases, actual EOF/auction controls and a new stamped WASM. Existing ignored tests remain ignored. The compiled-IIFE startup suite passes 16 tests, including pre-core queue context/configuration and complete GPT API availability after the unified script. The selected Node suite passes 80 tests with zero skips. The previous broad JS result remains evidence for unchanged production JS inputs, not a newly executed full suite. + +The identity-transport diagnostic screen executes 40 measurements per cold/warm condition, four alternating arms at two widths with five samples per group. Both successful-runtime cache and single-request/targeting/visible-creative checks pass. Baseline to combined medians are: + +| Condition | Viewport | FCP/LCP, ms | First visible ad estimate, ms | +| -------------- | --------- | ----------- | ----------------------------- | +| Throttled cold | 390x844 | 948 to 824 | 1,345.7 to 1,231.9 | +| Throttled cold | 1280x1000 | 956 to 828 | 1,350.0 to 1,208.7 | +| Throttled warm | 390x844 | 348 to 168 | 564.8 to 395.1 | +| Throttled warm | 1280x1000 | 352 to 152 | 563.0 to 382.2 | + +The combined document is 2,104 bytes instead of the previous candidate's 128,941 bytes. All 14 production JS modules remain byte-identical. The actual unified static asset is the same one already exported for the previous CSP path. Controlled reservation still changes mobile/desktop CLS from 0.207065/0.111133 to zero. These are fictional loopback observations with uncompressed HTTP, not field or compressed-delivery CWV. + +A separately executed four-case diagnostic covers normal and old-hash/self CSP success plus critical-unified HTTP 404 under each policy. Success has working APIs, one queue callback, one targeted native request and a visible creative. Failure has no initialization/shim/renderer APIs, no queue drain, one 404 with no retry, zero native requests or delivery, completed parsing and only the declared core-dependent publisher error. CSP violations remain zero. This observes the changed outage boundary; it does not approve it. + +Both diagnostic scopes explicitly report `fullAcceptancePassed=false`, and the outage scope also reports `changedOutageBehaviorApproved=false`. The unchanged full verifier rejects both scopes. Running the existing full CWV correctness scope on the new export exits 1 before any sample with `loading must exercise its intended production loading mode`. Its deferred-runtime contract has not been rewritten to make this candidate green. Full framework/live consumer migration remains deferred until the architecture and outage decision. Required CI coverage is not replaced by diagnostic passes. + +The synchronous candidate does not satisfy outcome 1 of the earlier closure contract below. Issue #854 permits documenting blocking dependencies, but accepting that alternative and the loss of the ordinary-page inline fallback requires an explicit decision. Dedicated real-GPT inventory and genuine original-main reproduction are also still missing. No closing keyword, ready transition or review request is appropriate at this checkpoint. + +Evidence is under `/home/pav/.cache/ts-854-acceptance/recovery-2026-11-03/`: `screen-cold/`, `screen-warm/`, `outages/`, `full-contract-rejection/`, `gates/`, `unified-iife.log` and `all-node.log`. The measurement JSON adds `recovery20261103` with all 80 new metric rows, four outage observations, source/report/tooling hashes and the new WASM hash. Its original 200 measurements and historical blocked status are unchanged. The screen used its recorded initial tooling; the later opt-in outage extension is separately tested against the same production bytes, not relabeled as a timing rerun. Both independent read-only cross-reviews found no additional correctness findings in the loader or diagnostic changes. + ## Closure contract The PR must demonstrate these outcomes through the production HTML transformation and compiled JavaScript: diff --git a/docs/superpowers/reviews/2026-11-02-854-cwv-measurements.json b/docs/superpowers/reviews/2026-11-02-854-cwv-measurements.json index 0eab25bfb3..83b71a37ed 100644 --- a/docs/superpowers/reviews/2026-11-02-854-cwv-measurements.json +++ b/docs/superpowers/reviews/2026-11-02-854-cwv-measurements.json @@ -1,5 +1,3014 @@ { "schemaVersion": 1, + "recovery20261103": { + "status": "diagnostic-pass-closure-blocked", + "boundary": "Uncompressed fictional loopback fixture. Eighty repeated screen samples and four separately executed normal/CSP availability diagnostics. No field CWV, compressed-delivery, framework, real GPT/GAM or compatibility-approval claim.", + "candidateInputRevision": "e2ecbb9c9c85108b16e99cdf6513bdcde207d667", + "candidateState": "Dirty working tree identified by retained production source fingerprints. The screen precedes the optional outage tooling extension; both use byte-identical production HTML and JS.", + "fullAcceptancePassed": false, + "blockers": [ + "Synchronous candidate does not satisfy the earlier parser-progress deferral contract; acceptance of the issue's documented-dependency alternative remains pending.", + "Loss of the inline fallback on supported ordinary-page critical-script outage is observed, not approved.", + "Architecture-specific full acceptance and framework/live consumer migration deferred until the contract decision.", + "Dedicated real GPT/GAM inventory missing; no live acceptance or original-main live reproduction." + ], + "node": "v24.20.0", + "browser": "145.0.7632.6", + "candidateSourceRevision": "e2ecbb9c9c85108b16e99cdf6513bdcde207d667", + "candidateSourceStatus": "M crates/trusted-server-core/src/html_processor.rs\n M crates/trusted-server-core/src/integrations/gpt.rs\n M crates/trusted-server-core/src/integrations/registry.rs\n M crates/trusted-server-core/src/publisher.rs\n M crates/trusted-server-core/src/tsjs.rs\n M crates/trusted-server-integration-tests/browser/cwv/README.md\n M crates/trusted-server-integration-tests/browser/cwv/options.cjs\n M crates/trusted-server-integration-tests/browser/cwv/options.test.cjs\n M crates/trusted-server-integration-tests/browser/cwv/run.cjs\n M crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs\n?? crates/trusted-server-integration-tests/browser/cwv/diagnostic.cjs\n?? crates/trusted-server-integration-tests/browser/cwv/diagnostic.test.cjs", + "sourceFingerprints": "550a4adb3d7199a73585886ce9fe01f1f66e3c1670e92deb7e78aa8a8420c00c Cargo.lock\n95961a65cd44ee0b7d97243bdc87d3b35d2b8d710d86e850bc8da64bcbd51491 rust-toolchain.toml\n273b277448d6ea76639e75ed0af0260f24c80b34b711970747e7e9b9524383c4 .tool-versions\n52c4db7d101904dafa8a1ec302fa2b54bbd64c2eda65e4b628708b9e1a03cb14 crates/trusted-server-core/src/html_processor.rs\n3f83b22e8457441b70039f2afc874a84dc01e9a20188e3c3b0dc0a8a44bd255b crates/trusted-server-core/src/publisher.rs\nefa608d2a5055d1a02e121958c0eaf0dc276143d808272731e616512418071f2 crates/trusted-server-core/src/integrations/registry.rs\n81c5a8833a0c820c7de7adbde530b1c2098c5f738bad551cf40fa93cb38e745a crates/trusted-server-core/src/creative_opportunities.rs\nfd5e308c2a010713c670f5a5d0ce30ccaf288fab13f084c25bee4c48c7c797f8 crates/trusted-server-core/src/tsjs.rs\nd5e8f1c52006bcff59d1decbaa9620be32ccc34fe5773821268a7c1cf2864f0e crates/trusted-server-js/lib/build-module-options.mjs\n692f2f36201bff185ca8259e3c80ed1929690ee8af046b7c4839bc6476a38e27 crates/trusted-server-js/lib/build-all.mjs\n1bf4cffcd9a2de3ec42ff7b3f7fb7645ec125c3b3866ae0789269c8c4aa29820 crates/trusted-server-core/src/integrations/gpt_bootstrap.js\ne9dabc46ca3ab50b08b20cd98088183de710abf25e196afe49515f6b1f05b44b crates/trusted-server-integration-tests/examples/cwv_html.rs", + "jsSourceFiles": "crates/trusted-server-js/lib/src/core/config.ts\ncrates/trusted-server-js/lib/src/core/context.ts\ncrates/trusted-server-js/lib/src/core/first_impression.ts\ncrates/trusted-server-js/lib/src/core/log.ts\ncrates/trusted-server-js/lib/src/core/queue.ts\ncrates/trusted-server-js/lib/src/core/registry.ts\ncrates/trusted-server-js/lib/src/core/render.ts\ncrates/trusted-server-js/lib/src/core/request.ts\ncrates/trusted-server-js/lib/src/core/slot_element.ts\ncrates/trusted-server-js/lib/src/core/styles/normalize.css\ncrates/trusted-server-js/lib/src/core/templates/iframe.html\ncrates/trusted-server-js/lib/src/core/util.ts\ncrates/trusted-server-js/lib/src/core/auction.ts\ncrates/trusted-server-js/lib/src/core/global.d.ts\ncrates/trusted-server-js/lib/src/core/index.ts\ncrates/trusted-server-js/lib/src/core/types.ts\ncrates/trusted-server-js/lib/src/index.ts\ncrates/trusted-server-js/lib/src/integrations/aps/render.ts\ncrates/trusted-server-js/lib/src/integrations/bootstrap/index.ts\ncrates/trusted-server-js/lib/src/integrations/creative/click.ts\ncrates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts\ncrates/trusted-server-js/lib/src/integrations/creative/iframe.ts\ncrates/trusted-server-js/lib/src/integrations/creative/image.ts\ncrates/trusted-server-js/lib/src/integrations/creative/index.ts\ncrates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts\ncrates/trusted-server-js/lib/src/integrations/datadome/index.ts\ncrates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts\ncrates/trusted-server-js/lib/src/integrations/didomi/index.ts\ncrates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts\ncrates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts\ncrates/trusted-server-js/lib/src/integrations/gpt/early.ts\ncrates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts\ncrates/trusted-server-js/lib/src/integrations/gpt/render_bridge.ts\ncrates/trusted-server-js/lib/src/integrations/gpt/script_guard.ts\ncrates/trusted-server-js/lib/src/integrations/gpt/shim.ts\ncrates/trusted-server-js/lib/src/integrations/gpt/types.ts\ncrates/trusted-server-js/lib/src/integrations/gpt/index.ts\ncrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts\ncrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts\ncrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/exhaustive.ts\ncrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts\ncrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts\ncrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/slot_size_observer.ts\ncrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts\ncrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts\ncrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts\ncrates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts\ncrates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts\ncrates/trusted-server-js/lib/src/integrations/lockr/api_guard.ts\ncrates/trusted-server-js/lib/src/integrations/lockr/index.ts\ncrates/trusted-server-js/lib/src/integrations/osano/index.ts\ncrates/trusted-server-js/lib/src/integrations/permutive/index.ts\ncrates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts\ncrates/trusted-server-js/lib/src/integrations/permutive/segments.ts\ncrates/trusted-server-js/lib/src/integrations/prebid/prebid_modules/aliases.d.ts\ncrates/trusted-server-js/lib/src/integrations/prebid/prebid_modules/liveIntentIdSystem.ts\ncrates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json\ncrates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts\ncrates/trusted-server-js/lib/src/integrations/prebid/index.ts\ncrates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts\ncrates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts\ncrates/trusted-server-js/lib/src/integrations/testlight/index.ts\ncrates/trusted-server-js/lib/src/shared/async.ts\ncrates/trusted-server-js/lib/src/shared/beacon_guard.ts\ncrates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts\ncrates/trusted-server-js/lib/src/shared/globals.ts\ncrates/trusted-server-js/lib/src/shared/origin.ts\ncrates/trusted-server-js/lib/src/shared/scheduler.ts\ncrates/trusted-server-js/lib/src/shared/script_guard.ts\ncrates/trusted-server-js/lib/src/trace/collector.ts\ncrates/trusted-server-js/lib/src/trace/context.ts\ncrates/trusted-server-js/lib/src/trace/correlation.ts\ncrates/trusted-server-js/lib/src/trace/export.ts\ncrates/trusted-server-js/lib/src/trace/gpt.ts\ncrates/trusted-server-js/lib/src/trace/handoff.ts\ncrates/trusted-server-js/lib/src/trace/json.ts\ncrates/trusted-server-js/lib/src/trace/lifecycle.ts\ncrates/trusted-server-js/lib/src/trace/omissions.ts\ncrates/trusted-server-js/lib/src/trace/pending.ts\ncrates/trusted-server-js/lib/src/trace/projection.ts\ncrates/trusted-server-js/lib/src/trace/report-details.ts\ncrates/trusted-server-js/lib/src/trace/report-dom.ts\ncrates/trusted-server-js/lib/src/trace/report-types.ts\ncrates/trusted-server-js/lib/src/trace/report-validation.ts\ncrates/trusted-server-js/lib/src/trace/report-view.ts\ncrates/trusted-server-js/lib/src/trace/report.ts\ncrates/trusted-server-js/lib/src/trace/runtime.ts\ncrates/trusted-server-js/lib/src/trace/setup.ts\ncrates/trusted-server-js/lib/src/trace/shape.ts\ncrates/trusted-server-js/lib/src/trace/storage.ts\ncrates/trusted-server-js/lib/src/trace/types.ts\ncrates/trusted-server-js/lib/src/trace/validation.ts\ncrates/trusted-server-js/lib/src/trace/view-model.ts\ncrates/trusted-server-js/lib/src/trace/viewer.css\ncrates/trusted-server-js/lib/src/trace/viewer.ts", + "bytes": [ + { + "label": "baseline", + "source": "51730bdaf841e4d7bb96c2258e5e98169bdff9a7", + "htmlBytes": 38728, + "htmlSha256": "f9b3213f0dfb0a437442f37a84317b2eb06381809cab95e22c274b9e9b797671", + "assets": { + "/static/tsjs=tsjs-unified.min.js?v=af0daac10f0bde94fc7432655d2cf6cfc4ad18084e7af9fd467487ffa197c5f2": "assets/0.js" + } + }, + { + "label": "previous", + "source": "2168a75fcfd98662b3f468412cabc88fc2ff0726", + "htmlBytes": 128941, + "htmlSha256": "27bdaa1eef5378e6e9e40e535294982e11f7ad18283800580fab9a469294dc75", + "assets": { + "/static/tsjs=tsjs-gpt.min.js?v=c6fd0895a70f5f23734245d7e54dcd966d4b9d94c78bbe158213f4fe8ea8f372": "assets/0.js", + "/static/tsjs=tsjs-unified.min.js?v=79d7792bfbbcb918bfd49c9fca59f4b14f8cfb1723455c2d66c68d48eeebc0fa": "assets/1.js" + } + }, + { + "label": "candidate", + "source": "e2ecbb9c9c85108b16e99cdf6513bdcde207d667", + "htmlBytes": 2104, + "htmlSha256": "58cadbc1116135811f64ed1593fbd130d44df39464bab0f753b606d2fa9a453f", + "assets": { + "/static/tsjs=tsjs-unified.min.js?v=79d7792bfbbcb918bfd49c9fca59f4b14f8cfb1723455c2d66c68d48eeebc0fa": "assets/0.js" + } + } + ], + "productionModuleHashes": "7e39e93e1717d0f2aec4657a2876f648eeaba47d32fadf6f2aa75671db8579f1 crates/trusted-server-js/dist/tsjs-bootstrap.js\n116c4626cb6ff5af932117c99d2926da9bf38d7e36b3258c3dc56c70870e870e crates/trusted-server-js/dist/tsjs-core.js\nd4fb6562058ca59d14fde1ccf263ab5c85fabe963501aa09c8330b84fbfe5cac crates/trusted-server-js/dist/tsjs-creative.js\nd3b2166f1dc38cf2183aa0ac6479ab7ccea9fb78433054de0b64f4768971b4b4 crates/trusted-server-js/dist/tsjs-datadome.js\nca11403af8b24d0ea29c236fdc86f422c6b421fe58e260370f368b48fb6914ad crates/trusted-server-js/dist/tsjs-didomi.js\n08656a6fdd929fd9802973771a5433c2bc2b4eba02b3a1d7b984fbed1c94d71b crates/trusted-server-js/dist/tsjs-google_tag_manager.js\nc6fd0895a70f5f23734245d7e54dcd966d4b9d94c78bbe158213f4fe8ea8f372 crates/trusted-server-js/dist/tsjs-gpt.js\nb1401ad2f4a1ace1818965be094b08ac2d8abd482f2197525f0050d20e30a6c4 crates/trusted-server-js/dist/tsjs-gpt_diagnostics.js\n7bbedcbf07e6aae567c9d753a36144180612095c019605804b0e23f7b619edbc crates/trusted-server-js/dist/tsjs-lockr.js\n43f1a0ee70762bf5cda4123e1006d936482400ac9f4f298102e61a7a6bd37780 crates/trusted-server-js/dist/tsjs-osano.js\n5f2a08a296c96fcbec32c5028cab6b22a6f603437919a42d65ae619f8cb3ab03 crates/trusted-server-js/dist/tsjs-permutive.js\n8359d76a03a6743bf5bd3585d23518b0ae0b010a232e010bd23630a80eed6f08 crates/trusted-server-js/dist/tsjs-prebid.js\ndf53db3050f851cb8b4d8b2bcdea672288da5f9e3b488b666ffcd56266fd2d9b crates/trusted-server-js/dist/tsjs-sourcepoint.js\n2517e7e6c43d4cd486fe09ee528f63411727125178e47f1040c69bbcd1201a32 crates/trusted-server-js/dist/tsjs-testlight.js\n", + "candidateWasmSha256": "71f86ac6670f3f2bcd33ba68c2e83cf7414904007ccafbde447964101284f438", + "outages": { + "status": "passed", + "scope": "diagnostic-outages", + "sourceEvidence": { + "samplesSha256": "83f251aacc091b6f5caac2d9c54acff0eefc13721876554fb416f3adcd20a29b", + "artifactsSha256": "95da2d1d577aa010b46f4692ce3dba7964e0ec77023b3329f7e5182e7abef331", + "acceptanceSha256": "d2a2aaf7086fde00853cdb13f9d6efd3ba93807fe26b43b4bf5f49fb367612c5" + }, + "fullAcceptancePassed": false, + "changedOutageBehaviorApproved": false, + "samples": [ + { + "name": "inline", + "mode": "slow", + "nativeRequests": 1, + "apis": { + "methods": { + "setConfig": "function", + "getConfig": "function", + "requestAds": "function", + "addAdUnits": "function", + "renderAdUnit": "function", + "renderAllAdUnits": "function", + "adInit": "function", + "scheduleInitialAdInit": "function", + "registerPublisherSlot": "function" + }, + "shim": "function", + "registrationVersion": 1, + "rendererVersion": 1 + }, + "queueCallbacks": 1, + "runtimeLoads": 1, + "errors": [], + "cspViolations": [], + "firstVisibleEstimateMs": 260.89999999990687 + }, + { + "name": "inline", + "mode": "failed", + "nativeRequests": 0, + "apis": { + "methods": { + "setConfig": "undefined", + "getConfig": "undefined", + "requestAds": "undefined", + "addAdUnits": "undefined", + "renderAdUnit": "undefined", + "renderAllAdUnits": "undefined", + "adInit": "undefined", + "scheduleInitialAdInit": "undefined", + "registerPublisherSlot": "undefined" + }, + "shim": "undefined", + "registrationVersion": null, + "rendererVersion": null + }, + "queueCallbacks": 0, + "runtimeLoads": 0, + "errors": ["tsjs.setConfig is not a function"], + "cspViolations": [], + "firstVisibleEstimateMs": null + }, + { + "name": "csp", + "mode": "slow", + "nativeRequests": 1, + "apis": { + "methods": { + "setConfig": "function", + "getConfig": "function", + "requestAds": "function", + "addAdUnits": "function", + "renderAdUnit": "function", + "renderAllAdUnits": "function", + "adInit": "function", + "scheduleInitialAdInit": "function", + "registerPublisherSlot": "function" + }, + "shim": "function", + "registrationVersion": 1, + "rendererVersion": 1 + }, + "queueCallbacks": 1, + "runtimeLoads": 1, + "errors": [], + "cspViolations": [], + "firstVisibleEstimateMs": 233.80000000004657 + }, + { + "name": "csp", + "mode": "failed", + "nativeRequests": 0, + "apis": { + "methods": { + "setConfig": "undefined", + "getConfig": "undefined", + "requestAds": "undefined", + "addAdUnits": "undefined", + "renderAdUnit": "undefined", + "renderAllAdUnits": "undefined", + "adInit": "undefined", + "scheduleInitialAdInit": "undefined", + "registerPublisherSlot": "undefined" + }, + "shim": "undefined", + "registrationVersion": null, + "rendererVersion": null + }, + "queueCallbacks": 0, + "runtimeLoads": 0, + "errors": ["tsjs.setConfig is not a function"], + "cspViolations": [], + "firstVisibleEstimateMs": null + } + ] + }, + "fullContract": { + "status": "failed", + "scope": "correctness", + "samples": 0, + "error": "loading must exercise its intended production loading mode\n\nfalse !== true\n" + }, + "gates": "core-native\t0\naxum-native\t0\nparity\t0\nclippy-fastly\t0\nclippy-axum\t0\nclippy-cloudflare\t0\nclippy-cloudflare-wasm\t0\nclippy-spin-native\t0\nclippy-spin-wasm\t0\nclippy-cli\t0\nclippy-codegen\t0\ntest-fastly\t0\ntest-fastly-reuse\t0\ntest-cloudflare\t0\ntest-spin\t0\nbuild-digest-clippy\t0\neof-native\t0\neof-clippy\t0\neof-axum\t0\neof-fastly\t0\nbuild-fastly\t0\nstamp-wasm\t0\nrust-format\t0\n", + "profiles": [ + { + "name": "throttled-cold", + "conditions": { + "runtimeDelayMs": 0, + "cpuRate": 4, + "cache": "cold", + "network": { + "offline": false, + "latency": 100, + "downloadThroughput": 200000, + "uploadThroughput": 93750 + } + }, + "status": "passed", + "scope": "diagnostic-screen", + "fullAcceptancePassed": false, + "samples": 40, + "negativeControls": { + "parser-blocking": { + "status": "not-requested", + "reason": "Diagnostic synchronous-unified measurement only; deferred parser control is inapplicable and reservation-removal correctness is outside this screen. Full acceptance remains unexecuted." + }, + "reservation-removed": { + "status": "not-requested", + "reason": "Diagnostic synchronous-unified measurement only; deferred parser control is inapplicable and reservation-removal correctness is outside this screen. Full acceptance remains unexecuted." + } + }, + "sourceEvidence": { + "samplesSha256": "f6d4b768f548160217152ae4765f4dc860fe1f8dfb54730caafcfa27ebcfe371", + "artifactsSha256": "f826ac04acd7a81e4885a8eb7e1fd47ebefd7d7727b48af3f33b1ac96f9ea919", + "acceptanceSha256": "1ba210dc890d32bcd235060e4119ba72e1db121d6ad198a9c2d74e104d46e25e", + "runnerSha256": "78701ee549f7fce33b246fe83f0acb2b3e0bd51608b3afdd79052e8c3613b950", + "metricsSha256": "886359ad58e86f8baca93b178898148992ca5d897906cc081c720a9d099e4118", + "htmlSha256": "6c842586f4060f871e74080243152707a3ea49c9f63ca4e695b316c92fc71500", + "optionsSha256": "444b5f6f932e74928ce78bbf01a4494409629876a61d25f9d549d385c57f2f6a", + "evidenceSha256": "25d21e7e061e68281eaf41b5306f533915ef92257257439216050a0a2d1506e5", + "proxySha256": "dcae99e59be2488ea4a3cb946e32ad1e51bcab566eeb9f93a5b2fb21636346fa", + "diagnosticSha256": "8de225bd4334ef44b9b6048db2dd613a16d47bad9768ef42440a91d3e417c45c" + }, + "measurements": [ + { + "index": 0, + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 972, + "lcp": 972, + "cls": 0.20706520518407043, + "domInteractive": 968.7999999998137, + "domContentLoaded": 970.3999999999069, + "requestMs": 1016, + "firstVisibleEstimateMs": 1403.1999999999534, + "runtimeResponseEnd": 904.0999999998603, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 1, + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 808, + "lcp": 808, + "cls": 0.20706520518407043, + "domInteractive": 801.5, + "domContentLoaded": 802.6999999999534, + "requestMs": 840, + "firstVisibleEstimateMs": 1185.1000000000931, + "runtimeResponseEnd": 764.6000000000931, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 2, + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 948, + "lcp": 948, + "cls": 0, + "domInteractive": 943.4000000001397, + "domContentLoaded": 944.9000000001397, + "requestMs": 996, + "firstVisibleEstimateMs": 1346.6999999999534, + "runtimeResponseEnd": 890.6999999999534, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 3, + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 836, + "lcp": 836, + "cls": 0, + "domInteractive": 829.2000000001863, + "domContentLoaded": 830.4000000001397, + "requestMs": 877, + "firstVisibleEstimateMs": 1237.4000000001397, + "runtimeResponseEnd": 776.2000000001863, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 4, + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 824, + "lcp": 824, + "cls": 0, + "domInteractive": 818.3999999999069, + "domContentLoaded": 820, + "requestMs": 867, + "firstVisibleEstimateMs": 1231.5, + "runtimeResponseEnd": 762.8999999999069, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 5, + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 944, + "lcp": 944, + "cls": 0, + "domInteractive": 939.1999999999534, + "domContentLoaded": 940.3999999999069, + "requestMs": 988, + "firstVisibleEstimateMs": 1341, + "runtimeResponseEnd": 890.5999999998603, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 6, + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 812, + "lcp": 812, + "cls": 0.20706520518407043, + "domInteractive": 807.6000000000931, + "domContentLoaded": 808.6999999999534, + "requestMs": 857, + "firstVisibleEstimateMs": 1215.8000000000466, + "runtimeResponseEnd": 756.3999999999069, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 7, + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 944, + "lcp": 944, + "cls": 0.20706520518407043, + "domInteractive": 939.1000000000931, + "domContentLoaded": 940.5, + "requestMs": 992, + "firstVisibleEstimateMs": 1353.1999999999534, + "runtimeResponseEnd": 890.3000000000466, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 8, + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 952, + "lcp": 952, + "cls": 0.20706520518407043, + "domInteractive": 943.1999999999534, + "domContentLoaded": 945.3000000000466, + "requestMs": 995, + "firstVisibleEstimateMs": 1344.1999999999534, + "runtimeResponseEnd": 890.1999999999534, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 9, + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 828, + "lcp": 828, + "cls": 0.20706520518407043, + "domInteractive": 822.4000000001397, + "domContentLoaded": 823.5, + "requestMs": 872, + "firstVisibleEstimateMs": 1227.6999999999534, + "runtimeResponseEnd": 771.6999999999534, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 10, + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 948, + "lcp": 948, + "cls": 0, + "domInteractive": 943, + "domContentLoaded": 944.3000000000466, + "requestMs": 992, + "firstVisibleEstimateMs": 1369.5, + "runtimeResponseEnd": 893.6999999999534, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 11, + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 820, + "lcp": 820, + "cls": 0, + "domInteractive": 815.6000000000931, + "domContentLoaded": 816.5, + "requestMs": 862, + "firstVisibleEstimateMs": 1231.9000000001397, + "runtimeResponseEnd": 764.9000000001397, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 12, + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 824, + "lcp": 824, + "cls": 0, + "domInteractive": 817, + "domContentLoaded": 818.0999999998603, + "requestMs": 864, + "firstVisibleEstimateMs": 1222.7999999998137, + "runtimeResponseEnd": 763.5, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 13, + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 948, + "lcp": 948, + "cls": 0, + "domInteractive": 941.3999999999069, + "domContentLoaded": 942.7999999998137, + "requestMs": 994, + "firstVisibleEstimateMs": 1341.5999999998603, + "runtimeResponseEnd": 892.2999999998137, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 14, + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 820, + "lcp": 820, + "cls": 0.20706520518407043, + "domInteractive": 815.1000000000931, + "domContentLoaded": 816.7000000001863, + "requestMs": 834, + "firstVisibleEstimateMs": 1191.1000000000931, + "runtimeResponseEnd": 764.5, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 15, + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 936, + "lcp": 936, + "cls": 0.20706520518407043, + "domInteractive": 933.1000000000931, + "domContentLoaded": 933.6000000000931, + "requestMs": 970, + "firstVisibleEstimateMs": 1317.1000000000931, + "runtimeResponseEnd": 893.6000000000931, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 16, + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 948, + "lcp": 948, + "cls": 0.20706520518407043, + "domInteractive": 941.6000000000931, + "domContentLoaded": 942.6999999999534, + "requestMs": 990, + "firstVisibleEstimateMs": 1345.6999999999534, + "runtimeResponseEnd": 892, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 17, + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 824, + "lcp": 824, + "cls": 0.20706520518407043, + "domInteractive": 817.5, + "domContentLoaded": 818.8000000000466, + "requestMs": 841, + "firstVisibleEstimateMs": 1190, + "runtimeResponseEnd": 765.5, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 18, + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 948, + "lcp": 948, + "cls": 0, + "domInteractive": 941.8999999999069, + "domContentLoaded": 943.0999999998603, + "requestMs": 993, + "firstVisibleEstimateMs": 1344.1999999999534, + "runtimeResponseEnd": 891.3999999999069, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 19, + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 824, + "lcp": 824, + "cls": 0, + "domInteractive": 819.8000000000466, + "domContentLoaded": 821.1999999999534, + "requestMs": 876, + "firstVisibleEstimateMs": 1247.8999999999069, + "runtimeResponseEnd": 762.8999999999069, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 20, + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 956, + "lcp": 956, + "cls": 0.11113281249999998, + "domInteractive": 943.3000000000466, + "domContentLoaded": 945, + "requestMs": 999, + "firstVisibleEstimateMs": 1364.5, + "runtimeResponseEnd": 889.9000000001397, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 21, + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 820, + "lcp": 820, + "cls": 0.11113281249999998, + "domInteractive": 810.6000000000931, + "domContentLoaded": 812.2000000001863, + "requestMs": 833, + "firstVisibleEstimateMs": 1188.3000000000466, + "runtimeResponseEnd": 755.2000000001863, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 22, + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 952, + "lcp": 952, + "cls": 0, + "domInteractive": 940, + "domContentLoaded": 941.3000000000466, + "requestMs": 965, + "firstVisibleEstimateMs": 1337.5, + "runtimeResponseEnd": 890.7000000001863, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 23, + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 820, + "lcp": 820, + "cls": 0, + "domInteractive": 807.8000000000466, + "domContentLoaded": 808.6000000000931, + "requestMs": 839, + "firstVisibleEstimateMs": 1201.3000000000466, + "runtimeResponseEnd": 771.3000000000466, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 24, + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 836, + "lcp": 836, + "cls": 0, + "domInteractive": 822.3999999999069, + "domContentLoaded": 823.8999999999069, + "requestMs": 845, + "firstVisibleEstimateMs": 1213, + "runtimeResponseEnd": 772.3999999999069, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 25, + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 952, + "lcp": 952, + "cls": 0, + "domInteractive": 939.8999999999069, + "domContentLoaded": 941.1999999999534, + "requestMs": 988, + "firstVisibleEstimateMs": 1356.1000000000931, + "runtimeResponseEnd": 891.6999999999534, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 26, + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 828, + "lcp": 828, + "cls": 0.11113281249999998, + "domInteractive": 817.7999999998137, + "domContentLoaded": 819.1999999999534, + "requestMs": 866, + "firstVisibleEstimateMs": 1242.7999999998137, + "runtimeResponseEnd": 763.6999999999534, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 27, + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 948, + "lcp": 948, + "cls": 0.11113281249999998, + "domInteractive": 940, + "domContentLoaded": 941.3000000000466, + "requestMs": 992, + "firstVisibleEstimateMs": 1368, + "runtimeResponseEnd": 890.9000000001397, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 28, + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 956, + "lcp": 956, + "cls": 0.11113281249999998, + "domInteractive": 942.0999999998603, + "domContentLoaded": 943.6999999999534, + "requestMs": 990, + "firstVisibleEstimateMs": 1350, + "runtimeResponseEnd": 891.3000000000466, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 29, + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 828, + "lcp": 828, + "cls": 0.11113281249999998, + "domInteractive": 817, + "domContentLoaded": 818, + "requestMs": 867, + "firstVisibleEstimateMs": 1243.8999999999069, + "runtimeResponseEnd": 764.1999999999534, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 30, + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 952, + "lcp": 952, + "cls": 0, + "domInteractive": 942.6999999999534, + "domContentLoaded": 944, + "requestMs": 990, + "firstVisibleEstimateMs": 1358.3000000000466, + "runtimeResponseEnd": 893, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 31, + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 824, + "lcp": 824, + "cls": 0, + "domInteractive": 813, + "domContentLoaded": 814.1999999999534, + "requestMs": 832, + "firstVisibleEstimateMs": 1213.8999999999069, + "runtimeResponseEnd": 763.5, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 32, + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 828, + "lcp": 828, + "cls": 0, + "domInteractive": 817.3000000000466, + "domContentLoaded": 819, + "requestMs": 839, + "firstVisibleEstimateMs": 1208.7000000001863, + "runtimeResponseEnd": 764.6000000000931, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 33, + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 952, + "lcp": 952, + "cls": 0, + "domInteractive": 940.9000000001397, + "domContentLoaded": 941.6999999999534, + "requestMs": 959, + "firstVisibleEstimateMs": 1323.8000000000466, + "runtimeResponseEnd": 892.6000000000931, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 34, + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 820, + "lcp": 820, + "cls": 0.11113281249999998, + "domInteractive": 808.1000000000931, + "domContentLoaded": 809.3000000000466, + "requestMs": 831, + "firstVisibleEstimateMs": 1198.3000000000466, + "runtimeResponseEnd": 757.8000000000466, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 35, + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 952, + "lcp": 952, + "cls": 0.11113281249999998, + "domInteractive": 942.3999999999069, + "domContentLoaded": 943.5999999998603, + "requestMs": 962, + "firstVisibleEstimateMs": 1327.1999999999534, + "runtimeResponseEnd": 892, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 36, + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 956, + "lcp": 956, + "cls": 0.11113281249999998, + "domInteractive": 943.8000000000466, + "domContentLoaded": 944.6999999999534, + "requestMs": 964, + "firstVisibleEstimateMs": 1317.3000000000466, + "runtimeResponseEnd": 892.4000000001397, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 37, + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 832, + "lcp": 832, + "cls": 0.11113281249999998, + "domInteractive": 819, + "domContentLoaded": 820.6000000000931, + "requestMs": 869, + "firstVisibleEstimateMs": 1234, + "runtimeResponseEnd": 764.7000000001863, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 38, + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 956, + "lcp": 956, + "cls": 0, + "domInteractive": 943, + "domContentLoaded": 944.5, + "requestMs": 965, + "firstVisibleEstimateMs": 1320.3999999999069, + "runtimeResponseEnd": 893.6999999999534, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 97416, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 39, + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 828, + "lcp": 828, + "cls": 0, + "domInteractive": 816.5, + "domContentLoaded": 817.6000000000931, + "requestMs": 843, + "firstVisibleEstimateMs": 1205.6000000000931, + "runtimeResponseEnd": 764.2000000001863, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": false, + "runtimeTransferBytes": 107231, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + } + ], + "comparisons": { + "loading/390": { + "fcp": { + "baseline": 948, + "changed": 820, + "delta": -128, + "percent": -13.502109704641349 + }, + "lcp": { + "baseline": 948, + "changed": 820, + "delta": -128, + "percent": -13.502109704641349 + }, + "cls": { + "baseline": 0.20706520518407043, + "changed": 0.20706520518407043, + "delta": 0, + "percent": 0 + }, + "domInteractive": { + "baseline": 941.6000000000931, + "changed": 815.1000000000931, + "delta": -126.5, + "percent": -13.434579439251007 + }, + "domContentLoaded": { + "baseline": 942.6999999999534, + "changed": 816.7000000001863, + "delta": -125.99999999976717, + "percent": -13.365864007613599 + }, + "requestMs": { + "baseline": 992, + "changed": 841, + "delta": -151, + "percent": -15.221774193548388 + }, + "firstVisibleEstimateMs": { + "baseline": 1345.6999999999534, + "changed": 1191.1000000000931, + "delta": -154.5999999998603, + "percent": -11.488444675623516 + }, + "runtimeResponseEnd": { + "baseline": 892, + "changed": 764.6000000000931, + "delta": -127.39999999990687, + "percent": -14.282511210751892 + }, + "reservationHeight": { + "baseline": 250, + "changed": 250, + "delta": 0, + "percent": 0 + }, + "whitespace": { + "baseline": 0, + "changed": 0, + "delta": 0, + "percent": null + } + }, + "reservation/390": { + "fcp": { + "baseline": 948, + "changed": 948, + "delta": 0, + "percent": 0 + }, + "lcp": { + "baseline": 948, + "changed": 948, + "delta": 0, + "percent": 0 + }, + "cls": { + "baseline": 0.20706520518407043, + "changed": 0, + "delta": -0.20706520518407043, + "percent": -100 + }, + "domInteractive": { + "baseline": 941.6000000000931, + "changed": 941.8999999999069, + "delta": 0.2999999998137355, + "percent": 0.031860662681999344 + }, + "domContentLoaded": { + "baseline": 942.6999999999534, + "changed": 943.0999999998603, + "delta": 0.39999999990686774, + "percent": 0.04243131430008353 + }, + "requestMs": { + "baseline": 992, + "changed": 993, + "delta": 1, + "percent": 0.10080645161290322 + }, + "firstVisibleEstimateMs": { + "baseline": 1345.6999999999534, + "changed": 1344.1999999999534, + "delta": -1.5, + "percent": -0.1114661514453483 + }, + "runtimeResponseEnd": { + "baseline": 892, + "changed": 891.3999999999069, + "delta": -0.6000000000931323, + "percent": -0.06726457400147223 + }, + "reservationHeight": { + "baseline": 250, + "changed": 250, + "delta": 0, + "percent": 0 + }, + "whitespace": { + "baseline": 0, + "changed": 0, + "delta": 0, + "percent": null + } + }, + "combined/390": { + "fcp": { + "baseline": 948, + "changed": 824, + "delta": -124, + "percent": -13.080168776371309 + }, + "lcp": { + "baseline": 948, + "changed": 824, + "delta": -124, + "percent": -13.080168776371309 + }, + "cls": { + "baseline": 0.20706520518407043, + "changed": 0, + "delta": -0.20706520518407043, + "percent": -100 + }, + "domInteractive": { + "baseline": 941.6000000000931, + "changed": 818.3999999999069, + "delta": -123.20000000018626, + "percent": -13.084112149551197 + }, + "domContentLoaded": { + "baseline": 942.6999999999534, + "changed": 820, + "delta": -122.69999999995343, + "percent": -13.015805664576163 + }, + "requestMs": { + "baseline": 992, + "changed": 867, + "delta": -125, + "percent": -12.600806451612904 + }, + "firstVisibleEstimateMs": { + "baseline": 1345.6999999999534, + "changed": 1231.9000000001397, + "delta": -113.79999999981374, + "percent": -8.456565356306582 + }, + "runtimeResponseEnd": { + "baseline": 892, + "changed": 763.5, + "delta": -128.5, + "percent": -14.405829596412556 + }, + "reservationHeight": { + "baseline": 250, + "changed": 250, + "delta": 0, + "percent": 0 + }, + "whitespace": { + "baseline": 0, + "changed": 0, + "delta": 0, + "percent": null + } + }, + "loading/1280": { + "fcp": { + "baseline": 956, + "changed": 828, + "delta": -128, + "percent": -13.389121338912133 + }, + "lcp": { + "baseline": 956, + "changed": 828, + "delta": -128, + "percent": -13.389121338912133 + }, + "cls": { + "baseline": 0.11113281249999998, + "changed": 0.11113281249999998, + "delta": 0, + "percent": 0 + }, + "domInteractive": { + "baseline": 942.3999999999069, + "changed": 817, + "delta": -125.39999999990687, + "percent": -13.30645161289466 + }, + "domContentLoaded": { + "baseline": 943.6999999999534, + "changed": 818, + "delta": -125.69999999995343, + "percent": -13.319910988657375 + }, + "requestMs": { + "baseline": 990, + "changed": 866, + "delta": -124, + "percent": -12.525252525252526 + }, + "firstVisibleEstimateMs": { + "baseline": 1350, + "changed": 1234, + "delta": -116, + "percent": -8.592592592592592 + }, + "runtimeResponseEnd": { + "baseline": 891.3000000000466, + "changed": 763.6999999999534, + "delta": -127.60000000009313, + "percent": -14.316167395948218 + }, + "reservationHeight": { + "baseline": 250, + "changed": 250, + "delta": 0, + "percent": 0 + }, + "whitespace": { + "baseline": 0, + "changed": 0, + "delta": 0, + "percent": null + } + }, + "reservation/1280": { + "fcp": { + "baseline": 956, + "changed": 952, + "delta": -4, + "percent": -0.41841004184100417 + }, + "lcp": { + "baseline": 956, + "changed": 952, + "delta": -4, + "percent": -0.41841004184100417 + }, + "cls": { + "baseline": 0.11113281249999998, + "changed": 0, + "delta": -0.11113281249999998, + "percent": -100 + }, + "domInteractive": { + "baseline": 942.3999999999069, + "changed": 940.9000000001397, + "delta": -1.4999999997671694, + "percent": -0.1591680814693673 + }, + "domContentLoaded": { + "baseline": 943.6999999999534, + "changed": 941.6999999999534, + "delta": -2, + "percent": -0.21193175797394287 + }, + "requestMs": { + "baseline": 990, + "changed": 965, + "delta": -25, + "percent": -2.525252525252525 + }, + "firstVisibleEstimateMs": { + "baseline": 1350, + "changed": 1337.5, + "delta": -12.5, + "percent": -0.9259259259259258 + }, + "runtimeResponseEnd": { + "baseline": 891.3000000000466, + "changed": 892.6000000000931, + "delta": 1.3000000000465661, + "percent": 0.1458543700265341 + }, + "reservationHeight": { + "baseline": 250, + "changed": 600, + "delta": 350, + "percent": 140 + }, + "whitespace": { + "baseline": 0, + "changed": 350, + "delta": 350, + "percent": null + } + }, + "combined/1280": { + "fcp": { + "baseline": 956, + "changed": 828, + "delta": -128, + "percent": -13.389121338912133 + }, + "lcp": { + "baseline": 956, + "changed": 828, + "delta": -128, + "percent": -13.389121338912133 + }, + "cls": { + "baseline": 0.11113281249999998, + "changed": 0, + "delta": -0.11113281249999998, + "percent": -100 + }, + "domInteractive": { + "baseline": 942.3999999999069, + "changed": 816.5, + "delta": -125.89999999990687, + "percent": -13.359507640059348 + }, + "domContentLoaded": { + "baseline": 943.6999999999534, + "changed": 817.6000000000931, + "delta": -126.0999999998603, + "percent": -13.362297340242293 + }, + "requestMs": { + "baseline": 990, + "changed": 839, + "delta": -151, + "percent": -15.252525252525254 + }, + "firstVisibleEstimateMs": { + "baseline": 1350, + "changed": 1208.7000000001863, + "delta": -141.29999999981374, + "percent": -10.46666666665287 + }, + "runtimeResponseEnd": { + "baseline": 891.3000000000466, + "changed": 764.6000000000931, + "delta": -126.69999999995343, + "percent": -14.215191293610099 + }, + "reservationHeight": { + "baseline": 250, + "changed": 600, + "delta": 350, + "percent": 140 + }, + "whitespace": { + "baseline": 0, + "changed": 350, + "delta": 350, + "percent": null + } + } + } + }, + { + "name": "throttled-warm", + "conditions": { + "runtimeDelayMs": 0, + "cpuRate": 4, + "cache": "warm", + "network": { + "offline": false, + "latency": 100, + "downloadThroughput": 200000, + "uploadThroughput": 93750 + } + }, + "status": "passed", + "scope": "diagnostic-screen", + "fullAcceptancePassed": false, + "samples": 40, + "negativeControls": { + "parser-blocking": { + "status": "not-requested", + "reason": "Diagnostic synchronous-unified measurement only; deferred parser control is inapplicable and reservation-removal correctness is outside this screen. Full acceptance remains unexecuted." + }, + "reservation-removed": { + "status": "not-requested", + "reason": "Diagnostic synchronous-unified measurement only; deferred parser control is inapplicable and reservation-removal correctness is outside this screen. Full acceptance remains unexecuted." + } + }, + "sourceEvidence": { + "samplesSha256": "2e6ce30dfe0c375b38bca8fd9b7fac567512c14caec5ce1412bea78028781c65", + "artifactsSha256": "ba2963e586fabbf2fcce1171e152a0438dee49c0cfb9916da68556aba2fdefcf", + "acceptanceSha256": "90d67af45dcb4874ef713f76a5ea6bb3925b2fb0464fedd6aa0fb1f30aad2984", + "runnerSha256": "78701ee549f7fce33b246fe83f0acb2b3e0bd51608b3afdd79052e8c3613b950", + "metricsSha256": "886359ad58e86f8baca93b178898148992ca5d897906cc081c720a9d099e4118", + "htmlSha256": "6c842586f4060f871e74080243152707a3ea49c9f63ca4e695b316c92fc71500", + "optionsSha256": "444b5f6f932e74928ce78bbf01a4494409629876a61d25f9d549d385c57f2f6a", + "evidenceSha256": "25d21e7e061e68281eaf41b5306f533915ef92257257439216050a0a2d1506e5", + "proxySha256": "dcae99e59be2488ea4a3cb946e32ad1e51bcab566eeb9f93a5b2fb21636346fa", + "diagnosticSha256": "8de225bd4334ef44b9b6048db2dd613a16d47bad9768ef42440a91d3e417c45c" + }, + "measurements": [ + { + "index": 0, + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 340, + "lcp": 340, + "cls": 0.20706520518407043, + "domInteractive": 315.89999999990687, + "domContentLoaded": 317.19999999995343, + "requestMs": 352, + "firstVisibleEstimateMs": 564.7999999998137, + "runtimeResponseEnd": 299.79999999981374, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 1, + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 180, + "lcp": 180, + "cls": 0.20706520518407043, + "domInteractive": 167.5, + "domContentLoaded": 169.30000000004657, + "requestMs": 190, + "firstVisibleEstimateMs": 405.5, + "runtimeResponseEnd": 119, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 2, + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 336, + "lcp": 336, + "cls": 0, + "domInteractive": 328.0999999998603, + "domContentLoaded": 330, + "requestMs": 343, + "firstVisibleEstimateMs": 563.7999999998137, + "runtimeResponseEnd": 293.0999999998603, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 3, + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 168, + "lcp": 168, + "cls": 0, + "domInteractive": 160.0999999998603, + "domContentLoaded": 161.69999999995343, + "requestMs": 192, + "firstVisibleEstimateMs": 391.39999999990687, + "runtimeResponseEnd": 114.30000000004657, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 4, + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 160, + "lcp": 160, + "cls": 0, + "domInteractive": 158.39999999990687, + "domContentLoaded": 159.0999999998603, + "requestMs": 181, + "firstVisibleEstimateMs": 395.0999999998603, + "runtimeResponseEnd": 114.80000000004657, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 5, + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 308, + "lcp": 324, + "cls": 0, + "domInteractive": 318.89999999990687, + "domContentLoaded": 320.0999999998603, + "requestMs": 329, + "firstVisibleEstimateMs": 530.7999999998137, + "runtimeResponseEnd": 293.0999999998603, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 6, + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 180, + "lcp": 180, + "cls": 0.20706520518407043, + "domInteractive": 165.69999999995343, + "domContentLoaded": 167.39999999990687, + "requestMs": 191, + "firstVisibleEstimateMs": 389.29999999981374, + "runtimeResponseEnd": 118.0999999998603, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 7, + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 356, + "lcp": 356, + "cls": 0.20706520518407043, + "domInteractive": 319.80000000004657, + "domContentLoaded": 321.80000000004657, + "requestMs": 357, + "firstVisibleEstimateMs": 573.3000000000466, + "runtimeResponseEnd": 299.4000000001397, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 8, + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0.20706520518407043, + "domInteractive": 315.69999999995343, + "domContentLoaded": 317.80000000004657, + "requestMs": 353, + "firstVisibleEstimateMs": 563.3999999999069, + "runtimeResponseEnd": 296.69999999995343, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 9, + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 148, + "lcp": 148, + "cls": 0.20706520518407043, + "domInteractive": 128, + "domContentLoaded": 129.80000000004657, + "requestMs": 164, + "firstVisibleEstimateMs": 363.70000000018626, + "runtimeResponseEnd": 118.60000000009313, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 10, + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 344, + "lcp": 344, + "cls": 0, + "domInteractive": 336.69999999995343, + "domContentLoaded": 338.80000000004657, + "requestMs": 353, + "firstVisibleEstimateMs": 554.6999999999534, + "runtimeResponseEnd": 297.89999999990687, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 11, + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 148, + "lcp": 148, + "cls": 0, + "domInteractive": 162.60000000009313, + "domContentLoaded": 163.9000000001397, + "requestMs": 174, + "firstVisibleEstimateMs": 396.5, + "runtimeResponseEnd": 120.10000000009313, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 12, + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 184, + "lcp": 184, + "cls": 0, + "domInteractive": 168.80000000004657, + "domContentLoaded": 170.70000000018626, + "requestMs": 194, + "firstVisibleEstimateMs": 392.30000000004657, + "runtimeResponseEnd": 118.5, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 13, + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 344, + "lcp": 344, + "cls": 0, + "domInteractive": 334.39999999990687, + "domContentLoaded": 336.30000000004657, + "requestMs": 351, + "firstVisibleEstimateMs": 558.8999999999069, + "runtimeResponseEnd": 296.39999999990687, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 14, + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 164, + "lcp": 164, + "cls": 0.20706520518407043, + "domInteractive": 160.30000000004657, + "domContentLoaded": 161.30000000004657, + "requestMs": 189, + "firstVisibleEstimateMs": 387.5, + "runtimeResponseEnd": 115.80000000004657, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 15, + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 352, + "lcp": 352, + "cls": 0.20706520518407043, + "domInteractive": 316.89999999990687, + "domContentLoaded": 318.60000000009313, + "requestMs": 354, + "firstVisibleEstimateMs": 564.8999999999069, + "runtimeResponseEnd": 299.5, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 16, + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0.20706520518407043, + "domInteractive": 312.4000000001397, + "domContentLoaded": 314.20000000018626, + "requestMs": 350, + "firstVisibleEstimateMs": 554.7000000001863, + "runtimeResponseEnd": 293.9000000001397, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 17, + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 164, + "lcp": 164, + "cls": 0.20706520518407043, + "domInteractive": 163.20000000018626, + "domContentLoaded": 163.4000000001397, + "requestMs": 192, + "firstVisibleEstimateMs": 406.9000000001397, + "runtimeResponseEnd": 114.80000000004657, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 18, + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 340, + "lcp": 340, + "cls": 0, + "domInteractive": 333.80000000004657, + "domContentLoaded": 335.89999999990687, + "requestMs": 349, + "firstVisibleEstimateMs": 550.6999999999534, + "runtimeResponseEnd": 297.60000000009313, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 19, + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 184, + "lcp": 184, + "cls": 0, + "domInteractive": 176.60000000009313, + "domContentLoaded": 177.80000000004657, + "requestMs": 200, + "firstVisibleEstimateMs": 412.20000000018626, + "runtimeResponseEnd": 125.30000000004657, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 20, + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 356, + "lcp": 356, + "cls": 0.11113281249999998, + "domInteractive": 316.5, + "domContentLoaded": 318.19999999995343, + "requestMs": 352, + "firstVisibleEstimateMs": 579.0999999998603, + "runtimeResponseEnd": 297.5999999998603, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 21, + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 176, + "lcp": 176, + "cls": 0.11113281249999998, + "domInteractive": 165.89999999990687, + "domContentLoaded": 167.5999999998603, + "requestMs": 188, + "firstVisibleEstimateMs": 403.19999999995343, + "runtimeResponseEnd": 120.5999999998603, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 22, + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 312, + "lcp": 312, + "cls": 0, + "domInteractive": 318.60000000009313, + "domContentLoaded": 319.19999999995343, + "requestMs": 327, + "firstVisibleEstimateMs": 524.8999999999069, + "runtimeResponseEnd": 293.30000000004657, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 23, + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 176, + "lcp": 176, + "cls": 0, + "domInteractive": 162.89999999990687, + "domContentLoaded": 164.89999999990687, + "requestMs": 190, + "firstVisibleEstimateMs": 390, + "runtimeResponseEnd": 118.5, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 24, + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 148, + "lcp": 148, + "cls": 0, + "domInteractive": 156.0999999998603, + "domContentLoaded": 157.19999999995343, + "requestMs": 169, + "firstVisibleEstimateMs": 368.29999999981374, + "runtimeResponseEnd": 120, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 25, + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 336, + "lcp": 336, + "cls": 0, + "domInteractive": 324.19999999995343, + "domContentLoaded": 326, + "requestMs": 339, + "firstVisibleEstimateMs": 550.3999999999069, + "runtimeResponseEnd": 294.89999999990687, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 26, + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 172, + "lcp": 172, + "cls": 0.11113281249999998, + "domInteractive": 161.9000000001397, + "domContentLoaded": 162.60000000009313, + "requestMs": 192, + "firstVisibleEstimateMs": 411.20000000018626, + "runtimeResponseEnd": 117.5, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 27, + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0.11113281249999998, + "domInteractive": 316.79999999981374, + "domContentLoaded": 318.5, + "requestMs": 344, + "firstVisibleEstimateMs": 551.1999999999534, + "runtimeResponseEnd": 295.5999999998603, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 28, + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 312, + "lcp": 312, + "cls": 0.11113281249999998, + "domInteractive": 308.39999999990687, + "domContentLoaded": 309.0999999998603, + "requestMs": 330, + "firstVisibleEstimateMs": 527.0999999998603, + "runtimeResponseEnd": 295.29999999981374, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 29, + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 184, + "lcp": 184, + "cls": 0.11113281249999998, + "domInteractive": 163.5999999998603, + "domContentLoaded": 165.69999999995343, + "requestMs": 193, + "firstVisibleEstimateMs": 412.5, + "runtimeResponseEnd": 115.29999999981374, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 30, + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 344, + "lcp": 344, + "cls": 0, + "domInteractive": 332.5999999998603, + "domContentLoaded": 334.39999999990687, + "requestMs": 348, + "firstVisibleEstimateMs": 556.0999999998603, + "runtimeResponseEnd": 297.5999999998603, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 31, + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 152, + "lcp": 152, + "cls": 0, + "domInteractive": 162.30000000004657, + "domContentLoaded": 162.5, + "requestMs": 171, + "firstVisibleEstimateMs": 382.19999999995343, + "runtimeResponseEnd": 119.60000000009313, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 32, + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 152, + "lcp": 152, + "cls": 0, + "domInteractive": 163.30000000004657, + "domContentLoaded": 164.19999999995343, + "requestMs": 176, + "firstVisibleEstimateMs": 375.89999999990687, + "runtimeResponseEnd": 120.19999999995343, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 33, + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 344, + "lcp": 344, + "cls": 0, + "domInteractive": 332.69999999995343, + "domContentLoaded": 334.19999999995343, + "requestMs": 348, + "firstVisibleEstimateMs": 553, + "runtimeResponseEnd": 294.10000000009313, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 34, + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 180, + "lcp": 180, + "cls": 0.11113281249999998, + "domInteractive": 159.69999999995343, + "domContentLoaded": 161.5999999998603, + "requestMs": 186, + "firstVisibleEstimateMs": 401, + "runtimeResponseEnd": 113.39999999990687, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 35, + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 352, + "lcp": 352, + "cls": 0.11113281249999998, + "domInteractive": 311.79999999981374, + "domContentLoaded": 314.29999999981374, + "requestMs": 346, + "firstVisibleEstimateMs": 563, + "runtimeResponseEnd": 294, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 36, + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 356, + "lcp": 356, + "cls": 0.11113281249999998, + "domInteractive": 322.80000000004657, + "domContentLoaded": 325.70000000018626, + "requestMs": 352, + "firstVisibleEstimateMs": 566.2000000001863, + "runtimeResponseEnd": 299.4000000001397, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 37, + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 148, + "lcp": 148, + "cls": 0.11113281249999998, + "domInteractive": 159.60000000009313, + "domContentLoaded": 160.30000000004657, + "requestMs": 167, + "firstVisibleEstimateMs": 385, + "runtimeResponseEnd": 117.10000000009313, + "reservationHeight": 250, + "whitespace": 0 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 38, + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 332, + "lcp": 332, + "cls": 0, + "domInteractive": 323.19999999995343, + "domContentLoaded": 324.5999999998603, + "requestMs": 335, + "firstVisibleEstimateMs": 531.8999999999069, + "runtimeResponseEnd": 296.89999999990687, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + }, + { + "index": 39, + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 172, + "lcp": 172, + "cls": 0, + "domInteractive": 161.19999999995343, + "domContentLoaded": 162.69999999995343, + "requestMs": 176, + "firstVisibleEstimateMs": 382.19999999995343, + "runtimeResponseEnd": 112.69999999995343, + "reservationHeight": 600, + "whitespace": 350 + }, + "nativeRequests": 1, + "runtimeCacheHit": true, + "runtimeTransferBytes": 0, + "preCoreCallbacks": 1, + "nativeRuntimeLoads": 1 + } + ], + "comparisons": { + "loading/390": { + "fcp": { + "baseline": 348, + "changed": 164, + "delta": -184, + "percent": -52.87356321839081 + }, + "lcp": { + "baseline": 348, + "changed": 164, + "delta": -184, + "percent": -52.87356321839081 + }, + "cls": { + "baseline": 0.20706520518407043, + "changed": 0.20706520518407043, + "delta": 0, + "percent": 0 + }, + "domInteractive": { + "baseline": 315.89999999990687, + "changed": 163.20000000018626, + "delta": -152.6999999997206, + "percent": -48.33808167134081 + }, + "domContentLoaded": { + "baseline": 317.80000000004657, + "changed": 163.4000000001397, + "delta": -154.39999999990687, + "percent": -48.58401510380247 + }, + "requestMs": { + "baseline": 353, + "changed": 190, + "delta": -163, + "percent": -46.1756373937677 + }, + "firstVisibleEstimateMs": { + "baseline": 564.7999999998137, + "changed": 389.29999999981374, + "delta": -175.5, + "percent": -31.072946175647644 + }, + "runtimeResponseEnd": { + "baseline": 299.4000000001397, + "changed": 118.0999999998603, + "delta": -181.3000000002794, + "percent": -60.55444221783394 + }, + "reservationHeight": { + "baseline": 250, + "changed": 250, + "delta": 0, + "percent": 0 + }, + "whitespace": { + "baseline": 0, + "changed": 0, + "delta": 0, + "percent": null + } + }, + "reservation/390": { + "fcp": { + "baseline": 348, + "changed": 340, + "delta": -8, + "percent": -2.2988505747126435 + }, + "lcp": { + "baseline": 348, + "changed": 340, + "delta": -8, + "percent": -2.2988505747126435 + }, + "cls": { + "baseline": 0.20706520518407043, + "changed": 0, + "delta": -0.20706520518407043, + "percent": -100 + }, + "domInteractive": { + "baseline": 315.89999999990687, + "changed": 333.80000000004657, + "delta": 17.9000000001397, + "percent": 5.6663501108404475 + }, + "domContentLoaded": { + "baseline": 317.80000000004657, + "changed": 335.89999999990687, + "delta": 18.0999999998603, + "percent": 5.69540591562544 + }, + "requestMs": { + "baseline": 353, + "changed": 349, + "delta": -4, + "percent": -1.13314447592068 + }, + "firstVisibleEstimateMs": { + "baseline": 564.7999999998137, + "changed": 554.6999999999534, + "delta": -10.099999999860302, + "percent": -1.7882436260381787 + }, + "runtimeResponseEnd": { + "baseline": 299.4000000001397, + "changed": 296.39999999990687, + "delta": -3.0000000002328306, + "percent": -1.0020040080933303 + }, + "reservationHeight": { + "baseline": 250, + "changed": 250, + "delta": 0, + "percent": 0 + }, + "whitespace": { + "baseline": 0, + "changed": 0, + "delta": 0, + "percent": null + } + }, + "combined/390": { + "fcp": { + "baseline": 348, + "changed": 168, + "delta": -180, + "percent": -51.724137931034484 + }, + "lcp": { + "baseline": 348, + "changed": 168, + "delta": -180, + "percent": -51.724137931034484 + }, + "cls": { + "baseline": 0.20706520518407043, + "changed": 0, + "delta": -0.20706520518407043, + "percent": -100 + }, + "domInteractive": { + "baseline": 315.89999999990687, + "changed": 162.60000000009313, + "delta": -153.29999999981374, + "percent": -48.528015194637206 + }, + "domContentLoaded": { + "baseline": 317.80000000004657, + "changed": 163.9000000001397, + "delta": -153.89999999990687, + "percent": -48.42668344867348 + }, + "requestMs": { + "baseline": 353, + "changed": 192, + "delta": -161, + "percent": -45.609065155807365 + }, + "firstVisibleEstimateMs": { + "baseline": 564.7999999998137, + "changed": 395.0999999998603, + "delta": -169.69999999995343, + "percent": -30.046033994335943 + }, + "runtimeResponseEnd": { + "baseline": 299.4000000001397, + "changed": 118.5, + "delta": -180.9000000001397, + "percent": -60.4208416833852 + }, + "reservationHeight": { + "baseline": 250, + "changed": 250, + "delta": 0, + "percent": 0 + }, + "whitespace": { + "baseline": 0, + "changed": 0, + "delta": 0, + "percent": null + } + }, + "loading/1280": { + "fcp": { + "baseline": 352, + "changed": 176, + "delta": -176, + "percent": -50 + }, + "lcp": { + "baseline": 352, + "changed": 176, + "delta": -176, + "percent": -50 + }, + "cls": { + "baseline": 0.11113281249999998, + "changed": 0.11113281249999998, + "delta": 0, + "percent": 0 + }, + "domInteractive": { + "baseline": 316.5, + "changed": 161.9000000001397, + "delta": -154.5999999998603, + "percent": -48.846761453352386 + }, + "domContentLoaded": { + "baseline": 318.19999999995343, + "changed": 162.60000000009313, + "delta": -155.5999999998603, + "percent": -48.900062853514484 + }, + "requestMs": { + "baseline": 346, + "changed": 188, + "delta": -158, + "percent": -45.664739884393065 + }, + "firstVisibleEstimateMs": { + "baseline": 563, + "changed": 403.19999999995343, + "delta": -159.80000000004657, + "percent": -28.383658969812892 + }, + "runtimeResponseEnd": { + "baseline": 295.5999999998603, + "changed": 117.10000000009313, + "delta": -178.49999999976717, + "percent": -60.385656292236646 + }, + "reservationHeight": { + "baseline": 250, + "changed": 250, + "delta": 0, + "percent": 0 + }, + "whitespace": { + "baseline": 0, + "changed": 0, + "delta": 0, + "percent": null + } + }, + "reservation/1280": { + "fcp": { + "baseline": 352, + "changed": 336, + "delta": -16, + "percent": -4.545454545454546 + }, + "lcp": { + "baseline": 352, + "changed": 336, + "delta": -16, + "percent": -4.545454545454546 + }, + "cls": { + "baseline": 0.11113281249999998, + "changed": 0, + "delta": -0.11113281249999998, + "percent": -100 + }, + "domInteractive": { + "baseline": 316.5, + "changed": 324.19999999995343, + "delta": 7.699999999953434, + "percent": 2.4328593996693315 + }, + "domContentLoaded": { + "baseline": 318.19999999995343, + "changed": 326, + "delta": 7.800000000046566, + "percent": 2.451288497815119 + }, + "requestMs": { + "baseline": 346, + "changed": 339, + "delta": -7, + "percent": -2.023121387283237 + }, + "firstVisibleEstimateMs": { + "baseline": 563, + "changed": 550.3999999999069, + "delta": -12.600000000093132, + "percent": -2.2380106572101477 + }, + "runtimeResponseEnd": { + "baseline": 295.5999999998603, + "changed": 294.89999999990687, + "delta": -0.6999999999534339, + "percent": -0.23680649524822892 + }, + "reservationHeight": { + "baseline": 250, + "changed": 600, + "delta": 350, + "percent": 140 + }, + "whitespace": { + "baseline": 0, + "changed": 350, + "delta": 350, + "percent": null + } + }, + "combined/1280": { + "fcp": { + "baseline": 352, + "changed": 152, + "delta": -200, + "percent": -56.81818181818182 + }, + "lcp": { + "baseline": 352, + "changed": 152, + "delta": -200, + "percent": -56.81818181818182 + }, + "cls": { + "baseline": 0.11113281249999998, + "changed": 0, + "delta": -0.11113281249999998, + "percent": -100 + }, + "domInteractive": { + "baseline": 316.5, + "changed": 162.30000000004657, + "delta": -154.19999999995343, + "percent": -48.72037914690472 + }, + "domContentLoaded": { + "baseline": 318.19999999995343, + "changed": 162.69999999995343, + "delta": -155.5, + "percent": -48.86863607794555 + }, + "requestMs": { + "baseline": 346, + "changed": 176, + "delta": -170, + "percent": -49.13294797687861 + }, + "firstVisibleEstimateMs": { + "baseline": 563, + "changed": 382.19999999995343, + "delta": -180.80000000004657, + "percent": -32.11367673180223 + }, + "runtimeResponseEnd": { + "baseline": 295.5999999998603, + "changed": 119.60000000009313, + "delta": -175.99999999976717, + "percent": -59.53991880915099 + }, + "reservationHeight": { + "baseline": 250, + "changed": 600, + "delta": 350, + "percent": 140 + }, + "whitespace": { + "baseline": 0, + "changed": 350, + "delta": 350, + "percent": null + } + } + } + } + ] + }, "status": "blocked", "blockers": [ "Material slower warm-cache paint and ad-startup regression; loading design needs revision.", From c60229f70c96d703dce3f5506071b3b2d592a93e Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 3 Nov 2026 11:48:17 -0600 Subject: [PATCH 12/13] Fix synchronous loading acceptance and review regressions Adopt the approved synchronous unified loading and no-fallback outage contract in the full CWV and publisher acceptance consumers. Preserve registered trace correlation, bind exported JavaScript to current sources and selected runtime bytes, invalidate stale acceptance journals, and verify marker visibility across frame boundaries. Record passing scoped verification and byte-checked measurement reuse without relabeling the failed controller attempt or claiming real-GPT acceptance. --- .../src/integrations/gpt_bootstrap.js | 36 +- .../browser/cwv/README.md | 504 +++++++----------- .../browser/cwv/contract.cjs | 152 ++++++ .../browser/cwv/contract.test.cjs | 201 +++++++ .../browser/cwv/diagnostic.cjs | 23 +- .../browser/cwv/diagnostic.test.cjs | 22 +- .../browser/cwv/evidence.cjs | 2 +- .../browser/cwv/evidence.test.cjs | 51 +- .../browser/cwv/html.cjs | 11 +- .../browser/cwv/html.test.cjs | 22 +- .../browser/cwv/run.cjs | 330 ++++++------ .../marker-visibility.cjs | 71 +++ .../marker-visibility.test.cjs | 126 +++++ .../publisher-registration/real-gpt.README.md | 6 +- .../publisher-registration.real-gpt.spec.ts | 367 ++++++++----- .../nextjs/publisher-registration.spec.ts | 293 ++++++---- .../examples/cwv_html.rs | 14 +- .../lib/src/integrations/gpt/index.ts | 30 +- .../gpt/publisher_registration.ts | 8 +- .../gpt/registration_trace.test.ts | 455 ++++++++++++++++ .../gpt/schedule_initial_ad_init.test.ts | 12 +- docs/guide/integrations/gpt.md | 40 +- .../2026-11-02-854-closure-acceptance.md | 30 +- scripts/test-client-cwv-acceptance.mjs | 68 ++- scripts/test-client-cwv-acceptance.test.mjs | 169 +++++- 25 files changed, 2266 insertions(+), 777 deletions(-) create mode 100644 crates/trusted-server-integration-tests/browser/cwv/contract.cjs create mode 100644 crates/trusted-server-integration-tests/browser/cwv/contract.test.cjs create mode 100644 crates/trusted-server-integration-tests/browser/publisher-registration/marker-visibility.cjs create mode 100644 crates/trusted-server-integration-tests/browser/publisher-registration/marker-visibility.test.cjs create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/registration_trace.test.ts diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 3aef7d1669..c9547af52a 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -704,6 +704,12 @@ ts.prevSlotTargetingKeys, ); try { + const auctionId = + candidate == null ? void 0 : candidate.hb_auction_id; + const trace = + window.__tsjs_trace_active === true && ts.traceGpt + ? ts.traceGpt.opportunity(slot, auctionId) + : { auctionId }; (_e = ts.gptDiagnosticsRecorder) == null ? void 0 : _e.recordTrustedServerOpportunity( @@ -714,8 +720,9 @@ ? "renderable_candidate" : "unrenderable_candidate" : "no_candidate", - candidate == null ? void 0 : candidate.hb_auction_id, + trace.auctionId, record.eligibleFormats, + ...(trace.identity ? [trace.identity] : []), ); } catch (e) {} ts.gptSlotHandoffInternal = true; @@ -910,22 +917,24 @@ // would overwrite a committed SPA navigation's slots. if (initialSlots !== undefined) ts.adSlots = initialSlots; if (initialBids !== undefined) ts.bids = initialBids; + // Bind before registered dispatch; the deferred automatic pass must not + // re-observe this batch and replace newer trace bindings. + try { + if (window.__tsjs_trace_active === true && ts.traceGpt) { + ts.traceGpt.observeTransport( + initialSlots === undefined ? ts.adSlots : initialSlots, + traceAuctionTransport, + "initial_navigation_ssat", + ); + } + } catch (_) { + // Trace failure cannot interrupt the initial advertising pass. + } if (initialBids !== undefined || initialSlots !== undefined) { settlePublisherSlotPayload(ts, 0, ts.adSlots || [], ts.bids || {}); } var fire = function () { if ((ts.navGeneration || 0) !== 0) return; - try { - if (window.__tsjs_trace_active === true && ts.traceGpt) { - ts.traceGpt.observeTransport( - initialSlots === undefined ? ts.adSlots : initialSlots, - traceAuctionTransport, - "initial_navigation_ssat", - ); - } - } catch (_) { - // Trace failure cannot interrupt the initial advertising pass. - } if (typeof ts.adInit === "function") ts.adInit(); }; var afterFrames = function () { @@ -941,7 +950,8 @@ if (window.__tsjs_trace_active !== true) return; try { var bridge = ts.traceGpt; - if (!bridge || !bridge.identity(slot) || !ts.gptDiagnosticsRecorder) return; + if (!bridge || !bridge.identity(slot) || !ts.gptDiagnosticsRecorder) + return; var trace = bridge.opportunity(slot, bid.hb_auction_id); var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[gptSlot.getSlotElementId()]; diff --git a/crates/trusted-server-integration-tests/browser/cwv/README.md b/crates/trusted-server-integration-tests/browser/cwv/README.md index a343dc7cce..51e2ae5610 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/README.md +++ b/crates/trusted-server-integration-tests/browser/cwv/README.md @@ -1,15 +1,24 @@ # Parser, paint and slot-space lab checks -This runner uses a loopback HTTP server, parser-created production script tags, +This runner uses loopback HTTP servers, parser-created production script tags, real Rust HTML injection and the exact JS bytes returned by the production static route. GPT transport and bids are fictional. No customer, GAM or bidder request is allowed. These are lab results, not field CWV or production fill evidence. -The default, correctness-only and regressions-only modes retain the earlier -inline/deferred loading contract. They reject the current synchronous-unified -candidate until its architecture and outage boundary are accepted and the full -consumers are migrated. Use the explicit diagnostic scopes below for the limited -candidate experiments, not as a substitute for full acceptance. +All scopes now test the approved synchronous unified runtime for this supported +GPT profile. Candidate documents must have one versioned, classic, parser-blocking +unified runtime, GPT configuration before it, and neither a raw nor compiled inline +bootstrap. A failed or absent critical runtime means no TS APIs or TS delivery. +The pinned original-main baseline remains a baseline, not a candidate failure. + +```mermaid +flowchart TD + A[Production exported HTML] --> B[Synchronous unified runtime] + B -->|Success| C[Core queue and GPT APIs available before publisher script] + C --> D[One targeted initial request and visible creative] + B -->|Failed or absent| E[No TS APIs or delivery; parser continues] + E --> F[Explicit reload and publisher scheduling retry only in recovery cases] +``` ## Export @@ -26,331 +35,226 @@ TSJS_PREBUILT_DIR=/absolute/current-dist \ ``` The exporter writes inline/shared documents, responsive and unknown-height -creatives, unmatched paths, absent wrappers and child-replacement controls. The -manifest contains source revision/status, source fingerprints, the exact exporter -source, selected configuration, toolchain and the production URL-to-asset map. -The runner snapshots and hashes every input before launching Chromium. - -For an archived pre-change source tree, copy this example and the small -`test-utils` `publisher::fixture_ad_scripts` bridge, both of which call existing -production builders. Omit `.with_request_path(path)` and `.with_enforced_csp(name == "csp")`, -which did not exist in the archived source. Baseline output already uses the -original synchronous loading path. Use the archived source's frozen JS and `unreserved`. -Record these fixture-only source differences. Archive exports can supply -`CWV_SOURCE_REVISION` and `CWV_SOURCE_STATUS`; the manifest explicitly records -these environment overrides instead of presenting them as Git observations. - -Do not substitute current generated head snippets for baseline snippets. Baseline -production code must generate baseline HTML and serve baseline JS. +creatives, unmatched paths, absent wrappers, registered slots and child-replacement +controls. Its manifest retains source revision/status, source fingerprints, exact +exporter source, configuration, toolchain and the production URL-to-asset map. +The runner snapshots and hashes every input before launching Chromium. Full +acceptance additionally validates source fingerprints against the selected checkout. +Regenerate candidate exports after production source changes. An iteration run +against older exports does not verify the final source state. + +Baseline must identify original main +`51730bdaf841e4d7bb96c2258e5e98169bdff9a7`, not the former inline/deferred PR +snapshot. Use genuine baseline production HTML and served JS, never rewritten +candidate head snippets. For an archived source tree, copy the exporter and small +`test-utils` `publisher::fixture_ad_scripts` bridge that call existing production +builders. Omit `.with_request_path(path)` and `.with_enforced_csp(name == "csp")` +if absent in that source. Record those fixture-only differences. Archive exports +may supply `CWV_SOURCE_REVISION` and `CWV_SOURCE_STATUS`; the manifest records +these as environment overrides rather than Git observations. ## Run -Install the browser package's existing dependencies and its matching Playwright -Chromium first. The runner itself performs no installs or builds. +Use the browser package's existing dependencies and matching Playwright Chromium. +The runner performs no installs or builds. ```sh node --test crates/trusted-server-integration-tests/browser/cwv/*.test.cjs node crates/trusted-server-integration-tests/browser/cwv/run.cjs \ --baseline /tmp/cwv-baseline \ - --loading /tmp/cwv-loading \ - --combined /tmp/cwv-combined \ + --loading /tmp/cwv-loading --combined /tmp/cwv-combined \ --runs 5 --out /tmp/cwv-evidence ``` -Four arms alternate forward/reverse order at 390x844 and 1280x1000. Each sample -starts in a fresh context, including warm samples before priming. The arms are: +Four measurement arms alternate forward/reverse order at 390x844 and 1280x1000: -- `baseline`: frozen pre-change production HTML and JS, without reservation. +- `baseline`: frozen original-main production HTML and JS, without reservation. - `loading`: changed production HTML and JS, without reservation. -- `reservation`: baseline HTML and JS plus the exact production reservation style - from the changed reserved document. This CSS-only isolation derivative does not - imply that old code understood the new config. +- `reservation`: baseline HTML and JS plus the exact changed production reservation + style. This CSS-only isolation derivative does not imply old code understood the + new configuration. - `combined`: changed production HTML and JS with reservation. -Baseline and changed measurement HTML remain untouched. Every isolation derivative -and copied style has its own hash. All arms use the same selected conditions. - -| Option | Default | Meaning | -| ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--runtime-delay-ms NUMBER` | `750` | Hold the first main-runtime HTTP response per navigation. `0` is valid. Cache hits do not contact the server and therefore have no hold. Retries are not held. | -| `--cpu-rate NUMBER` | `1` | Chromium CDP CPU slowdown rate, at least 1. | -| `--network-latency-ms NUMBER` | `0` | CDP request latency in milliseconds. | -| `--download-kbps NUMBER` | `0` | CDP download rate in decimal kilobits/second. `0` means unlimited. | -| `--upload-kbps NUMBER` | `0` | CDP upload rate in decimal kilobits/second. `0` means unlimited. | -| `--cache cold\|warm` | `cold` | Cold disables HTTP cache and serves no-store responses. Warm primes then navigates again in the same page/context. | - -Numeric options must be finite and nonnegative except CPU rate, which must be at -least 1, and runs, which must be a positive safe integer. Runtime delay must also -fit Node's timer range. Invalid options fail before browser startup. - -Warm responses cache successful exported JS, the hero image and the creative JS -for one hour. HTML, creative documents and failures remain no-store. Priming uses -the exact sample document, runtime-failure mode, viewport and selected conditions. -It waits for delivery when matched and retains its own observer/network evidence. -The measured navigation must show a native CDP HTTP-cache hit. A successful runtime -must itself hit cache. Service workers are blocked and do not count as HTTP cache. -No Playwright routes are installed because routing disables Chromium HTTP cache. -An HTTP proxy forwards only the exact publisher and creative fixture origins, -preserving response headers and browser HTTP caching. It rejects every other -origin and all CONNECT tunnels. It does not depend on browser loopback-bypass -settings. Unexpected URLs still fail assertions. The origin allowlist and proxy -requests are retained in `artifacts.json`. +Baseline and candidate measurement HTML stay untouched. Each derivative and copied +style has its own hash. All arms use the same selected conditions. Original-main +arms may retain their original raw bootstrap and lack the new renderer marker. +They still require one targeted request, actual Server creative DOM and visible-ad +evidence. Candidate arms additionally require renderer protocol version 1. + +| Option | Default | Meaning | +| ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `--runtime-delay-ms NUMBER` | `750` | Hold the first runtime HTTP response per navigation. `0` is valid. Cache hits never reach the server; explicit retries are not held. | +| `--cpu-rate NUMBER` | `1` | Chromium CDP CPU slowdown rate, at least 1. | +| `--network-latency-ms NUMBER` | `0` | CDP latency in milliseconds. | +| `--download-kbps NUMBER` | `0` | Decimal kilobits/second; `0` means unlimited. | +| `--upload-kbps NUMBER` | `0` | Decimal kilobits/second; `0` means unlimited. | +| `--cache cold\|warm` | `cold` | Cold disables cache and serves no-store responses. Warm primes then navigates again in the same page/context. | + +Numbers must be finite and nonnegative except CPU rate, which must be at least 1, +and runs, which must be a positive safe integer. Delay must fit Node's timer range. +Invalid options fail before browser startup. + +Each sample starts in a fresh context. Warm priming uses the exact document, +failure mode, viewport and conditions, waits for expected delivery, and retains +separate observer/network evidence. Successful exported JS, hero and creative JS +are cacheable for one hour; HTML, creative documents and failures are no-store. +Measured warm samples must demonstrate a native CDP HTTP-cache hit. Successful +runtimes must themselves hit cache. Service workers are blocked and never count +as HTTP-cache evidence. No Playwright routes disable browser caching. -For example, run the ordinary response with warm cache and explicit throttling: +An HTTP proxy forwards only the exact publisher and creative fixture origins, +preserves response headers, rejects all other origins and CONNECT tunnels, and +does not depend on loopback-bypass settings. Unexpected URLs fail assertions. +Artifacts retain the allowlist and every proxy request. ```sh node crates/trusted-server-integration-tests/browser/cwv/run.cjs \ --baseline /tmp/cwv-baseline --loading /tmp/cwv-loading \ --combined /tmp/cwv-combined --out /tmp/cwv-warm \ --runtime-delay-ms 0 --cache warm --cpu-rate 4 \ - --network-latency-ms 150 --download-kbps 1600 --upload-kbps 750 -``` - -Default cold runs retain the existing 750ms held-response FCP check. Native -captured script-load events also check whether the parser has reached the article: -blocking runtime must load before it, deferred runtime after it. This ordering -check does not depend on a slow response. A normal 0ms response or a warm cache hit -may legitimately finish before FCP. No timing-improvement threshold decides whether -measurements pass. CPU and network shaping apply to all fixture resources, not -just the runtime, and shared-host contention can still affect results. - -`--correctness-only` omits the baseline comparison and repeated timing samples. -CI uses this mode against freshly exported changed artifacts, then uploads all -measurement evidence. Full before/after comparison is an explicit local command -because CI does not reconstruct historical production builds. - -For the review regressions alone, add `--regressions-only` instead. This runs -four focused cases: true renderer absence, deferred-runtime failure with the -early renderer retained, successful enforced old-inline-hash/self CSP, and failure -of the synchronous runtime under that same CSP. Supply the frozen -`--baseline` export to derive hashes from genuinely pre-change inline script -text. Without it, CI explicitly records hashes from the current synchronous -legacy-inline output. Neither policy permits the new compiled early entry. -The exporter marks the CSP document with `with_enforced_csp(true)` and the runner -sends the matching enforced header. Fictional creative-inline hashes are also -included; the synchronous fallback must render without CSP violations. The normal -CSP case also checks every compiled core API, registration/scheduling APIs, shim -hook, and protocol/renderer versions with neither raw nor compiled early inline -bootstrap present. This is a reproducible check of the exact runtime bytes loaded -by the browser, not just a separately evaluated source module. - -The failed synchronous-runtime case establishes a different boundary. It requires -core, shim and renderer APIs to remain unavailable, zero native requests and no TS -delivery, while still having no blocked inline scripts. The existing core-dependent -publisher probe must produce exactly its declared `tsjs.setConfig is not a function` -error; no other page/network errors are accepted. This case does not claim a -provisional fallback exists or that failed-runtime behavior matches normal CSP -delivery. It remains a separate sample and never relaxes the successful CSP gate. - -Full mode has `8 * runs + 24` samples, correctness-only has 26, and regressions-only -has 4. The completion schema is unchanged. - -## Diagnostic synchronous-unified screen - -Use `--diagnostic-screen` only for the first identity-transport comparison of the -cacheable synchronous-unified candidate. It is not combined acceptance and must -not be passed to the full completion controller as a replacement for its required -scope. Existing full, correctness-only and regressions-only modes retain their -inline/deferred expectations for old artifacts. They are not migrated here. - -```sh -for CACHE in cold warm; do - node crates/trusted-server-integration-tests/browser/cwv/run.cjs \ - --diagnostic-screen --baseline /tmp/cwv-baseline \ - --loading /tmp/cwv-loading --combined /tmp/cwv-combined \ - --runs 5 --out "/tmp/cwv-diagnostic-$CACHE" \ - --runtime-delay-ms 0 --cache "$CACHE" --cpu-rate 4 \ - --network-latency-ms 100 --download-kbps 1600 --upload-kbps 750 -done + --network-latency-ms 100 --download-kbps 1600 --upload-kbps 750 ``` -The baseline is mandatory and its manifest must identify pinned original main -`51730bdaf841e4d7bb96c2258e5e98169bdff9a7`, not the failing inline/deferred PR -snapshot. Keep genuine baseline production HTML and served bytes. Candidate -exports must come from the production builders, not rewritten head snippets. - -The expected architecture is fixed independently of observed HTML. Every arm -must use one synchronous classic unified runtime. Candidate arms require a -versioned URL, GPT configuration before it, no `#trustedserver-bootstrap` and no -raw provisional GPT bootstrap. Native runtime load events must observe parser -blocking through `assertRuntimeOrdering`; the test does not treat that as safe -deferral. Warm samples must prove actual runtime HTTP-cache reuse through -`assertCacheUse`. Identity responses, fail-closed proxy, per-sample contexts, -priming, observers and raw timing/byte evidence are unchanged. No Playwright -routes or response codecs are added. - -The screen runs exactly `8 * runs` measurement samples, 40 with this command. -It alternates baseline/loading/reservation/combined forward and reverse at -390x844 and 1280x1000. Each invocation selects one cache condition, so the pair -above records 80 samples. There are no skipped or optional cases within that -scope. The existing inline fixture checks direct early core calls, one initial -native request, candidate targeting and visible Server creative. The screen also -queues a test-only callback before production core executes. Evidence captures -core methods and a real `getConfig()` call when that queue drains, then captures -GPT registration, scheduling, shim and renderer APIs at the native runtime load. -The core callback runs before the GPT IIFE, so it does not require those later -APIs there. This init-script probe does not alter the exported HTML or runtime. - -`acceptance.json.scope` is `diagnostic-screen`, with a declared expected sample -count and `diagnostic.fullAcceptancePassed=false` even if this limited scope -passes. Both existing negative-control entries are `not-requested` with explicit -reasons. Removing `defer` would be vacuous for this candidate; the reservation -negative control and broader correctness suites are outside the first screen. -The screen retains successful-arm reservation geometry and unreserved CLS gates. -A zero or incomplete case set fails. A failed rerun still invalidates the previous -outcome before dependencies or exports are read. - -CSP and critical-runtime outages, publisher registration, framework lifecycle, -full negative controls, deferred-runtime correctness, production compressed -magnitude and real-GPT acceptance are not established by this screen. Missing -authorized Google inventory remains blocked outside this diagnostic scope. -Synchronous loading and loss of the inline critical-script fallback remain -architecture decisions, not contracts waived by a diagnostic pass. Inspect paint -and ad-startup regressions before broadening coverage. There is no newly invented -noise tolerance or timing-improvement threshold. - -## Diagnostic critical-runtime outages - -After the measurement screen, `--diagnostic-outages` observes four focused -availability cases at 390x844 using the reserved candidate export. It is mutually -exclusive with every other scope. It does not change the full-mode deferred-runtime -failure contract or approve loss of the inline fallback. +## Full functional gates + +Full mode has `8 * runs + 24` samples. `--correctness-only` has 26 samples, +including two unreserved shift checks, but no repeated baseline comparison. +`--regressions-only` has four cases: renderer absence, normal runtime failure on a +registered slot, CSP success and CSP failure. Neither limited full-mode subset +satisfies the full measurement scope. The exact measurement and correctness case +lists reject missing, duplicated or reordered samples. + +Every normal successful sample requires native runtime load before article parsing +and FCP at or after runtime response end. This remains a blocking-runtime check at +zero delay and with warm cache. The default cold 750ms hold demonstrates the same +paint cost; no timing-improvement threshold turns a regression into a pass. +CPU/network shaping applies to all fixture resources. Shared-host contention can +still affect measurements. Report paint and ad-startup changes against baseline. + +Two separately hashed negative controls run in full and correctness-only modes: + +- `runtime-deferred` adds only `defer` to the actual synchronous runtime tag. + It must break the direct early publisher core call and its queued callback, + load after article parsing, and fail the normal synchronous ordering assertion. + Compiled APIs eventually become available, but the body-close scheduling call + was missed, so no initial requests or creative delivery may be invented. With + cold cache and the default 750ms hold, content must paint before runtime response + end. At zero delay or with cache reuse, native parser/load evidence still proves + the regression without requiring a favorable timing gap. +- `reservation-removed` removes only the production reservation styles. It must + produce article movement and positive observer-reported CLS attributed to the + article. The untouched reserved success arm must preserve article geometry and + zero controlled ad-induced CLS for compatible fill or no-fill. + +These are browser controls, not labels inferred from HTML or unit-test passes. +The old `parser-blocking` control was replaced because removing nonexistent +`defer` from the approved synchronous candidate would be vacuous. + +Slow, failed, absent and explicitly recovered runtime cases cover automatic and +publisher-registered slots. Failed critical requests must produce exactly one +404 with no implicit retry; absent tags must produce no runtime request. Renderer +absence blocks the sole unified renderer, not an imaginary standalone deferred +module. No publisher probe or raw payload is removed. All unavailable cases retain +admitted candidate data but require unavailable core/GPT/shim/renderer APIs, an +undrained pre-core queue, no successful runtime load, zero native requests, +displays, refreshes or creatives, no visible-ad estimate and no delivery. Parsing +must still reach DOMContentLoaded and the article. + +The retained publisher core probe reports exactly +`tsjs.setConfig is not a function` during outage. Registered fixtures also report +exactly `tsjs.registerPublisherSlot is not a function`. These declare failed +publisher calls, not successful compatibility behavior; no other page errors are +accepted. A registered publisher can create its native slot before its registration +call fails, but must not display or request it through TS. + +Recovery samples first retain and assert the complete unavailable state. Only then +do they explicitly reload the runtime with a normal DOM script, re-register the +existing publisher native object when applicable, and retry initial scheduling. +They require exactly one failed and one successful runtime response, a single +queue drain after article parsing, the original failed early probe, and exactly +one targeted initial request and visible Server creative. This is explicit +publisher recovery, not automatic replay of failed scripts or inline fallback. + +Registered success and recovery require explicitly disabled GPT initial loading, +exactly one native definition/display, one scoped refresh of the publisher's exact +native object, preserved `publisher_key` targeting and a settled `rendered` handle. +The transport fixture never deduplicates TS requests. + +The enforced CSP sample sends an actual `Content-Security-Policy` header with +`script-src 'self'` and old inline hashes. With `--baseline`, hashes come from +genuine original-main inline scripts. Without baseline, the runner explicitly +records current synchronous legacy-inline output as the policy source. Fictional +creative-inline hashes are included. Neither policy permits a compiled early +bootstrap. Success requires all compiled APIs and actual creative delivery; +failure requires the same no-fallback boundary as normal pages. Both require zero +CSP violations. The failed case never relaxes the successful CSP gate. + +Additional hard checks cover inline/shared payloads, direct early core calls, +publisher queues, missing wrappers, unmatched paths, replacement of the inner ad +element, responsive widths 1023/1024, no-fill and unknown/oversize creative heights. +No zero-shift promise applies to oversize or unknown-height creatives. The +child-replacement fixture is not React hydration proof; the separate Next.js +publisher-registration gate covers framework lifecycle. + +## Limited diagnostic scopes + +`--diagnostic-screen` still runs exactly `8 * runs` alternating four-arm +measurement samples. It requires baseline and explicit `--runtime-delay-ms 0`. +`--diagnostic-outages` still runs exactly four reserved-candidate cases at 390x844: +normal success/failure and enforced-CSP success/failure. Both flags are mutually +exclusive with the other scopes. They use the same synchronous document, queue, +API, transport and ordering assertions as full mode where applicable. ```sh node crates/trusted-server-integration-tests/browser/cwv/run.cjs \ --diagnostic-outages --baseline /tmp/cwv-baseline \ --loading /tmp/cwv-loading --combined /tmp/cwv-combined \ - --out /tmp/cwv-diagnostic-outages --runs 1 \ - --runtime-delay-ms 0 --cache cold --cpu-rate 1 \ - --network-latency-ms 0 --download-kbps 0 --upload-kbps 0 + --out /tmp/cwv-diagnostic-outages --runtime-delay-ms 0 ``` -The exact cases are normal success, normal critical-unified HTTP 404, enforced -old-inline-hash/self CSP success, and the same CSP with critical-unified HTTP 404. -There are always four cases, independent of `--runs`, as in regressions-only mode. -Baseline remains pinned and supplies the genuine old inline hashes. Production -HTML and runtime bytes are unchanged. Native observers, cache configuration, -fail-closed proxy and failure artifacts use the existing sample machinery. - -Successful cases require synchronous native runtime ordering, the pre-core queue, -full runtime APIs, one targeted initial request and visible Server creative. -Failed cases separately record normal and enforced-CSP behavior. They require -all core/GPT/shim/renderer APIs unavailable, an undrained pre-core queue, no -successful runtime load, exactly one runtime 404 without retry, zero native -requests/delivery/visible ad, and only the existing declared -`tsjs.setConfig is not a function` publisher error. DOMContentLoaded and article -geometry must establish that parsing continued after failure. Enforced-CSP cases -also require zero CSP violations. No late recovery or retry is claimed. - -Completion uses `scope=diagnostic-outages`, an exact four-case completeness gate, -`diagnostic.fullAcceptancePassed=false` and -`diagnostic.changedOutageBehaviorApproved=false`. Each sample records its policy, -expected availability and the explicit changed-outage-not-approval interpretation. -Both existing negative controls are `not-requested` with a reason. A successful -diagnostic means these observations matched the declared failure boundary, not -that the changed outage boundary is acceptable for adoption or issue closure. -The public full-acceptance verifier rejects both diagnostic scopes. - -Keep a separate output directory. Prior screen measurements remain evidence for -their frozen inputs and recorded tooling hashes; this diagnostic does not rerun, -replace or relabel those 80 samples. Full correctness, registration/framework, -controller and live gates remain outside this scope. Missing authorized Google -inventory stays blocked. Start with zero delay; a separately recorded held-response -run can be requested later if needed. - -## Full-mode observations and functional gates - -The following describes the retained full modes. Diagnostic scopes run only the -cases and observations declared in their sections above. - -- Pre-navigation buffered `PerformanceObserver` entries capture FCP, every LCP - candidate, resources and raw layout shifts with source geometry. -- CLS is the largest session-window sum, excluding recent input, with one-second - gaps and a five-second maximum window. Unit tests cover these boundaries. -- Navigation timing retains DOM interactive and DOMContentLoaded. Native script - load events retain parser/article state. Missing required observers, paint - metrics, runtime load evidence or declared fixture cases fail the run. -- Except in `--regressions-only`, two negative controls derive from the reserved - changed document. Removing only the runtime's `defer` attribute must reproduce - parser blocking through native load/parser observations and FCP/resource timing. - Removing only the production reservation styles must produce article movement - and positive observer-reported CLS with a source inside the article. These are - separate correctness samples, never mixed into the four measurement arms. Their - exact HTML and hashes are retained. These controls execute only when the browser - runner runs; the focused Node tests alone do not establish browser behavior. -- The transport fixture records exact native requests and request-time targeting. - It installs before production bootstrap and does not deduplicate TS requests. -- Real Universal Creative renders the server bid in an iframe. Assertions inspect - actual nested creative DOM, not just a render event. Original-main baseline and - CSS-only reservation arms predate the new renderer protocol marker; they must - still pass actual Server creative, candidate-targeting and one-request checks. - Candidate arms additionally require renderer protocol version 1. First-visible time is - named `firstVisibleEstimateMs`: it combines creative-ready, visible frame - bounds and an animation-frame barrier, not hardware paint or a GAM impression. -- Compatible reservation must preserve article geometry and zero controlled - ad-induced CLS. No-fill retains space. Boundary widths 1023/1024 exercise the - mobile-first rule. Oversize and unknown-height creatives do not get a promise - of zero shift. -- Slow, failed, absent and late-retried runtime cases cover both automatic and - publisher-registered slots. Registration starts with explicitly disabled GPT - initial loading and checks the publisher's exact native object and targeting. - The early fallback and takeover must keep one initial request. The absent-script case is another explicit - hashed document intervention. Late retry uses a normal DOM script element - after the parser-created runtime tag has failed and the fallback has rendered. -- True renderer absence removes the compiled early tag, blocks standalone GPT - and the original unified route, and retains the raw protocol bootstrap. - Its fixture-only direct-core probe is removed because core is intentionally - unavailable. Registered delivery must use ordinary publisher fill without - candidate targeting, while retaining the admitted candidate data. This is - separate from a failed deferred runtime, which must keep the real early bridge - and candidate delivery. -- Inline/shared payloads, direct early core APIs, publisher queues, missing - wrappers, nonmatching paths and replacement of the inner ad element are hard - functional checks. The child-replacement fixture is not a React hydration - proof; the existing real Next.js publisher-registration gate covers that. +Diagnostic outcomes keep their distinct scope, exact expected sample count, +`fullAcceptancePassed=false` and both controls `not-requested` with reasons. +New outage diagnostic records acknowledge the approved architecture through +`changedOutageBehaviorApproved=true`. This does not promote a limited diagnostic +to full acceptance. The public full verifier rejects both diagnostic scopes. +Framework, full controls, publisher lifecycle and real-GPT acceptance remain +outside those scopes. + +Historical measurements and outage records remain unchanged evidence for their +frozen inputs and tooling hashes, including their then-unapproved interpretation. +Do not relabel or overwrite those directories. New architecture approval does not +retroactively establish missing full acceptance. Missing authorized Google +inventory remains blocked, and staging/deployment/live activation require +separate authorization. ## Evidence -Export input filenames remain `manifest.json`, the declared HTML/assets and -`app.toml`. No new export format is required. - -`acceptance.json` is the machine-readable completion record, also mirrored as -`artifacts.json.outcome`. Its `schemaVersion` is 1. `status` is `running`, `passed`, -`failed` for an assertion/runtime failure, or `blocked` for a missing export, -dependency or Chromium executable. `passed` means the selected scope completed, -including report writes and browser/server cleanup. The scope is -`measurement-and-correctness`, `correctness`, `regressions`, or the limited -`diagnostic-screen` and `diagnostic-outages` scopes described above. It does not certify -any live publisher or real-GPT behavior. `conditions`, `samples`, `profileSamples`, -timestamps and an error when unsuccessful describe the run. - -`negativeControls` has `parser-blocking` and `reservation-removed` entries. Each -entry has a `status` of `pending`, `running`, `passed`, `failed`, `blocked` or -`not-requested`; completed controls include `sampleIndex` and `htmlFile`. A -regressions-only run records `not-requested` and the reason explicitly. Validate -both the overall status and the scope/control statuses when consuming evidence. -The runner initializes these outcomes before reading exports, so a failed rerun -cannot retain an earlier passed outcome. Malformed CLI arguments fail before -an output directory is established and do not produce a completion record. - -`artifacts.json` retains exact inputs, provenance, browser/Node versions, runner -and helper hashes, normalized conditions, cache semantics and interventions. -`samples.json` retains all raw observations and per-sample geometry, native events, -CDP requests/cache-hit flags, server responses with applied delay, network calls -and separate warmup evidence. Each sample records the selected conditions and any -negative control. `summary.json` reports -sample count, median, minimum and maximum for each metric, with repeated -measurement samples kept separate from functional cases. `comparisons.json` -reports signed median deltas and percentages where the baseline is nonzero. -Raw/gzip/Brotli sizes cover HTML, individual inline tags, all inline JS and the -remaining fetched runtime. Screenshots and traces include every sample; failures -also retain observer/native state, server responses and available cache evidence. -Use a different `--out` directory for each condition set. The runner records one -selected condition set per invocation, not an implicit device/network matrix. - -A fast first paint can coexist with later ad startup because the retained -bootstrap scheduler waits for page load. Report both, including regressions. -The earlier full-mode exports retain the original inline GPT bootstrap. The -synchronous candidate omits it and has the separately documented critical-script -outage boundary. Neither diagnostic scope approves that change or proves full -acceptance. Real-GPT staging and live publisher activation require separate approval. +Buffered `PerformanceObserver` entries capture FCP, all LCP candidates, resources +and raw layout shifts with source geometry. CLS uses the largest session-window +sum, excludes recent input, and has one-second gaps and a five-second maximum. +Missing observers, paint, native load/parser evidence or required cases fail. +First-visible time is `firstVisibleEstimateMs`, a creative-ready, frame-bounds and +animation-frame estimate, not hardware paint or a GAM impression. Universal +Creative renders the fictional server bid; assertions inspect actual nested DOM. + +`acceptance.json` is mirrored as `artifacts.json.outcome`, with schema version 1. +Status is `running`, `passed`, `failed`, or `blocked` for a missing export, +dependency or Chromium executable. Passing means only the selected scope +completed, including report writes and cleanup. It never certifies live behavior. +Scope, conditions, exact sample/profile counts, timestamps and errors are retained. +`negativeControls` contains `runtime-deferred` and `reservation-removed`, with +status, sample index and derivative HTML filename. Consumers must validate overall +status, scope, counts and both control statuses. Outcomes initialize before +reading exports, so a failed rerun cannot retain a stale pass. Malformed arguments +fail before output initialization. + +`artifacts.json` retains input provenance, source snapshots, browser/Node versions, +runner/helper hashes, conditions, cache semantics and interventions. `samples.json` +retains raw observers, geometry, native traces, CDP cache evidence, server responses +and applied delays, headers, network calls and separate warmup evidence. Recovery +samples also retain their pre-recovery state. `summary.json` reports count, median, +minimum and maximum separately for measurement and correctness cases. +`comparisons.json` reports signed median deltas and percentages where baseline is +nonzero. Raw/gzip/Brotli sizes cover HTML, individual/all inline scripts and fetched +runtime. Every sample retains a screenshot and trace; failures retain available +observer/native/network state. Use a different output directory per condition set. diff --git a/crates/trusted-server-integration-tests/browser/cwv/contract.cjs b/crates/trusted-server-integration-tests/browser/cwv/contract.cjs new file mode 100644 index 0000000000..2003868dfe --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/contract.cjs @@ -0,0 +1,152 @@ +const assert = require("node:assert/strict"); +const { + assertRuntimeOrdering, + assertCspRuntimeApis, +} = require("./evidence.cjs"); +const { assertCriticalOutage } = require("./diagnostic.cjs"); + +function correctnessCases(regressionsOnly = false) { + const mobile = { width: 390, height: 844 }; + const cases = []; + const add = (name, mode = "slow", extra = {}) => + cases.push({ profile: "combined", name, viewport: mobile, mode, ...extra }); + add("registered", "renderer-absent"); + add("registered", "failed"); + add("csp"); + add("csp", "failed"); + if (regressionsOnly) return cases; + add("inline", "slow", { control: "runtime-deferred" }); + add("inline", "slow", { control: "reservation-removed" }); + for (const mode of ["slow", "failed", "absent", "late-runtime"]) { + add("inline", mode); + add("registered", mode); + } + for (const name of [ + "shared", + "absent-wrapper", + "unmatched", + "child-replacement", + "unknown-height", + ]) + add(name); + add("shared", "slow", { viewport: { width: 1280, height: 1000 } }); + add("unknown-height", "slow", { profile: "loading" }); + for (const width of [1023, 1024]) + add("tall", "slow", { viewport: { width, height: 1000 } }); + add("inline", "slow", { noFill: true }); + return cases; +} + +function assertCorrectnessSamples(samples, regressionsOnly = false) { + const shape = ({ + profile, + name, + viewport, + mode, + noFill = false, + control = null, + }) => ({ + profile, + name, + viewport, + mode, + noFill, + control, + }); + assert.deepEqual( + samples.filter((entry) => entry.stage === "correctness").map(shape), + correctnessCases(regressionsOnly).map(shape), + "Every declared correctness case must execute exactly once in order", + ); +} + +function assertCspDocument(input) { + assert.equal( + input.enforcedCsp, + input.name === "csp", + "Candidate CSP case must enforce a real response policy; other cases must remain normal", + ); +} + +function unavailableErrors(registered) { + return [ + "tsjs.setConfig is not a function", + ...(registered ? ["tsjs.registerPublisherSlot is not a function"] : []), + ]; +} + +function assertUnavailableRuntime(raw, responses, runtimeUrl, mode) { + assertCspRuntimeApis(raw, false); + assertCriticalOutage(raw, responses, runtimeUrl, mode); + assert.equal( + raw.candidateAdId, + "fixture-server-ad", + "Unavailable renderer must not erase the admitted candidate data", + ); + assert.deepEqual( + raw.fixture.creatives, + [], + "No creative may render without the critical runtime", + ); + assert.deepEqual( + raw.fixture.trace.filter((entry) => + ["nativeDisplay", "nativeRefresh", "nativeRequest"].includes(entry.event), + ), + [], + "Unavailable runtime must not display, refresh or request a TS slot", + ); +} + +function assertDeferredRegression(raw, runtimeUrl, fcp, responseEnd, settings) { + assert.equal( + raw.early.core, + false, + "Deferring the sole runtime must break the direct early core call", + ); + assert.equal( + raw.early.queued, + false, + "Failed early publisher script must not enqueue its callback", + ); + assertRuntimeOrdering(raw, runtimeUrl, true); + assert.throws( + () => assertRuntimeOrdering(raw, runtimeUrl, false), + /before the parser reaches the article/, + "The production synchronous ordering gate must reject this actual regression", + ); + assertCspRuntimeApis(raw, true); + assert.deepEqual( + raw.fixture.requests, + [], + "Deferred runtime must reproduce the missed initial scheduling call", + ); + assert.deepEqual( + raw.fixture.creatives, + [], + "Deferred startup regression must not invent a creative", + ); + assert.equal( + raw.delivered, + false, + "Deferred startup regression must not report delivery", + ); + assert.equal( + raw.firstVisibleEstimateMs, + null, + "Deferred startup regression must not report a visible ad", + ); + if (settings.cache === "cold" && settings.runtimeDelayMs === 750) + assert( + fcp < responseEnd, + "Deferred regression must permit content paint during the 750ms runtime hold", + ); +} + +module.exports = { + correctnessCases, + assertCorrectnessSamples, + unavailableErrors, + assertCspDocument, + assertUnavailableRuntime, + assertDeferredRegression, +}; diff --git a/crates/trusted-server-integration-tests/browser/cwv/contract.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/contract.test.cjs new file mode 100644 index 0000000000..91053f1f7a --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/contract.test.cjs @@ -0,0 +1,201 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + correctnessCases, + assertCorrectnessSamples, + unavailableErrors, + assertCspDocument, + assertUnavailableRuntime, + assertDeferredRegression, +} = require("./contract.cjs"); +const runtimeUrl = "http://127.0.0.1/static/tsjs=tsjs-unified.min.js?v=example"; +const methods = [ + "setConfig", + "getConfig", + "requestAds", + "addAdUnits", + "renderAdUnit", + "renderAllAdUnits", + "adInit", + "scheduleInitialAdInit", + "registerPublisherSlot", +]; +const apis = (available) => ({ + methods: Object.fromEntries( + methods.map((name) => [name, available ? "function" : "undefined"]), + ), + shim: available ? "function" : "undefined", + registrationVersion: available ? 1 : null, + rendererVersion: available ? 1 : null, +}); + +for (const regressionsOnly of [false, true]) + test(`full correctness case completeness rejects missing, duplicate and reordered cases, regressionsOnly=${regressionsOnly}`, () => { + const cases = correctnessCases(regressionsOnly); + assert.equal(cases.length, regressionsOnly ? 4 : 24); + const samples = cases.map((entry) => ({ ...entry, stage: "correctness" })); + assertCorrectnessSamples(samples, regressionsOnly); + for (const changed of [ + [], + samples.slice(1), + [...samples, samples[0]], + [...samples].reverse(), + ]) + assert.throws(() => assertCorrectnessSamples(changed, regressionsOnly)); + assert.throws(() => + assertCorrectnessSamples( + samples.map((entry) => ({ ...entry, stage: "measurement" })), + regressionsOnly, + ), + ); + if (!regressionsOnly) { + assert.deepEqual( + cases.filter((entry) => entry.control).map((entry) => entry.control), + ["runtime-deferred", "reservation-removed"], + ); + for (const name of ["inline", "registered"]) + for (const mode of ["failed", "absent", "late-runtime"]) + assert( + cases.some((entry) => entry.name === name && entry.mode === mode), + ); + for (const width of [1023, 1024]) + assert( + cases.some( + (entry) => entry.name === "tall" && entry.viewport.width === width, + ), + ); + } + }); + +test("CSP cases must actually enforce policy, including full scopes rather than only diagnostics", () => { + assertCspDocument({ name: "csp", enforcedCsp: true }); + assertCspDocument({ name: "inline", enforcedCsp: false }); + for (const changed of [ + { name: "csp", enforcedCsp: false }, + { name: "csp" }, + { name: "inline", enforcedCsp: true }, + { name: "registered" }, + ]) + assert.throws(() => assertCspDocument(changed)); +}); + +test("deferred negative control must reproduce the broken early API and parser contract, not just carry a control label", () => { + const raw = () => ({ + early: { core: false, queued: false }, + scriptLoads: [{ src: runtimeUrl, articlePresent: true }], + apis: apis(true), + fixture: { requests: [], creatives: [] }, + delivered: false, + firstVisibleEstimateMs: null, + }); + for (const settings of [ + { cache: "cold", runtimeDelayMs: 0 }, + { cache: "warm", runtimeDelayMs: 750 }, + { cache: "cold", runtimeDelayMs: 750 }, + ]) + assertDeferredRegression(raw(), runtimeUrl, 100, 800, settings); + for (const change of [ + (raw) => { + raw.early.core = true; + }, + (raw) => { + raw.early.queued = true; + }, + (raw) => { + raw.scriptLoads[0].articlePresent = false; + }, + (raw) => { + raw.scriptLoads = []; + }, + (raw) => { + raw.apis.methods.registerPublisherSlot = "undefined"; + }, + (raw) => raw.fixture.requests.push({}), + (raw) => raw.fixture.creatives.push({}), + (raw) => { + raw.delivered = true; + }, + (raw) => { + raw.firstVisibleEstimateMs = 1; + }, + ]) { + const changed = raw(); + change(changed); + assert.throws(() => + assertDeferredRegression(changed, runtimeUrl, 100, 800, { + cache: "cold", + runtimeDelayMs: 0, + }), + ); + } + assert.throws(() => + assertDeferredRegression(raw(), runtimeUrl, 900, 800, { + cache: "cold", + runtimeDelayMs: 750, + }), + ); +}); + +test("failed, absent and renderer-absent sole runtimes require no APIs, queue drain, requests or creatives", () => { + const raw = () => ({ + early: { core: false, queued: false }, + apis: apis(false), + candidateAdId: "fixture-server-ad", + preCoreQueue: { callbacks: [], runtimeLoads: [] }, + scriptLoads: [], + navigation: [{ domContentLoadedEventEnd: 1 }], + geometry: [{ article: { top: 1 } }], + fixture: { requests: [], trace: [], creatives: [] }, + delivered: false, + firstVisibleEstimateMs: null, + }); + for (const mode of ["failed", "absent", "renderer-absent", "late-runtime"]) { + const responses = + mode === "absent" ? [] : [{ url: runtimeUrl, status: 404 }]; + assertUnavailableRuntime(raw(), responses, runtimeUrl, mode); + for (const change of [ + (raw) => { + raw.apis.methods.setConfig = "function"; + }, + (raw) => { + raw.apis.rendererVersion = 1; + }, + (raw) => raw.preCoreQueue.callbacks.push({}), + (raw) => raw.preCoreQueue.runtimeLoads.push({}), + (raw) => raw.fixture.requests.push({}), + (raw) => raw.fixture.creatives.push({ label: "Server result" }), + (raw) => raw.fixture.trace.push({ event: "nativeDisplay" }), + (raw) => raw.fixture.trace.push({ event: "nativeRefresh" }), + (raw) => { + raw.delivered = true; + }, + (raw) => { + raw.firstVisibleEstimateMs = 1; + }, + (raw) => { + raw.candidateAdId = undefined; + }, + ]) { + const changed = raw(); + change(changed); + assert.throws(() => + assertUnavailableRuntime(changed, responses, runtimeUrl, mode), + ); + } + assert.throws(() => + assertUnavailableRuntime( + raw(), + [...responses, { url: runtimeUrl, status: 200 }], + runtimeUrl, + mode, + ), + ); + } + assert.deepEqual(unavailableErrors(false), [ + "tsjs.setConfig is not a function", + ]); + assert.deepEqual(unavailableErrors(true), [ + "tsjs.setConfig is not a function", + "tsjs.registerPublisherSlot is not a function", + ]); +}); diff --git a/crates/trusted-server-integration-tests/browser/cwv/diagnostic.cjs b/crates/trusted-server-integration-tests/browser/cwv/diagnostic.cjs index f17b075b62..fb1ad456b7 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/diagnostic.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/diagnostic.cjs @@ -92,14 +92,19 @@ function installEarlyProbe() { document.addEventListener( "load", (event) => { - if (event.target?.id !== "trustedserver-js") return; + if ( + !["trustedserver-js", "trustedserver-js-recovery"].includes( + event.target?.id, + ) + ) + return; state.runtimeLoads.push({ time: performance.now(), apis: methods() }); }, true, ); } -function assertEarlyProbe(raw, profile) { +function assertEarlyProbe(raw, profile, articlePresent = false) { const probe = raw.preCoreQueue; assert.equal( probe?.callbacks.length, @@ -109,8 +114,10 @@ function assertEarlyProbe(raw, profile) { const callback = probe.callbacks[0]; assert.equal( callback.articlePresent, - false, - "Queue must drain before body parsing", + articlePresent, + articlePresent + ? "Deferred control or explicit late recovery must drain the queue after article parsing" + : "Queue must drain before body parsing", ); assert.equal( callback.coreCallSucceeded, @@ -207,7 +214,7 @@ function assertOutageSamples(samples) { ); } -function assertCriticalOutage(raw, responses, runtimeUrl) { +function assertCriticalOutage(raw, responses, runtimeUrl, mode = "failed") { assert.deepEqual( raw.preCoreQueue.callbacks, [], @@ -227,8 +234,10 @@ function assertCriticalOutage(raw, responses, runtimeUrl) { responses .filter((entry) => entry.url === runtimeUrl) .map((entry) => entry.status), - [404], - "Observe exactly one failed critical runtime response, not a cached runtime or retry", + mode === "absent" ? [] : [404], + mode === "absent" + ? "Absent runtime tag must issue no runtime request" + : "Observe exactly one failed critical runtime response, not a cached runtime or retry", ); assert( raw.navigation[0].domContentLoadedEventEnd > 0, diff --git a/crates/trusted-server-integration-tests/browser/cwv/diagnostic.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/diagnostic.test.cjs index e637f30314..54a2e8ee27 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/diagnostic.test.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/diagnostic.test.cjs @@ -59,13 +59,16 @@ test("original-main allows its actual raw bootstrap instead of using candidate H assert.throws(() => assertScreenDocument(legacy, "loading")); }); -function probeFixture() { +function probeFixture({ + articlePresent = false, + runtimeId = "trustedserver-js", +} = {}) { const listeners = {}; const context = vm.createContext({ window: { cwv: {} }, performance: { now: () => 1 }, document: { - getElementById: () => null, + getElementById: () => (articlePresent ? {} : null), addEventListener: (name, callback) => { listeners[name] = callback; }, @@ -93,7 +96,7 @@ function probeFixture() { window.__tsjs_installGptShim = () => {}; window.tsjs.publisherSlotRegistrationVersion = 1; window.tsjs.tsRenderBridgeVersion = 1; - listeners.load({ target: { id: "trustedserver-js" } }); + listeners.load({ target: { id: runtimeId } }); return JSON.parse(JSON.stringify(window.cwv)); } @@ -111,6 +114,15 @@ test("pre-core queue drain observes core methods without assuming GPT has initia assertEarlyProbe(raw, "baseline"); }); +test("explicit dynamic recovery captures native load and late queue drain without satisfying normal early ordering", () => { + const raw = probeFixture({ + articlePresent: true, + runtimeId: "trustedserver-js-recovery", + }); + assertEarlyProbe(raw, "combined", true); + assert.throws(() => assertEarlyProbe(raw, "combined"), /before body parsing/); +}); + test("early API evidence fails closed for dropped/duplicate queue callbacks and missing GPT APIs", () => { for (const change of [ (raw) => { @@ -296,7 +308,7 @@ for (const scope of ["diagnostic-screen", "diagnostic-outages"]) scope === "diagnostic-screen" ? 40 : 4, ); if (scope === "diagnostic-outages") - assert.equal(outcome.diagnostic.changedOutageBehaviorApproved, false); + assert.equal(outcome.diagnostic.changedOutageBehaviorApproved, true); assert.match(outcome.error.message, /ENOENT/); for (const control of Object.values(outcome.negativeControls)) { assert.equal(control.status, "not-requested"); @@ -304,7 +316,7 @@ for (const scope of ["diagnostic-screen", "diagnostic-outages"]) control.reason, scope === "diagnostic-screen" ? /outside this screen/ - : /observed, not approved/, + : /Full acceptance remains unexecuted/, ); } assert.deepEqual( diff --git a/crates/trusted-server-integration-tests/browser/cwv/evidence.cjs b/crates/trusted-server-integration-tests/browser/cwv/evidence.cjs index 186775f1e1..9b4eecb134 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/evidence.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/evidence.cjs @@ -60,7 +60,7 @@ function assertCandidateRendererVersion(raw, profile) { assert.equal( raw.rendererVersion, 1, - "Candidate renderer must remain installed when only deferred runtime fails", + "Available candidate unified runtime must install the real renderer", ); } diff --git a/crates/trusted-server-integration-tests/browser/cwv/evidence.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/evidence.test.cjs index 6cb23e8f64..39c792ee90 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/evidence.test.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/evidence.test.cjs @@ -128,6 +128,55 @@ test("CSP API proof distinguishes working compiled runtime from unavailable no-f } }); +test("missing DOM dependency invalidates a stale pass before loading contract helpers", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cwv-dependency-")); + try { + fs.writeFileSync( + path.join(root, "acceptance.json"), + JSON.stringify({ status: "passed" }), + ); + const result = spawnSync( + process.execPath, + [ + "-e", + `const Module = require("node:module"); + const load = Module._load; + Module._load = function(name, ...args) { + if (name === "jsdom") { + const error = new Error("Deliberately missing jsdom"); + error.code = "MODULE_NOT_FOUND"; + throw error; + } + return load.call(this, name, ...args); + }; + require(process.argv[1]);`, + path.join(__dirname, "run.cjs"), + "--loading", + root, + "--combined", + root, + "--out", + root, + "--correctness-only", + ], + { encoding: "utf8" }, + ); + assert.equal(result.status, 1, result.stderr); + const outcome = JSON.parse( + fs.readFileSync(path.join(root, "acceptance.json")), + ); + assert.equal(outcome.status, "blocked"); + assert.equal(outcome.samples, 0); + assert.match(outcome.error.message, /Deliberately missing jsdom/); + assert.deepEqual( + JSON.parse(fs.readFileSync(path.join(root, "artifacts.json"))).outcome, + outcome, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + for (const status of ["blocked", "failed"]) test(`setup ${status} replaces a previous successful outcome without launching a browser`, () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "cwv-outcome-")); @@ -177,7 +226,7 @@ for (const status of ["blocked", "failed"]) assert(outcome.finishedAt); assert.equal(outcome.samples, 0); assert.equal( - outcome.negativeControls["parser-blocking"].status, + outcome.negativeControls["runtime-deferred"].status, "pending", ); } finally { diff --git a/crates/trusted-server-integration-tests/browser/cwv/html.cjs b/crates/trusted-server-integration-tests/browser/cwv/html.cjs index e3f5750b6f..809b8e933e 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/html.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/html.cjs @@ -57,14 +57,13 @@ function withoutEarlyRenderer(html) { ); } -function withoutRuntimeDefer(html) { +function withRuntimeDefer(html) { const dom = new JSDOM(html, { includeNodeLocations: true }); try { const script = dom.window.document.querySelector("script#trustedserver-js"); - const location = script && dom.nodeLocation(script)?.attrs?.defer; - return location - ? html.slice(0, location.startOffset) + html.slice(location.endOffset) - : html; + if (!script || script.hasAttribute("defer")) return html; + const offset = dom.nodeLocation(script).startOffset + "body{margin:0}"), []); }); -test("parser-blocking control removes only the actual runtime defer attribute", () => { +test("deferred regression control adds only the actual runtime defer attribute", () => { const prefix = - '\r\n'; + '\r\n'; const suffix = '
Keep bytes
'; - for (const attribute of ["defer", 'DEFER=""', "defer='defer'"]) { - const tag = ``; + for (const tag of [ + '', + '', + ]) { assert.equal( - withoutRuntimeDefer(prefix + tag + suffix), - prefix + tag.replace(` ${attribute} src=`, " src=") + suffix, + withRuntimeDefer(prefix + tag + suffix), + prefix + tag.slice(0, 7) + " defer" + tag.slice(7) + suffix, ); } - assert.equal(withoutRuntimeDefer("

No runtime

"), "

No runtime

"); + for (const attribute of ["defer", 'DEFER=""', "defer='defer'"]) { + const html = ``; + assert.equal(withRuntimeDefer(html), html); + } + assert.equal(withRuntimeDefer("

No runtime

"), "

No runtime

"); }); diff --git a/crates/trusted-server-integration-tests/browser/cwv/run.cjs b/crates/trusted-server-integration-tests/browser/cwv/run.cjs index aa2cd84476..3b632cb07a 100644 --- a/crates/trusted-server-integration-tests/browser/cwv/run.cjs +++ b/crates/trusted-server-integration-tests/browser/cwv/run.cjs @@ -42,7 +42,7 @@ const acceptance = { samples: 0, profileSamples: {}, negativeControls: Object.fromEntries( - ["parser-blocking", "reservation-removed"].map((id) => [ + ["runtime-deferred", "reservation-removed"].map((id) => [ id, { status: @@ -52,9 +52,9 @@ const acceptance = { ...(unifiedDiagnostic || options["regressions-only"] ? { reason: diagnosticScreen - ? "Diagnostic synchronous-unified measurement only; deferred parser control is inapplicable and reservation-removal correctness is outside this screen. Full acceptance remains unexecuted." + ? "Diagnostic synchronous-unified measurement only; deferred-runtime and reservation-removal controls are outside this screen. Full acceptance remains unexecuted." : diagnosticOutages - ? "Four critical-runtime availability diagnostics only; parser-defer and reservation-removal controls are not requested. Changed outage behavior is observed, not approved." + ? "Four critical-runtime availability diagnostics only; deferred-runtime and reservation-removal controls are not requested. Full acceptance remains unexecuted." : "--regressions-only selects the four review regressions, not the negative controls", } : {}), @@ -99,8 +99,15 @@ const { assertScreenSamples, outageCases, assertOutageSamples, - assertCriticalOutage, } = require("./diagnostic.cjs"); +const { + correctnessCases, + assertCorrectnessSamples, + unavailableErrors, + assertCspDocument, + assertUnavailableRuntime, + assertDeferredRegression, +} = require("./contract.cjs"); if (diagnosticScreen) { acceptance.diagnostic = { expectedArchitecture, @@ -116,16 +123,15 @@ if (diagnosticOutages) { expectedArchitecture, expectedSamples: 4, fullAcceptancePassed: false, - changedOutageBehaviorApproved: false, + changedOutageBehaviorApproved: true, excluded: - "Observes successful and failed critical unified loading with and without enforced CSP. Loss of inline fallback is a changed outage boundary, not compatibility approval. No retry/recovery, full correctness, controller, framework or live acceptance is established. Missing authorized Google inventory remains blocked outside this scope.", + "Checks the approved critical unified availability boundary with and without enforced CSP. This limited diagnostic does not establish retry/recovery, full correctness, controller, framework or live acceptance. Missing authorized Google inventory remains blocked outside this scope.", }; saveOutcome(); } const { reservationStyles, - withoutEarlyRenderer, - withoutRuntimeDefer, + withRuntimeDefer, withoutScripts, rendererAsset, } = require("./html.cjs"); @@ -185,11 +191,11 @@ function snapshot(directory, label) { const loading = snapshot(options.loading, "loading"); const combined = snapshot(options.combined, "combined"); const baseline = options.baseline && snapshot(options.baseline, "baseline"); -if (unifiedDiagnostic) +if (baseline) assert.equal( baseline.manifest.sourceRevision, baselineRevision, - "Diagnostic baseline must remain pinned original-main, not the failing optimized PR snapshot", + "Baseline must remain pinned original-main, not the failing optimized PR snapshot", ); const profiles = { loading, @@ -229,6 +235,7 @@ evidence = { optionsSha256: sha256(fs.readFileSync(path.join(__dirname, "options.cjs"))), evidenceSha256: sha256(fs.readFileSync(path.join(__dirname, "evidence.cjs"))), proxySha256: sha256(fs.readFileSync(path.join(__dirname, "proxy.cjs"))), + contractSha256: sha256(fs.readFileSync(path.join(__dirname, "contract.cjs"))), diagnosticSha256: sha256( fs.readFileSync(path.join(__dirname, "diagnostic.cjs")), ), @@ -455,28 +462,24 @@ async function sample( saveOutcome(); } const input = documentInput(profile, name); - if (diagnosticOutages) - assert.equal( - input.enforcedCsp, - name === "csp", - "Outage matrix must separately exercise normal and enforced-CSP production documents", - ); - const runtimeUnavailable = - (input.enforcedCsp || diagnosticOutages) && mode === "failed"; - // Keep the existing publisher-side probe. Its core call deliberately fails - // when the synchronous runtime is unavailable; no other page error is allowed. - const expectedErrors = runtimeUnavailable - ? ["tsjs.setConfig is not a function"] - : []; - if (unifiedDiagnostic) assertScreenDocument(input.html, profile); - const expectedDeferred = unifiedDiagnostic - ? false - : !input.enforcedCsp && (profile === "loading" || profile === "combined"); - assert.equal( - input.deferred, - expectedDeferred, - `${profile} must exercise its intended production loading mode`, - ); + if (["loading", "combined"].includes(profile)) assertCspDocument(input); + const initiallyUnavailable = [ + "failed", + "absent", + "renderer-absent", + "late-runtime", + ].includes(mode); + const runtimeUnavailable = initiallyUnavailable && mode !== "late-runtime"; + const deliveryExpected = + input.matched && !runtimeUnavailable && control !== "runtime-deferred"; + // Preserve publisher code and declare its exact failures when the sole runtime + // is missing. The deferred negative control breaks only the early core probe. + const expectedErrors = initiallyUnavailable + ? unavailableErrors(input.registered) + : control === "runtime-deferred" + ? unavailableErrors(false) + : []; + assertScreenDocument(input.html, profile); const index = samples.length; const label = `${index}-${profile}-${name}-${viewport.width}-${mode}${noFill ? "-nofill" : ""}${control ? "-" + control : ""}`; active = { @@ -490,12 +493,12 @@ async function sample( phase: options.cache === "warm" ? "warmup" : "measurement", html: input.html, }; - if (control === "parser-blocking") { - active.html = withoutRuntimeDefer(input.html); + if (control === "runtime-deferred") { + active.html = withRuntimeDefer(input.html); assert.notEqual( active.html, input.html, - "Parser negative control must remove defer", + "Deferred regression control must add defer to the actual synchronous runtime", ); } if (control === "reservation-removed") { @@ -553,7 +556,7 @@ async function sample( }); assert( !input.html.includes('id="trustedserver-bootstrap"'), - "Enforced CSP must use synchronous production fallback", + "Enforced CSP must use the synchronous production runtime without inline fallback", ); assert( !input.html.includes("// Edge-injected GPT auction bootstrap."), @@ -564,29 +567,20 @@ async function sample( index, profile, name, - type: "enforced-CSP synchronous runtime failure; no provisional fallback", + type: "enforced-CSP critical runtime failure; no inline fallback", expectedErrors, }); } if (mode === "renderer-absent") { - active.html = withoutEarlyRenderer(input.html); - assert.notEqual( - active.html, - input.html, - "Renderer absence must remove the real compiled early entry", - ); - // This fixture-only probe calls core APIs deliberately absent in this case. - active.html = withoutScripts(active.html, (script) => - script.content.trimStart().startsWith("window.fixtureEarly="), - ); assert( - active.html.includes("publisherSlotRegistrationVersion"), - "Raw protocol bootstrap must remain", + rendererAsset( + new URL(input.runtimeSrc, "http://publisher.example.com").pathname, + ), ); evidence.interventions.push({ profile, name, - type: "true renderer absence, early entry and core probe removed; GPT/unified requests blocked", + type: "true renderer absence; sole unified runtime blocked, no inline renderer or publisher-code removal", htmlSha256: sha256(active.html), }); } @@ -615,6 +609,7 @@ async function sample( let result; let page; let warmup = null; + const recovery = []; const requests = new Map(); const errors = []; const network = []; @@ -629,7 +624,7 @@ async function sample( await context.addInitScript({ content: `if(window===window.top){ (${observePage.toString()})(); - ${unifiedDiagnostic ? `(${installEarlyProbe.toString()})();` : ""} + (${installEarlyProbe.toString()})(); window.publisherFixtureOptions={initialLoad:${JSON.stringify(input.registered ? "disabled" : "enabled")}}; ${fixtureSource.replaceAll("https://creative.example.com", creativeOrigin)} (${installDelivery.toString()})(${JSON.stringify({ height: input.creativeHeight, noFill })}); @@ -679,7 +674,7 @@ async function sample( }); if (options.cache === "warm") { await navigate(); - if (input.matched && !runtimeUnavailable) + if (deliveryExpected && !initiallyUnavailable) await page.waitForFunction(() => cwv.delivered, null, { timeout: 10000, }); @@ -709,13 +704,31 @@ async function sample( active.phase = "measurement"; } await navigate(); - if (input.matched && !runtimeUnavailable) + if (deliveryExpected && !initiallyUnavailable) await page.waitForFunction(() => cwv.delivered, null, { timeout: 10000 }); if (mode === "late-runtime") { + const before = await page.evaluate(() => cwv.finish()); + assertUnavailableRuntime( + before, + active.responses, + new URL(input.runtimeSrc, publisherOrigin).href, + mode, + ); + assert.deepEqual( + errors, + expectedErrors, + "Before explicit recovery, only declared missing-runtime errors are allowed", + ); + recovery.push({ + phase: active.phase, + raw: before, + responses: [...active.responses], + }); await page.evaluate( (src) => new Promise((resolve, reject) => { const script = document.createElement("script"); + script.id = "trustedserver-js-recovery"; script.src = src; script.onload = resolve; script.onerror = reject; @@ -723,6 +736,31 @@ async function sample( }), input.runtimeSrc, ); + if (input.registered) + await page.evaluate(() => { + // Explicit publisher re-registration after recovery uses the native + // object already created before the critical runtime failed. + window.fixtureHandle = tsjs.registerPublisherSlot({ + slotId: "fixture-header", + gptSlot: window.fixturePublisherSlot, + navigationGeneration: tsjs.navGeneration || 0, + eligibleFormats: [[300, 250]], + }); + }); + // The original body-close scheduling call could not run during outage. + // Explicit publisher recovery must retry it, not pretend loading alone + // replays failed publisher scripts. + await page.evaluate(() => tsjs.scheduleInitialAdInit(tsjs.bids)); + evidence.interventions.push({ + index, + profile, + name, + type: "explicit runtime reload, publisher re-registration when applicable, and initial scheduling retry", + }); + if (input.matched) + await page.waitForFunction(() => cwv.delivered, null, { + timeout: 10000, + }); } // Multiple frames include fill-induced layout, then a declared quiet window. await page.evaluate( @@ -747,25 +785,26 @@ async function sample( expectedErrors, "Only declared fixture page errors are allowed", ); - if (input.enforcedCsp || diagnosticOutages) + if (["loading", "combined"].includes(profile)) assertCspRuntimeApis(raw, !runtimeUnavailable); - if (diagnosticOutages && runtimeUnavailable) { - assertCriticalOutage( + if (runtimeUnavailable) { + assertUnavailableRuntime( raw, active.responses, new URL(input.runtimeSrc, publisherOrigin).href, + mode, ); assert.equal( active.runtimeAttempts, - 1, - "Critical outage must issue one failed runtime request without retry", + mode === "absent" ? 0 : 1, + "Unavailable critical runtime must not retry implicitly", ); } if (input.enforcedCsp) assert.deepEqual( raw.cspViolations, [], - "Old-inline-hash/self CSP must permit synchronous fallback without violations", + "Old-inline-hash/self CSP must permit the synchronous runtime without violations", ); assert.deepEqual( active.unexpected, @@ -778,32 +817,62 @@ async function sample( undefined, "No actual render bridge may remain installed", ); - assert.equal( - raw.candidateAdId, - "fixture-server-ad", - "Absent renderer must not erase the admitted candidate", - ); assert.deepEqual( active.blockedRenderer, [new URL(input.runtimeSrc, publisherOrigin).pathname], - "Actual standalone GPT renderer must be blocked", + "Actual sole unified renderer must be blocked", ); } else if (!runtimeUnavailable) { - assert( - raw.early.core && raw.early.queued, - unifiedDiagnostic - ? "Direct core API and queued publisher callback must work at the first publisher script" - : "Direct core API and queued publisher callback must work before deferred runtime", - ); + if (control !== "runtime-deferred" && mode !== "late-runtime") + assert( + raw.early.core && raw.early.queued, + "Direct core API and queued publisher callback must work at the first publisher script", + ); + else if (mode === "late-runtime") { + assert.equal( + raw.early.core, + false, + "Recovery must not conceal the original unavailable core probe", + ); + assert.equal( + raw.early.queued, + false, + "Recovery must not invent the failed publisher callback", + ); + assert.deepEqual( + active.responses + .filter( + (entry) => + entry.url === new URL(input.runtimeSrc, publisherOrigin).href, + ) + .map((entry) => entry.status), + [404, 200], + "Recovery must follow one failure with one explicit successful request", + ); + assert.equal( + active.runtimeAttempts, + 2, + "Recovery must not implicitly retry again", + ); + assertRuntimeOrdering( + raw, + new URL(input.runtimeSrc, publisherOrigin).href, + true, + ); + } assertCandidateRendererVersion(raw, profile); - if (unifiedDiagnostic) assertEarlyProbe(raw, profile); + assertEarlyProbe( + raw, + profile, + control === "runtime-deferred" || mode === "late-runtime", + ); } assert.equal( raw.fixture.requests.length, - input.matched && !runtimeUnavailable ? 1 : 0, - "Exactly one native initial request requires a matched slot and available runtime or early bridge", + deliveryExpected ? 1 : 0, + "Exactly one native initial request requires a matched slot and available critical runtime", ); - if (input.matched && !runtimeUnavailable) { + if (deliveryExpected) { assert.equal( raw.fixture.trace.filter((entry) => entry.event === "nativeDefine") .length, @@ -852,7 +921,7 @@ async function sample( } assert.equal( raw.fixture.requests[0].targeting.hb_adid?.[0], - mode === "renderer-absent" ? undefined : "fixture-server-ad", + "fixture-server-ad", "Candidate targeting requires an actual renderer", ); if (!noFill) { @@ -864,7 +933,7 @@ async function sample( assertVisibleCreative( text, raw.firstVisibleEstimateMs, - mode === "renderer-absent" ? "Publisher result" : "Server result", + "Server result", ); } } @@ -882,25 +951,21 @@ async function sample( assertRuntimeOrdering( raw, new URL(input.runtimeSrc, publisherOrigin).href, - expectedDeferred && control !== "parser-blocking", + control === "runtime-deferred", ); - if (!expectedDeferred || control === "parser-blocking") + if (control === "runtime-deferred") + assertDeferredRegression( + raw, + new URL(input.runtimeSrc, publisherOrigin).href, + fcp, + responseEnd, + settings, + ); + else assert( fcp >= responseEnd, "Parser-blocking runtime must finish before FCP", ); - if ( - expectedDeferred && - control !== "parser-blocking" && - options.cache === "cold" && - settings.runtimeDelayMs === 750 - ) - assert( - fcp < responseEnd, - "Changed content must paint during the default 750ms runtime hold", - ); - // A deferred cache hit or 0ms response may legitimately finish before FCP. - // Native script-load/parser observations above are the ordering gate. } const cacheRequests = [...requests.values()]; const cacheHits = @@ -925,13 +990,11 @@ async function sample( expectedHeight, "Responsive reservation must match viewport", ); - if (runtimeUnavailable) { + if (!deliveryExpected) { assert.equal( cls, 0, - diagnosticOutages - ? "Unavailable critical runtime must not cause ad-induced layout shift" - : "Unavailable CSP runtime must not cause ad-induced layout shift", + "Unavailable runtime or deferred-startup regression must not produce ad-induced layout shift", ); } else if (noFill || input.creativeHeight <= expectedHeight) { assert.equal( @@ -963,6 +1026,7 @@ async function sample( index, control, runtimeUnavailableBoundary: runtimeUnavailable, + ...(recovery.length ? { recovery } : {}), ...(diagnosticOutages ? { diagnosticOutage: { @@ -970,9 +1034,9 @@ async function sample( ? "enforced-old-hash-self-csp" : "normal", expectedRuntimeAvailable: !runtimeUnavailable, - changedOutageBehaviorApproved: false, + changedOutageBehaviorApproved: true, interpretation: - "Observed critical-script availability boundary, not compatibility approval or recovery proof", + "Approved no-inline-fallback critical-script boundary; limited diagnostic, not full acceptance or recovery proof", }, } : {}), @@ -1111,14 +1175,9 @@ async function sample( "Unreserved diagnostic arms must reproduce ad-induced layout shift", ); } else if (!options["correctness-only"] && !options["regressions-only"]) { - for (const viewport of [mobile, desktop]) { - for (let run = 0; run < runs; run++) { - const order = ["baseline", "loading", "reservation", "combined"]; - if (run % 2) order.reverse(); - for (const profile of order) - await sample(browser, profile, "inline", viewport); - } - } + for (const { profile, name, viewport } of screenCases(runs)) + await sample(browser, profile, name, viewport); + assertScreenSamples(samples, runs); const shiftControls = samples.filter((entry) => ["baseline", "loading"].includes(entry.profile), ); @@ -1138,53 +1197,20 @@ async function sample( ); } } - await sample( - browser, - "combined", - "registered", - mobile, - "renderer-absent", + const correctnessStart = samples.length; + for (const { + profile, + name, + viewport, + mode, + noFill = false, + control = null, + } of correctnessCases(options["regressions-only"])) + await sample(browser, profile, name, viewport, mode, noFill, control); + assertCorrectnessSamples( + samples.slice(correctnessStart), + options["regressions-only"], ); - await sample(browser, "combined", "registered", mobile, "failed"); - await sample(browser, "combined", "csp", mobile); - await sample(browser, "combined", "csp", mobile, "failed"); - if (!options["regressions-only"]) { - await sample( - browser, - "combined", - "inline", - mobile, - "slow", - false, - "parser-blocking", - ); - await sample( - browser, - "combined", - "inline", - mobile, - "slow", - false, - "reservation-removed", - ); - for (const mode of ["slow", "failed", "absent", "late-runtime"]) { - await sample(browser, "combined", "inline", mobile, mode); - await sample(browser, "combined", "registered", mobile, mode); - } - for (const name of [ - "shared", - "absent-wrapper", - "unmatched", - "child-replacement", - "unknown-height", - ]) - await sample(browser, "combined", name, mobile); - await sample(browser, "combined", "shared", desktop); - await sample(browser, "loading", "unknown-height", mobile); - for (const width of [1023, 1024]) - await sample(browser, "combined", "tall", { width, height: 1000 }); - await sample(browser, "combined", "inline", mobile, "slow", true); - } } const groups = {}; for (const entry of samples) { @@ -1240,7 +1266,7 @@ async function sample( diagnosticScreen ? "Diagnostic screen completed; raw timings retained. Full correctness, negative controls, framework/live gates and combined acceptance remain unexecuted." : diagnosticOutages - ? "Four availability diagnostics completed. Changed critical-outage behavior is observed, not approved. Full acceptance remains unexecuted." + ? "Four availability diagnostics completed for the approved critical-runtime contract. Full acceptance remains unexecuted." : "CWV functional contracts passed; raw timings and ranges retained, no timing-improvement threshold applied.", ); } finally { diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/marker-visibility.cjs b/crates/trusted-server-integration-tests/browser/publisher-registration/marker-visibility.cjs new file mode 100644 index 0000000000..5189526e0b --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/marker-visibility.cjs @@ -0,0 +1,71 @@ +// Browser-only evidence helper. IntersectionObserver clips the marker itself +// through all ancestor browsing contexts, including cross-origin frames. +async function visiblyPainted(element) { + return element.evaluate(async (node) => { + if (!(node instanceof HTMLElement) || !node.isConnected || document.hidden) + return false; + const rect = node.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + for (let ancestor = node; ancestor; ancestor = ancestor.parentElement) { + const css = getComputedStyle(ancestor); + if ( + css.display === "none" || + css.visibility === "hidden" || + css.visibility === "collapse" || + Number(css.opacity) === 0 || + css.contentVisibility === "hidden" + ) + return false; + } + return new Promise((resolve) => { + const observer = new IntersectionObserver(([entry]) => { + observer.disconnect(); + resolve( + node.isConnected && + !document.hidden && + entry.isIntersecting && + entry.intersectionRect.width > 0 && + entry.intersectionRect.height > 0, + ); + }); + observer.observe(node); + }); + }); +} + +async function marker(page, text, id) { + for (const element of await page.locator(`#${id} iframe`).elementHandles()) { + const root = await element.contentFrame(); + if (!root) continue; + const frames = [root]; + for (let index = 0; index < frames.length; index++) { + const frame = frames[index]; + frames.push(...frame.childFrames()); + try { + let ancestorsVisible = true; + for ( + let ancestor = frame; + ancestor && ancestor !== page.mainFrame(); + ancestor = ancestor.parentFrame() + ) { + // Intersection alone does not reject transparent ancestor frames. + if (!(await visiblyPainted(await ancestor.frameElement()))) { + ancestorsVisible = false; + break; + } + } + if (!ancestorsVisible) continue; + for (const textElement of await frame + .getByText(text, { exact: false }) + .elementHandles()) { + if (await visiblyPainted(textElement)) return true; + } + } catch { + // Detached, hidden, collapsed and offscreen frames are not evidence. + } + } + } + return false; +} + +module.exports = { visiblyPainted, marker }; diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/marker-visibility.test.cjs b/crates/trusted-server-integration-tests/browser/publisher-registration/marker-visibility.test.cjs new file mode 100644 index 0000000000..1dc686ebfa --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/marker-visibility.test.cjs @@ -0,0 +1,126 @@ +const assert = require("node:assert/strict"); +const { createServer } = require("node:http"); +const { test } = require("node:test"); +const { chromium } = require("@playwright/test"); +const { marker, visiblyPainted } = require("./marker-visibility.cjs"); + +const text = "Visible fixture creative"; +const style = ``; + +test("marker visibility clips through nested same-origin and cross-origin loopback frames", async (t) => { + const origins = []; + const servers = []; + let browser; + try { + for (let index = 0; index < 2; index++) { + const server = createServer((request, response) => { + const url = new URL(request.url, origins[index]); + const y = Number(url.searchParams.get("y") || 200); + const cross = url.searchParams.get("cross") === "1"; + let body; + if (url.pathname === "/top") { + const outer = new URL("/outer", origins[cross ? 1 : 0]); + outer.search = url.search; + body = `
`; + } else if (url.pathname === "/outer") { + const inner = new URL("/inner", origins[0]); + inner.search = url.search; + body = ``; + } else if (url.pathname === "/inner") { + body = `
${text}
`; + } else { + response.writeHead(404).end(); + return; + } + response.writeHead(200, { "content-type": "text/html" }); + response.end(`${style}${body}`); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + origins.push(`http://127.0.0.1:${server.address().port}`); + } + browser = await chromium.launch(); + const page = await browser.newPage({ + viewport: { width: 900, height: 800 }, + }); + const external = []; + await page.route("**/*", (route) => { + if (origins.includes(new URL(route.request().url()).origin)) + return route.continue(); + external.push(route.request().url()); + return route.abort(); + }); + for (const cross of [false, true]) { + await t.test( + cross ? "cross-origin chain" : "same-origin chain", + async () => { + for (const [y, expected] of [ + [200, false], + [40, true], + [20, true], + ]) { + await page.goto(`${origins[0]}/top?cross=${Number(cross)}&y=${y}`); + const frame = page + .frames() + .find((entry) => new URL(entry.url()).pathname === "/inner"); + assert.ok(frame); + assert.equal( + new URL(frame.parentFrame().url()).origin, + origins[cross ? 1 : 0], + ); + const element = await frame.locator(".marker").elementHandle(); + // The old independent viewport checks accepted this exact clipped case. + const locallyVisible = await element.evaluate((node) => { + const rect = node.getBoundingClientRect(); + return rect.top < innerHeight && rect.bottom > 0; + }); + assert.equal(locallyVisible, true); + assert.equal( + await visiblyPainted(await frame.parentFrame().frameElement()), + true, + ); + assert.equal( + await visiblyPainted(element), + expected, + `marker y=${y}, cross=${cross}`, + ); + assert.equal(await marker(page, text, "placement"), expected); + } + const outer = page.locator("#placement > iframe"); + for (const css of [ + "opacity:0", + "visibility:hidden", + "display:none", + "content-visibility:hidden", + ]) { + await outer.evaluate( + (node, css) => (node.style.cssText = `top:750px;${css}`), + css, + ); + assert.equal(await marker(page, text, "placement"), false, css); + } + await outer.evaluate((node) => (node.style.cssText = "top:750px")); + assert.equal(await marker(page, text, "placement"), true); + await page + .locator("#placement") + .evaluate( + (node) => + (node.style.cssText = + "position:absolute;top:0;height:760px;width:300px;overflow:hidden"), + ); + assert.equal( + await marker(page, text, "placement"), + false, + "ancestor overflow clips marker but leaves part of frame visible", + ); + }, + ); + } + assert.deepEqual(external, []); + } finally { + await browser?.close(); + await Promise.all( + servers.map((server) => new Promise((resolve) => server.close(resolve))), + ); + } +}); diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.README.md b/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.README.md index 5f6490aab2..cf35e356ba 100644 --- a/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.README.md +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.README.md @@ -93,15 +93,15 @@ The current opt-in cases cover: - eligible first-request TS targeting and known creative, with unrelated native inventory untouched; - no-bid and incompatible eligibility ordinary fallback; -- failed and late deferred runtime with the actual early renderer retained, eligible TS delivery before takeover, and no repeated request after takeover; +- failed and late critical unified runtime under the accepted synchronous contract: no TS fallback or delivery after failure, and no readiness or delivery until a held request is released; - eligible TS-first delivery followed by a controlled actual-provider no-bid transition, fresh real Prebid/provider exchanges for both registered and unrelated ad units, candidate-targeting retirement, and an actual two-member SRA refresh; - React SSR-placeholder replacement before final registration; - cancellation before scheduled dispatch, then successful native destruction while the element is connected, with actual `getSlots()` absence before DOM removal; - Next.js navigation, teardown, and ordinary no-bid replacement. -The two deferred-runtime fault cases leave the actual early compiled entry, renderer, and script guard intact. They fail/hold only the deferred GPT runtime asset. The known TS creative and eligible targeting must render before a late runtime is released. The late case then waits for actual real-Prebid/refresh-wrapper initialization and rejects another initial request. All cases require first-party bootstrap and `pubads_impl` responses with `X-GPT-Proxy: true`, and reject direct GPT-script bypass. +The fault cases fail or hold the sole critical synchronous unified runtime. They do not assume an independent early renderer. Failure must leave TS APIs and delivery unavailable, without a fallback or retry. A late case must prove blocked parser/readiness state before release, then successful initialization and one eligible TS delivery afterward. Successful delivery cases require first-party GPT bootstrap and `pubads_impl` responses with `X-GPT-Proxy: true`, and reject direct GPT-script bypass. These are live assertions to run only with authorized inventory, not claims of completed delivery. -The observer decodes GET/URL-encoded POST and SRA inventory/targeting. It accounts for every per-slot network membership using distinct configured paths and public `slotRequested` events, then checks creative ID and a visible marker inside the corresponding real GPT frame. The marker and every containing iframe must have positive viewport-intersecting geometry and visible, nontransparent, noncollapsed DOM ancestors. Hidden frame text alone is not evidence. Unknown encodings, repeated paths in one SRA request, undecodable membership, extra refreshes, wrong mounts, unknown/wrong creatives, missing markers, and no-fill fail. There are no timing-based success thresholds. Polling timeouts bound observation; they do not prove the absence of an event indefinitely. +The observer decodes GET/URL-encoded POST and SRA inventory/targeting. It accounts for every per-slot network membership using distinct configured paths and public `slotRequested` events, then checks creative ID and a visible marker inside the corresponding real GPT frame. The marker must intersect the top-level viewport through the entire containing-frame chain, with visible, nontransparent, noncollapsed DOM ancestors. Separate local-viewport checks for each frame are insufficient: a marker in the clipped-away portion of a partly visible iframe is not visible delivery. Hidden frame text alone is not evidence. Unknown encodings, repeated paths in one SRA request, undecodable membership, extra refreshes, wrong mounts, unknown/wrong creatives, missing markers, and no-fill fail. There are no timing-based success thresholds. Polling timeouts bound observation; they do not prove the absence of an event indefinitely. The publisher authors real `pbjs.addAdUnits` entries with fixture-bidder parameters before refresh. `PUT /api/fixture-auction` accepts only `{ "noBid": true|false }` and changes actual local provider behavior without navigating or resetting the consumed native slot. Every provider exchange records its effective mode. `DELETE` resets both the ledger and forced mode; the existing `/no-bid` path continues to work without a control setting. diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts index af34e25d07..26e4ce4ae6 100644 --- a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts @@ -4,8 +4,6 @@ import { expect, test, type Page, - type ElementHandle, - type Frame, type Route, type TestInfo, } from "@playwright/test"; @@ -16,7 +14,10 @@ const { sha256, assertEvidence, } = require("../../publisher-registration/real-gpt.cjs"); -const { rendererAsset } = require("../../cwv/html.cjs"); +const { rendererAsset, scriptBlocks } = require("../../cwv/html.cjs"); +const { + marker, +} = require("../../publisher-registration/marker-visibility.cjs"); const baselineMode = process.env.PUBLISHER_REGISTRATION_REAL_GPT_MODE === "legacy"; const inventory = loadInventory( @@ -46,6 +47,9 @@ type Session = { network: Network[]; responses: Array>; errors: string[]; + consoleMessages: string[]; + expectedErrors: string[]; + runtimeRequests: string[]; pending: Promise[]; captureErrors: string[]; interventions: Array>; @@ -90,6 +94,9 @@ async function install( network: [], responses: [], errors: [], + consoleMessages: [], + expectedErrors: [], + runtimeRequests: [], pending: [], captureErrors: [], interventions: [], @@ -187,6 +194,7 @@ async function install( session.network[index].failure = request.failure()?.errorText; }); page.on("pageerror", (error) => session.errors.push(error.message)); + page.on("console", (message) => session.consoleMessages.push(message.text())); page.on("response", (response) => { const url = new URL(response.url()); if ( @@ -244,23 +252,82 @@ async function install( body: bytes, }); } - if ((runtime === "failed" || holdRuntime) && rendererAsset(url.pathname)) { - session.interventions.push({ type: runtime, url: url.href }); - if (holdRuntime) { - session.runtimeRoutes.push(route); - return; + if (rendererAsset(url.pathname)) { + session.runtimeRequests.push(url.pathname); + if (runtime === "failed" || holdRuntime) { + session.interventions.push({ type: runtime, url: url.href }); + if (holdRuntime) { + session.runtimeRoutes.push(route); + return; + } + return route.abort("failed"); } - return route.abort("failed"); } return route.continue(); }); return session; } -async function ready(page: Page, runtime = true, variant = "") { - await page.goto(`${baseUrl()}/publisher-registration-real-gpt${variant}`, { - waitUntil: "commit", - }); +function assertRuntimeOrder(html: string) { + const scripts = scriptBlocks(html); + const runtimes = scripts.filter( + (script: { id: string }) => script.id === "trustedserver-js", + ); + expect(runtimes).toHaveLength(1); + const runtime = runtimes[0]; + expect(new URL(runtime.src, baseUrl()).pathname).toBe( + "/static/tsjs=tsjs-unified.min.js", + ); + expect(html.slice(runtime.startOffset, runtime.endOffset)).not.toMatch( + /\s(?:async|defer)(?:\s|=|>)/, + ); + for (const config of [ + "window.__tsjs_gpt_enabled=true", + "window.__tsjs_prebid=", + ]) { + const script = scripts.find((entry: { content: string }) => + entry.content.includes(config), + ); + expect( + script, + `${config} must precede the synchronous runtime`, + ).toBeTruthy(); + expect(script.endOffset).toBeLessThanOrEqual(runtime.startOffset); + } + expect(runtime.endOffset).toBeLessThanOrEqual( + html.indexOf('id="real-gpt-placeholder"'), + ); + expect( + scripts.some( + (script: { id: string }) => script.id === "trustedserver-bootstrap", + ), + ).toBe(false); + for (const api of [ + "publisherSlotRegistrationVersion", + "tsRenderBridgeVersion", + ]) { + expect( + scripts.some( + (script: { src: string | null; content: string }) => + !script.src && script.content.includes(api), + ), + `no inline ${api} fallback`, + ).toBe(false); + } +} + +async function ready(page: Page, variant = "") { + const response = await page.goto( + `${baseUrl()}/publisher-registration-real-gpt${variant}`, + { + waitUntil: "commit", + }, + ); + assertRuntimeOrder(await response!.text()); + await waitReady(page); +} + +async function waitReady(page: Page) { await expect .poll(() => page.evaluate(() => { @@ -273,19 +340,18 @@ async function ready(page: Page, runtime = true, variant = "") { }), ) .toBe(true); - if (runtime) - await expect - .poll(() => - page.evaluate(() => { - const browser = window as unknown as Browser; - return ( - browser.tsjs?.tsRenderBridgeVersion === 1 && - typeof browser.pbjs?.registerBidAdapter === "function" && - browser.googletag.pubads().__tsRefreshWrapped === true - ); - }), - ) - .toBe(true); + await expect + .poll(() => + page.evaluate(() => { + const browser = window as unknown as Browser; + return ( + browser.tsjs?.tsRenderBridgeVersion === 1 && + typeof browser.pbjs?.registerBidAdapter === "function" && + browser.googletag.pubads().__tsRefreshWrapped === true + ); + }), + ) + .toBe(true); expect( (await publisher(page)).filter((event) => event.type === "slotRequested"), ).toHaveLength(0); @@ -308,77 +374,6 @@ async function mount(page: Page, eligible = true, id = "real-gpt-final") { await expect(page.locator(`#${id}`)).toHaveCount(1); } -async function visiblyPainted(element: ElementHandle) { - return element.evaluate((node) => { - if (!(node instanceof HTMLElement) || !node.isConnected || document.hidden) - return false; - const rect = node.getBoundingClientRect(); - let left = Math.max(0, rect.left); - let top = Math.max(0, rect.top); - let right = Math.min(innerWidth, rect.right); - let bottom = Math.min(innerHeight, rect.bottom); - for ( - let ancestor: HTMLElement | null = node; - ancestor; - ancestor = ancestor.parentElement - ) { - const css = getComputedStyle(ancestor); - if ( - css.display === "none" || - css.visibility === "hidden" || - css.visibility === "collapse" || - Number(css.opacity) === 0 || - css.contentVisibility === "hidden" - ) - return false; - const clip = ancestor.getBoundingClientRect(); - if (css.overflowX !== "visible") { - left = Math.max(left, clip.left); - right = Math.min(right, clip.right); - } - if (css.overflowY !== "visible") { - top = Math.max(top, clip.top); - bottom = Math.min(bottom, clip.bottom); - } - } - return right > left && bottom > top; - }); -} - -async function marker(page: Page, text: string, id: string) { - for (const element of await page.locator(`#${id} iframe`).elementHandles()) { - const root = await element.contentFrame(); - if (!root) continue; - const frames = [root]; - for (let index = 0; index < frames.length; index++) { - const frame = frames[index]; - frames.push(...frame.childFrames()); - try { - let ancestorsVisible = true; - for ( - let ancestor: Frame | null = frame; - ancestor && ancestor !== page.mainFrame(); - ancestor = ancestor.parentFrame() - ) { - if (!(await visiblyPainted(await ancestor.frameElement()))) { - ancestorsVisible = false; - break; - } - } - if (!ancestorsVisible) continue; - for (const textElement of await frame - .getByText(text, { exact: false }) - .elementHandles()) { - if (await visiblyPainted(textElement)) return true; - } - } catch { - /* Detached, hidden, collapsed and offscreen frames are not evidence. */ - } - } - } - return false; -} - async function nativeSlots(page: Page) { return page.evaluate(() => { const tag = ( @@ -493,7 +488,7 @@ async function verifyProxy(page: Page) { ), ) : session.errors; - expect(unexpectedErrors).toEqual([]); + expect(unexpectedErrors).toEqual(session.expectedErrors); } test.afterEach(async ({ page }, info) => { @@ -518,6 +513,9 @@ test.afterEach(async ({ page }, info) => { events, provider, errors: session.errors, + consoleMessages: session.consoleMessages, + expectedErrors: session.expectedErrors, + runtimeRequests: session.runtimeRequests, captureErrors: session.captureErrors, interventions: session.interventions, }; @@ -583,45 +581,152 @@ if (!baselineMode) { page, }, info) => { await install(page, info); - await ready(page, true, scenario === "no-bid" ? "/no-bid" : ""); + await ready(page, scenario === "no-bid" ? "/no-bid" : ""); await mount(page, scenario !== "ineligible"); await verify(page, [await delivered(page, false)]); }); - for (const runtime of ["failed", "late"] as const) - test(`${runtime} deferred runtime preserves eligible early TS delivery without a repeat at takeover`, async ({ - page, - }, info) => { - const session = await install(page, info, runtime); - await ready(page, false); - await mount(page); - expect( - await page.evaluate( - () => (window as unknown as Browser).tsjs?.tsRenderBridgeVersion, + test("held critical unified runtime blocks parsing and publisher readiness until release", async ({ + page, + }, info) => { + const session = await install(page, info, "late"); + const response = await page.goto( + `${baseUrl()}/publisher-registration-real-gpt`, + { + waitUntil: "commit", + }, + ); + await expect.poll(() => session.runtimeRoutes.length).toBe(1); + expect( + await page.evaluate(() => { + const browser = window as unknown as Browser; + return { + state: document.readyState, + registration: browser.tsjs?.publisherSlotRegistrationVersion, + renderer: browser.tsjs?.tsRenderBridgeVersion, + publisher: !!browser.realGptPublisher, + placeholder: !!document.getElementById("real-gpt-placeholder"), + }; + }), + ).toEqual({ + state: "loading", + registration: undefined, + renderer: undefined, + publisher: false, + placeholder: false, + }); + expect( + (await publisher(page)).filter((event) => event.type === "slotRequested"), + ).toEqual([]); + expect( + session.network.filter((entry) => + new URL(entry.url).pathname.endsWith("/gampad/ads"), + ), + ).toEqual([]); + await session.releaseRuntime(); + assertRuntimeOrder(await response!.text()); + await waitReady(page); + await mount(page); + await verify(page, [await delivered(page, true)]); + expect(session.runtimeRequests).toEqual([ + "/static/tsjs=tsjs-unified.min.js", + ]); + }); + + test("failed critical unified runtime has no TS APIs, delivery, inline fallback or retry", async ({ + page, + }, info) => { + const session = await install(page, info, "failed"); + const response = await page.goto( + `${baseUrl()}/publisher-registration-real-gpt`, + ); + assertRuntimeOrder(await response!.text()); + // GPT and React may still initialize. That does not make TS registration ready. + await expect + .poll(() => + page.evaluate(() => { + const browser = window as unknown as Browser; + return ( + browser.googletag.apiReady === true && + browser.realGptPublisher?.ready === true + ); + }), + ) + .toBe(true); + const apis = () => + page.evaluate(() => { + const browser = window as unknown as Browser; + return { + registration: browser.tsjs?.publisherSlotRegistrationVersion, + renderer: browser.tsjs?.tsRenderBridgeVersion, + register: typeof (browser.tsjs as { registerPublisherSlot?: unknown }) + ?.registerPublisherSlot, + scheduler: typeof ( + browser.tsjs as { scheduleInitialAdInit?: unknown } + )?.scheduleInitialAdInit, + }; + }); + expect(await apis()).toEqual({ + registration: undefined, + renderer: undefined, + register: "undefined", + scheduler: "undefined", + }); + await expect(page.locator("#real-gpt-placeholder")).toHaveCount(1); + const publisherError = "Actual Rust registration bootstrap is unavailable"; + await page.getByRole("button", { name: "Mount final placement" }).click(); + // GPT can catch command callbacks and React can catch layout effects. Keep + // their actual diagnostic channel instead of requiring a window error. + await expect + .poll(() => + [...session.errors, ...session.consoleMessages].some((message) => + message.includes(publisherError), ), - "actual early renderer must survive the deferred-runtime fault", - ).toBe(1); - const initial = await delivered(page, true); - expect( - session.interventions.some((entry) => entry.type === runtime), - ).toBe(true); - if (runtime === "late") { - await session.releaseRuntime(); - await expect - .poll(() => - page.evaluate(() => { - const browser = window as unknown as Browser; - return ( - typeof browser.pbjs?.registerBidAdapter === "function" && - browser.googletag.pubads().__tsRefreshWrapped === true - ); - }), - ) - .toBe(true); - // The completed main-runtime installation is the observation boundary, not a sleep. - } - await verify(page, [initial]); + ) + .toBe(true); + for (const message of session.errors) { + expect(message).toBe(publisherError); + session.expectedErrors.push(message); + } + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + expect(await apis()).toEqual({ + registration: undefined, + renderer: undefined, + register: "undefined", + scheduler: "undefined", }); + expect( + (await nativeSlots(page)).filter( + (slot) => slot.path === inventory.registered.path, + ), + ).toEqual([]); + assertEvidence({ + requests: session.network.filter((entry) => + new URL(entry.url).pathname.endsWith("/gampad/ads"), + ), + events: await publisher(page), + expected: [], + paths, + }); + expect( + session.network.filter( + (entry) => new URL(entry.url).pathname === "/auction", + ), + ).toEqual([]); + expect(session.runtimeRequests).toEqual([ + "/static/tsjs=tsjs-unified.min.js", + ]); + expect(session.interventions).toHaveLength(1); + expect(session.errors).toEqual(session.expectedErrors); + await expect(page.locator("#trustedserver-bootstrap")).toHaveCount(0); + await Promise.all(session.pending); + expect(session.captureErrors).toEqual([]); + }); test("eligible TS-first candidate retires on real no-bid publisher refresh with SRA isolation", async ({ page, diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts index ec7f584b65..9ca920af77 100644 --- a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts @@ -12,11 +12,7 @@ import { runtimeUrl } from "../../helpers/state.js"; import type { PublisherController } from "../../../fixtures/frameworks/nextjs/app/publisher-registration/RegisteredSlot"; const { creative, pucPage } = require("../../publisher-registration/pages.cjs"); -const { - scriptBlocks, - withoutEarlyRenderer, - rendererAsset, -} = require("../../cwv/html.cjs"); +const { scriptBlocks, rendererAsset } = require("../../cwv/html.cjs"); const enabled = process.env.PUBLISHER_REGISTRATION_RUN === "1"; test.skip( !enabled, @@ -40,6 +36,8 @@ interface FixtureWindow extends Window { tsjs: { publisherSlotRegistrationVersion?: number; tsRenderBridgeVersion?: number; + registerPublisherSlot?: unknown; + scheduleInitialAdInit?: unknown; navGeneration?: number; adSlots?: Array<{ id: string; initialization?: string }>; bids?: Record; @@ -73,13 +71,10 @@ interface Session { consoleErrors: string[]; external: string[]; auctions: string[]; - mainBundle?: string; - blockedRuntime: string[]; - rendererInterventions: Array<{ - originalSha256: string; - servedSha256: string; - removedEarly: boolean; - }>; + runtimeRequests: string[]; + runtimeRoutes: Route[]; + releaseRuntime(): Promise; + expectedErrors: string[]; pending: Promise[]; held: Route[]; expectedHydrationFailure?: boolean; @@ -110,8 +105,8 @@ async function install( page: Page, options: { holdFrames?: boolean; - blockRenderer?: boolean; - blockRuntime?: boolean; + holdRuntime?: boolean; + failRuntime?: boolean; failHydration?: boolean; holdHydration?: boolean; } = {}, @@ -123,13 +118,20 @@ async function install( consoleErrors: [], external: [], auctions: [], - blockedRuntime: [], - rendererInterventions: [], + runtimeRequests: [], + runtimeRoutes: [], + async releaseRuntime() { + holdRuntime = false; + for (const route of session.runtimeRoutes.splice(0)) + await route.continue(); + }, + expectedErrors: [], pending: [], held: [], expectedHydrationFailure: options.failHydration, }; sessions.set(page, session); + let holdRuntime = !!options.holdRuntime; let holdHydration = !!options.holdHydration; const hydrationRoutes: Route[] = []; session.releaseHydration = async () => { @@ -280,20 +282,19 @@ async function install( session.held.push(route); return; } - if ( - (options.blockRenderer || options.blockRuntime) && - rendererAsset(url.pathname) - ) { - const response = await route.fetch(); - session.mainBundle = await response.text(); - session.blockedRuntime.push(url.pathname); - return route.fulfill({ response, body: "" }); + if (rendererAsset(url.pathname)) { + session.runtimeRequests.push(url.pathname); + if (holdRuntime) { + session.runtimeRoutes.push(route); + session.held.push(route); + return; + } + if (options.failRuntime) return route.abort("failed"); } if (url.pathname === "/auction") session.auctions.push(route.request().postData() || ""); if ( - (process.env.PUBLISHER_REGISTRATION_ASSEMBLY === "shared" || - options.blockRenderer) && + process.env.PUBLISHER_REGISTRATION_ASSEMBLY === "shared" && route.request().resourceType() === "document" ) { // Request interception adds browser no-cache semantics. Exercise an @@ -309,23 +310,61 @@ async function install( expect(response.headers()["x-ts-template-cache"]).not.toBe( "bypass-request", ); - if (options.blockRenderer) { - const original = await response.text(); - const body = withoutEarlyRenderer(original); - session.rendererInterventions.push({ - originalSha256: sha256(original), - servedSha256: sha256(body), - removedEarly: original !== body, - }); - return route.fulfill({ response, body }); - } return route.fulfill({ response }); } return route.continue(); }); } -async function ready(page: Page, renderer = true) { +function assertRuntimeOrder(html: string) { + const scripts = scriptBlocks(html); + const runtimes = scripts.filter( + (script: { id: string }) => script.id === "trustedserver-js", + ); + expect(runtimes).toHaveLength(1); + const runtime = runtimes[0]; + expect(new URL(runtime.src, runtimeUrl("/")).pathname).toBe( + "/static/tsjs=tsjs-unified.min.js", + ); + expect(html.slice(runtime.startOffset, runtime.endOffset)).not.toMatch( + /\s(?:async|defer)(?:\s|=|>)/, + ); + for (const config of [ + "window.__tsjs_gpt_enabled=true", + "window.__tsjs_prebid=", + ]) { + const script = scripts.find((entry: { content: string }) => + entry.content.includes(config), + ); + expect( + script, + `${config} must precede the synchronous runtime`, + ).toBeTruthy(); + expect(script.endOffset).toBeLessThanOrEqual(runtime.startOffset); + } + expect(runtime.endOffset).toBeLessThanOrEqual( + html.indexOf('id="fixture-header-placeholder"'), + ); + expect( + scripts.some( + (script: { id: string }) => script.id === "trustedserver-bootstrap", + ), + ).toBe(false); + for (const api of [ + "publisherSlotRegistrationVersion", + "tsRenderBridgeVersion", + ]) { + expect( + scripts.some( + (script: { src: string | null; content: string }) => + !script.src && script.content.includes(api), + ), + `no inline ${api} fallback`, + ).toBe(false); + } +} + +async function ready(page: Page) { // Node-side polling still observes the real readiness conditions when a case // deliberately holds the page's animation frames to control dispatch order. await expect @@ -346,7 +385,7 @@ async function ready(page: Page, renderer = true) { await page.evaluate( () => (window as unknown as FixtureWindow).tsjs.tsRenderBridgeVersion, ), - ).toBe(renderer ? 1 : undefined); + ).toBe(1); await page.evaluate(() => { const current = window as unknown as FixtureWindow; current.pbjs.onEvent("auctionInit", () => @@ -526,14 +565,13 @@ async function saveEvidence(page: Page, info: TestInfo) { { html: session.html, htmlSha256: session.html.map(sha256), - bootstrapSha256: session.html - .map( - (html) => - scriptBlocks(html).find((script: { content: string }) => - script.content.includes("publisherSlotRegistrationVersion"), - )?.content, - ) - .map((bootstrap) => (bootstrap ? sha256(bootstrap) : null)), + runtimeScripts: session.html.map((html) => + scriptBlocks(html).filter( + (script: { src: string | null }) => + script.src && + rendererAsset(new URL(script.src, runtimeUrl("/")).pathname), + ), + ), payloads: session.payloads, }, null, @@ -550,8 +588,8 @@ async function saveEvidence(page: Page, info: TestInfo) { consoleErrors: session.consoleErrors, external: session.external, auctions: session.auctions, - blockedRuntime: session.blockedRuntime, - rendererInterventions: session.rendererInterventions, + runtimeRequests: session.runtimeRequests, + expectedErrors: session.expectedErrors, expectedHydrationFailure: session.expectedHydrationFailure, }, null, @@ -604,7 +642,8 @@ test.afterEach(async ({ page }, info) => { const session = sessions.get(page); if (session) { expect(session.external).toEqual([]); - if (!session.expectedHydrationFailure) expect(session.errors).toEqual([]); + if (!session.expectedHydrationFailure) + expect(session.errors).toEqual(session.expectedErrors); } }); @@ -616,10 +655,7 @@ test("actual Rust HTML, admitted payload, delayed mount and later real Prebid re const html = (await response!.text()).replaceAll('\\"', '"'); expect(html).toContain('"initialization":"publisher_registered"'); expect(html).toContain("scheduleInitialAdInit"); - expect(html.indexOf("publisherSlotRegistrationVersion")).toBeGreaterThan(-1); - expect(html.indexOf("publisherSlotRegistrationVersion")).toBeLessThan( - html.indexOf('id="fixture-header-placeholder"'), - ); + assertRuntimeOrder(html); if (process.env.PUBLISHER_REGISTRATION_ASSEMBLY === "shared") expect(html).toContain('if(typeof s==="function")s(b,a)'); else expect(html).toContain('if(typeof s==="function")s(b);'); @@ -934,71 +970,126 @@ test("legal no-bid still performs one scoped ordinary GAM delivery", async ({ ).toBeUndefined(); }); -test("bootstrap-only ordinary fill survives late renderer installation without replacement", async ({ +test("held critical unified runtime blocks parsing and readiness until release", async ({ page, }) => { - await install(page, { blockRenderer: true }); - await page.goto(runtimeUrl("/publisher-registration/registered-only")); - await ready(page, false); + await install(page, { holdRuntime: true }); + const session = sessions.get(page)!; + const response = await page.goto( + runtimeUrl("/publisher-registration/registered-only"), + { + waitUntil: "commit", + }, + ); + await expect.poll(() => session.runtimeRoutes.length).toBe(1); + expect( + await page.evaluate(() => { + const current = window as unknown as FixtureWindow; + return { + state: document.readyState, + registration: current.tsjs?.publisherSlotRegistrationVersion, + renderer: current.tsjs?.tsRenderBridgeVersion, + publisher: !!current.publisherRegistrationFixture, + placeholder: !!document.getElementById("fixture-header-placeholder"), + requests: current.fixture.requests.length, + }; + }), + ).toEqual({ + state: "loading", + registration: undefined, + renderer: undefined, + publisher: false, + placeholder: false, + requests: 0, + }); + await session.releaseRuntime(); + await ready(page); + assertRuntimeOrder(await response!.text()); await mount(page); const initial = await delivered(page); - expect(initial.targeting.hb_adid).toBeUndefined(); + expect(initial.targeting.hb_adid).toEqual(["fixture-win-fixture-header"]); await assertIdentity(page, initial); - await render(page, initial, "Publisher result"); + await render(page, initial, "Fixture publisher registration winner"); + expect(session.runtimeRequests).toEqual(["/static/tsjs=tsjs-unified.min.js"]); +}); + +test("failed critical unified runtime leaves no TS APIs or registered delivery and never retries", async ({ + page, +}) => { + await install(page, { failRuntime: true }); const session = sessions.get(page)!; - expect(session.rendererInterventions).toHaveLength(1); - expect(session.rendererInterventions[0].removedEarly).toBe(true); - expect( - session.blockedRuntime.some((url) => url.includes("tsjs-gpt.min.js")), - ).toBe(true); - expect(session.mainBundle).toBeTruthy(); - await page.addScriptTag({ content: session.mainBundle! }); - await page.waitForFunction( - () => (window as unknown as FixtureWindow).tsjs.tsRenderBridgeVersion === 1, + const response = await page.goto( + runtimeUrl("/publisher-registration/registered-only"), ); + assertRuntimeOrder(await response!.text()); + await expect(page.getByTestId("ssr-placeholder")).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + !!(window as unknown as FixtureWindow).publisherRegistrationFixture, + ), + ) + .toBe(true); + expect( + await page.evaluate(() => { + const current = window as unknown as FixtureWindow; + return { + registration: current.tsjs?.publisherSlotRegistrationVersion, + renderer: current.tsjs?.tsRenderBridgeVersion, + register: typeof current.tsjs?.registerPublisherSlot, + scheduler: typeof current.tsjs?.scheduleInitialAdInit, + requests: current.fixture.requests.length, + }; + }), + ).toEqual({ + registration: undefined, + renderer: undefined, + register: "undefined", + scheduler: "undefined", + requests: 0, + }); + // Separately deferred Prebid can still install its refresh wrapper. It cannot + // replace the registration API lost with the sole critical unified request. + await expect + .poll(() => + page.evaluate( + () => + (window as unknown as FixtureWindow).googletag.pubads() + .__tsRefreshWrapped, + ), + ) + .toBe(true); + await page.getByRole("button", { name: "Mount final placement" }).click(); + // React error boundaries report layout-effect failures to the console rather + // than necessarily dispatching an uncaught window error. + const registrationError = /registerPublisherSlot is not a function/; + await expect + .poll(() => + [...session.errors, ...session.consoleErrors].some((message) => + registrationError.test(message), + ), + ) + .toBe(true); + for (const message of session.errors) { + expect(message).toMatch(registrationError); + session.expectedErrors.push(message); + } await page.evaluate( () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), ), ); + expect(await headerRequests(page)).toHaveLength(0); expect( await page.evaluate( - () => - (window as unknown as FixtureWindow).tsjs.bids?.["fixture-header"] - ?.hb_adid, - ), - ).toBe("fixture-win-fixture-header"); - expect(await headerRequests(page)).toHaveLength(1); - expect( - await page.evaluate(() => - (window as unknown as FixtureWindow).fixture.nativeSlots - .find((slot) => slot.getSlotElementId() === "publisher-final-header")! - .getTargeting("hb_adid"), + () => (window as unknown as FixtureWindow).fixture.requests, ), ).toEqual([]); - await expect( - page.frameLocator("#publisher-final-header > iframe").locator(".marker"), - ).toHaveText("Publisher result"); -}); - -test("failed deferred runtime retains the early renderer and server candidate", async ({ - page, -}) => { - await install(page, { blockRuntime: true }); - await page.goto(runtimeUrl("/publisher-registration/registered-only")); - await ready(page); - expect(sessions.get(page)!.blockedRuntime).toContain( - "/static/tsjs=tsjs-gpt.min.js", - ); - expect(sessions.get(page)!.rendererInterventions).toEqual([]); - await expect(page.locator("#trustedserver-bootstrap")).toHaveCount(1); - await mount(page); - const initial = await delivered(page); - expect(initial.targeting.hb_adid).toEqual(["fixture-win-fixture-header"]); - await assertIdentity(page, initial); - await render(page, initial, "Fixture publisher registration winner"); - expect(await headerRequests(page)).toHaveLength(1); + expect(session.auctions).toEqual([]); + expect(session.runtimeRequests).toEqual(["/static/tsjs=tsjs-unified.min.js"]); + await expect(page.locator("#trustedserver-bootstrap")).toHaveCount(0); }); for (const { width, noFill } of [ diff --git a/crates/trusted-server-integration-tests/examples/cwv_html.rs b/crates/trusted-server-integration-tests/examples/cwv_html.rs index 3e161458ea..38cfc0aff4 100644 --- a/crates/trusted-server-integration-tests/examples/cwv_html.rs +++ b/crates/trusted-server-integration-tests/examples/cwv_html.rs @@ -237,9 +237,17 @@ fn main() -> Result<(), FixtureError> { "crates/trusted-server-core/src/tsjs.rs", "crates/trusted-server-js/lib/build-module-options.mjs", "crates/trusted-server-js/lib/build-all.mjs", + "crates/trusted-server-js/lib/package-lock.json", "crates/trusted-server-core/src/integrations/gpt_bootstrap.js", "crates/trusted-server-integration-tests/examples/cwv_html.rs", ]; + let js_source_files = command(&["find", "crates/trusted-server-js/lib/src", "-type", "f"]); + let mut js_source_paths = js_source_files.lines().collect::>(); + js_source_paths.sort_unstable(); + let source_files = source_files + .into_iter() + .chain(js_source_paths.iter().copied()) + .collect::>(); let manifest = json!({ "schemaVersion": 1, "reserved": reserved, "sourceRevision": env::var("CWV_SOURCE_REVISION").unwrap_or_else(|_| command(&["git", "rev-parse", "HEAD"])), @@ -249,7 +257,11 @@ fn main() -> Result<(), FixtureError> { "status": env::var("CWV_SOURCE_STATUS").ok(), "origin": "CWV_SOURCE_REVISION/CWV_SOURCE_STATUS if present, otherwise current git checkout" }, - "jsSourceFiles": command(&["find", "crates/trusted-server-js/lib/src", "-type", "f"]), + "jsSourceFiles": js_source_paths.join("\n"), + "jsModules": { + "immediate": registry.js_module_ids_immediate(), + "deferred": registry.js_module_ids_deferred() + }, "sourceFingerprints": command(&["sha256sum"].into_iter().chain(source_files).collect::>()), "rustc": command(&["rustc", "-Vv"]), "exporterSource": include_str!("cwv_html.rs"), diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 84972f31e9..6e78c5c26a 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -352,20 +352,22 @@ function installScheduleInitialAdInit(ts: TsjsApi): void { ts.initialAdInitScheduled = true; if (initialSlots !== undefined) ts.adSlots = initialSlots; if (initialBids !== undefined) ts.bids = initialBids; + // Bind the accepted batch before settlement can queue registered delivery. + // Do not re-observe it in the deferred automatic pass and replace newer bindings. + try { + getActiveTraceGptBridge()?.observeTransport( + initialSlots ?? ts.adSlots, + traceAuctionTransport, + 'initial_navigation_ssat' + ); + } catch { + // A diagnostic bridge cannot block the ordinary initial ad pass. + } if (initialBids !== undefined || initialSlots !== undefined) { settlePublisherSlotPayload(ts, 0, ts.adSlots ?? [], ts.bids ?? {}); } const runUnlessNavigated = (): void => { if ((ts.navGeneration ?? 0) !== 0) return; - try { - getActiveTraceGptBridge()?.observeTransport( - initialSlots ?? ts.adSlots, - traceAuctionTransport, - 'initial_navigation_ssat' - ); - } catch { - // A diagnostic bridge cannot block the ordinary initial ad pass. - } ts.adInit?.(); }; const afterHydrationFrames = (): void => { @@ -1353,6 +1355,11 @@ export function installSpaAuctionHook(): void { ts.adSlots = data.slots; ts.bids = data.bids; } + try { + getActiveTraceGptBridge()?.observePageBids(data, data.slots); + } catch { + // A diagnostic bridge cannot block the accepted navigation. + } settlePublisherSlotPayload(ts, generation, data.slots, data.bids); // Automatic placements retain their existing DOM wait. await waitForSlotElements( @@ -1362,11 +1369,6 @@ export function installSpaAuctionHook(): void { if (inflight !== controller || ts.navGeneration !== generation) return; ts.adSlots = data.slots; ts.bids = data.bids; - try { - getActiveTraceGptBridge()?.observePageBids(data, data.slots); - } catch { - // A diagnostic bridge cannot block the accepted navigation. - } // This route is now the committed, loaded state — a later failed // navigation rolls back here, and a return trip no-ops correctly. lastAppliedPath = path; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts b/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts index 81701e1cad..9ca530fb57 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts @@ -1,4 +1,5 @@ import { claimFirstImpressionForTrustedServer } from '../../core/first_impression'; +import { getActiveTraceGptBridge } from '../../trace/runtime'; import type { AuctionBidData, AuctionSlot, @@ -389,6 +390,8 @@ export function installPublisherSlotRegistration( ) as Record; retainPublisherSlotMappings(ts, ts.divToSlotId, ts.prevSlotTargetingKeys); try { + const auctionId = candidate?.hb_auction_id; + const trace = getActiveTraceGptBridge()?.opportunity(slot, auctionId) ?? { auctionId }; ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( native, slot.id, @@ -397,8 +400,9 @@ export function installPublisherSlotRegistration( ? 'renderable_candidate' : 'unrenderable_candidate' : 'no_candidate', - candidate?.hb_auction_id, - record.eligibleFormats + trace.auctionId, + record.eligibleFormats, + ...(trace.identity ? ([trace.identity] as const) : []) ); } catch { /* Diagnostics never own delivery. */ diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/registration_trace.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/registration_trace.test.ts new file mode 100644 index 0000000000..1504c1383d --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/registration_trace.test.ts @@ -0,0 +1,455 @@ +import { readFileSync } from 'node:fs'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + AuctionBidData, + AuctionSlot, + PublisherGptSlot, + TsjsApi, +} from '../../../src/core/types'; +import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; +import { settlePublisherSlotPayload } from '../../../src/integrations/gpt/publisher_registration'; +import { installTraceRuntime } from '../../../src/trace/runtime'; +import { GPT_BOOTSTRAP_PATH } from '../../fixtures/paths'; +import { AUCTION_TOKEN, SLOT_TOKEN } from '../../trace/fixtures'; +import { gptTransport, joinedGptStore } from '../../trace/gpt-fixtures'; + +for (const implementation of [ + 'runtime', + 'bootstrap', + 'waiting_takeover', + 'requested_takeover', +] as const) { + describe(`registered trace opportunities: ${implementation}`, () => { + let ts: TsjsApi; + let store: GptDiagnosticsStore; + let collector: NonNullable>; + let frames: FrameRequestCallback[]; + let commands: Array<() => void>; + let nativeSlots: PublisherGptSlot[]; + let listeners: Array<[string, EventListenerOrEventListenerObject]>; + let refresh: ReturnType; + let display: ReturnType; + const pushState = history.pushState; + const replaceState = history.replaceState; + + function configured( + initialization: 'auto' | 'publisher_registered' = 'publisher_registered' + ): AuctionSlot { + return { + id: 'example', + div_id: 'example', + gam_unit_path: '/123/example', + formats: [[300, 250]], + initialization, + ext: { trusted_server: { trace_slot_ref: SLOT_TOKEN } }, + }; + } + function selectedTransport(source = 'initial_navigation_ssat') { + const transport = gptTransport(source); + return { + ...transport, + evidence: { + ...transport.evidence, + slots: [ + { + ...transport.evidence.slots[0], + returned_bid_count: 1, + candidate: 'selected', + selected_creative_size: [300, 250], + }, + ], + }, + }; + } + function bid(): AuctionBidData { + return { + hb_adid: 'example-creative', + hb_bidder: 'exampleBidder', + hb_pb: '1.00', + hb_auction_id: AUCTION_TOKEN, + w: 300, + h: 250, + adm: 'example creative', + }; + } + function mount(id = 'example') { + const element = document.createElement('div'); + element.id = id; + document.body.append(element); + const targeting: Record = {}; + const native: PublisherGptSlot = { + getSlotElementId: () => id, + getAdUnitPath: () => '/123/example', + setTargeting: (key, value) => { + targeting[key] = value; + return native; + }, + clearTargeting: (key) => { + if (key) delete targeting[key]; + return native; + }, + getTargeting: (key) => { + const value = targeting[key]; + return value === undefined ? [] : Array.isArray(value) ? value : [value]; + }, + }; + nativeSlots.push(native); + return { native, targeting }; + } + function register( + native: PublisherGptSlot, + eligibleFormats: readonly (readonly [number, number])[] = [[300, 250]] + ) { + return ts.registerPublisherSlot!({ + slotId: 'example', + gptSlot: native, + navigationGeneration: ts.navGeneration ?? 0, + eligibleFormats, + }); + } + function tick() { + frames.splice(0).forEach((callback) => callback(0)); + commands.splice(0).forEach((callback) => callback()); + } + function flush() { + for (let index = 0; index < 6; index++) tick(); + } + async function runtime() { + const module = await import('../../../src/integrations/gpt/index'); + module.installTsAdInit(); + module.installSpaAuctionHook(); + module.installTsRenderBridge(); + } + async function takeoverBeforeDispatch() { + if (implementation === 'waiting_takeover') await runtime(); + } + async function takeoverAfterDispatch() { + if (implementation === 'requested_takeover') { + await runtime(); + flush(); + } + } + function assertJoined(opportunity = 'renderable_candidate') { + expect(refresh).toHaveBeenCalledTimes(1); + expect(store.snapshot().slots[0]?.requests[0]?.trustedServerOpportunity).toBe(opportunity); + expect(collector.snapshot().value?.slotCorrelations).toEqual([ + { + schema_version: 1, + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + runtime_slot_number: 1, + request_number: 1, + }, + ]); + expect(joinedGptStore(store, collector)?.auctions[0]?.slots[0]?.correlation).toBe('matched'); + } + beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers(); + frames = []; + commands = []; + nativeSlots = []; + listeners = []; + history.pushState = pushState; + history.replaceState = replaceState; + replaceState.call(history, {}, '', '/'); + ts = {} as TsjsApi; + window.tsjs = ts; + window.__tsjs_trace_active = true; + collector = installTraceRuntime(ts)!; + // Keep the real bridge closures, with a mutable facade for call-order spies. + ts.traceGpt = { ...ts.traceGpt! }; + store = new GptDiagnosticsStore({ + onTraceCorrelation: (value) => collector.recordCorrelation(value), + }); + ts.gptDiagnosticsRecorder = store; + refresh = vi.fn((slots: PublisherGptSlot[]) => + slots.forEach((slot) => store.recordSlotRequested(slot)) + ); + display = vi.fn(); + const service = { + getSlots: () => nativeSlots, + refresh, + addEventListener: vi.fn(), + enableSingleRequest: vi.fn(), + }; + Object.assign(window, { + googletag: { + cmd: { push: (callback: () => void) => commands.push(callback) }, + getConfig: () => ({ disableInitialLoad: true }), + pubads: () => service, + display, + enableServices: vi.fn(), + defineSlot: vi.fn(), + }, + }); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + vi.spyOn(document, 'hidden', 'get').mockReturnValue(false); + vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete'); + const add = window.addEventListener.bind(window); + vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { + listeners.push([type, listener]); + add(type, listener, options); + }); + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => {})) + ); + if (implementation === 'runtime') await runtime(); + else { + // Install the actual renderer, not a capability flag, to exercise bootstrap candidate parity. + const { installTsRenderBridge } = + await import('../../../src/integrations/gpt/render_bridge'); + installTsRenderBridge(); + new Function(readFileSync(GPT_BOOTSTRAP_PATH, 'utf8'))(); + } + flush(); + }); + afterEach(() => { + for (const [type, listener] of listeners) { + window.removeEventListener(type, listener); + window.removeEventListener(type, listener, true); + } + history.pushState = pushState; + history.replaceState = replaceState; + replaceState.call(history, {}, '', '/'); + document.body.innerHTML = ''; + Reflect.deleteProperty(window, 'googletag'); + Reflect.deleteProperty(window, 'tsjs'); + Reflect.deleteProperty(window, '__tsjs_trace_active'); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each(['registration_first', 'payload_first', 'prebound'] as const)( + 'joins the first native request with the exact accepted trace identity: %s', + async (order) => { + const slot = configured(); + const { native, targeting } = mount(); + const observe = vi.spyOn(ts.traceGpt!, 'observeTransport'); + if (order === 'prebound') { + // Isolate registration's identity forwarding from the scheduler's ordering. + ts.traceGpt!.observeTransport([slot], selectedTransport(), 'initial_navigation_ssat'); + ts.adSlots = [slot]; + ts.bids = { example: bid() }; + settlePublisherSlotPayload(ts, 0, ts.adSlots, ts.bids); + register(native); + } else { + if (order === 'registration_first') register(native); + ts.scheduleInitialAdInit!({ example: bid() }, [slot], selectedTransport()); + if (order === 'payload_first') register(native); + } + await takeoverBeforeDispatch(); + // Registered work may dispatch a frame before automatic adInit. Identity must already exist. + tick(); + assertJoined(); + expect(targeting.hb_adid).toBe('example-creative'); + expect(ts.publisherSlotRegistrationState?.records.example.selectedCandidate?.hb_adid).toBe( + 'example-creative' + ); + flush(); + await takeoverAfterDispatch(); + assertJoined(); + expect(observe).toHaveBeenCalledTimes(1); + expect(display).toHaveBeenCalledTimes(1); + } + ); + it('inherits the validated diagnostic marker only when the selected candidate has none', async () => { + const slot = configured(); + const { native } = mount(); + register(native); + const candidate = bid(); + delete candidate.hb_auction_id; + ts.scheduleInitialAdInit!({ example: candidate }, [slot], selectedTransport()); + await takeoverBeforeDispatch(); + flush(); + assertJoined(); + expect(store.snapshot().slots[0]?.requests[0]?.trustedServerAuctionId).toBe(AUCTION_TOKEN); + }); + it.each(['no_bid', 'ineligible', 'renderer_unavailable'] as const)( + 'correlates a native no-candidate request without selecting the server candidate: %s', + async (reason) => { + const slot = configured(); + const { native, targeting } = mount(); + register(native, reason === 'ineligible' ? [[300, 600]] : [[300, 250]]); + if (reason === 'renderer_unavailable') delete ts.tsRenderBridgeVersion; + const candidate = bid(); + // An excluded candidate's marker must not become this native request's identity. + if (reason === 'ineligible') + candidate.hb_auction_id = 'ts-auc-2234567812344abc8def123456789abc'; + ts.scheduleInitialAdInit!( + reason === 'no_bid' ? {} : { example: candidate }, + [slot], + reason === 'no_bid' ? gptTransport() : selectedTransport() + ); + // No takeover before dispatch: the missing-renderer observation belongs to this pass. + flush(); + assertJoined('no_candidate'); + expect(targeting).not.toHaveProperty('hb_adid'); + expect( + ts.publisherSlotRegistrationState?.records.example.selectedCandidate + ).toBeUndefined(); + expect(ts.publisherSlotRegistrationState?.records.example.noCandidateReason).toBe( + reason === 'ineligible' ? 'incompatible_dimensions' : reason + ); + await takeoverAfterDispatch(); + assertJoined('no_candidate'); + } + ); + it('matches the automatic path through the same validated bridge and recorder', async () => { + const slot = configured('auto'); + mount(); + ts.scheduleInitialAdInit!({ example: bid() }, [slot], selectedTransport()); + await takeoverBeforeDispatch(); + flush(); + await takeoverAfterDispatch(); + assertJoined(); + }); + it.each([ + 'inactive', + 'malformed_transport', + 'throwing_bridge', + 'throwing_recorder', + 'mismatched_marker', + ] as const)('keeps delivery independent of %s diagnostics', async (failure) => { + const slot = configured(); + const { native, targeting } = mount(); + register(native); + let transport: unknown = selectedTransport(); + if (failure === 'inactive') window.__tsjs_trace_active = false; + if (failure === 'malformed_transport') transport = { schema_version: 1 }; + if (failure === 'throwing_bridge') { + ts.traceGpt = { + ...ts.traceGpt!, + observeTransport: () => { + throw new Error('example transport error'); + }, + opportunity: () => { + throw new Error('example binding error'); + }, + }; + } + if (failure === 'throwing_recorder') + vi.spyOn(store, 'recordTrustedServerOpportunity').mockImplementation(() => { + throw new Error('example recorder error'); + }); + const candidate = bid(); + if (failure === 'mismatched_marker') + candidate.hb_auction_id = 'ts-auc-2234567812344abc8def123456789abc'; + ts.scheduleInitialAdInit!({ example: candidate }, [slot], transport); + await takeoverBeforeDispatch(); + flush(); + await takeoverAfterDispatch(); + expect(refresh).toHaveBeenCalledTimes(1); + expect(targeting.hb_adid).toBe('example-creative'); + expect(ts.publisherSlotRegistrationState?.records.example.status.status).toBe('requested'); + expect(collector.snapshot().value?.slotCorrelations ?? []).toEqual([]); + }); + it('does not re-observe the initial batch after registered delivery and erase a newer binding', async () => { + const slot = configured(); + const { native } = mount(); + register(native); + ts.scheduleInitialAdInit!({ example: bid() }, [slot], selectedTransport()); + await takeoverBeforeDispatch(); + tick(); + assertJoined(); + const newer = configured(); + ts.traceGpt!.observeTransport([newer], gptTransport('spa_page_bids'), 'spa_page_bids'); + flush(); + expect(ts.traceGpt!.identity(newer)).toEqual({ + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + }); + expect(ts.traceGpt!.identity(slot)).toBeUndefined(); + expect(refresh).toHaveBeenCalledTimes(1); + }); + it('does not observe or admit a document transport arriving after navigation', async () => { + const observe = vi.spyOn(ts.traceGpt!, 'observeTransport'); + history.pushState({}, '', '/before-document-seam'); + const { native } = mount(); + const handle = register(native); + ts.scheduleInitialAdInit!({ example: bid() }, [configured()], selectedTransport()); + flush(); + expect(observe).not.toHaveBeenCalled(); + expect(collector.snapshot().value?.serverAuctions ?? []).toEqual([]); + expect(handle.getStatus().status).toBe('waiting_payload'); + expect(refresh).not.toHaveBeenCalled(); + }); + it('rejects a forced stale SPA response before observing transport or settling registrations', async () => { + await runtime(); + flush(); + const responses: Array<(response: Response) => void> = []; + vi.mocked(fetch).mockImplementation( + () => new Promise((resolve) => responses.push(resolve)) + ); + const observe = vi.spyOn(ts.traceGpt!, 'observePageBids'); + history.pushState({}, '', '/stale-example'); + const stale = mount('stale-native'); + const old = register(stale.native); + history.pushState({}, '', '/current-example'); + const { native } = mount(); + const current = register(native); + const response = () => + new Response( + JSON.stringify({ + slots: [configured()], + bids: { example: bid() }, + trace_auction: selectedTransport('spa_page_bids'), + }) + ); + responses[0](response()); + await vi.advanceTimersByTimeAsync(0); + flush(); + expect(observe).not.toHaveBeenCalled(); + expect(old.getStatus().status).toBe('cancelled'); + expect(current.getStatus().status).toBe('waiting_payload'); + expect(refresh).not.toHaveBeenCalled(); + responses[1](response()); + await vi.advanceTimersByTimeAsync(0); + flush(); + assertJoined(); + expect(observe).toHaveBeenCalledTimes(1); + expect(current.getStatus().status).toBe('requested'); + }); + it('observes accepted SPA transport before registered dispatch, not after the automatic DOM wait', async () => { + await runtime(); + flush(); + const payload = { + slots: [ + configured(), + { ...configured('auto'), id: 'missing', div_id: 'missing', ext: undefined }, + ], + bids: { example: bid() }, + trace_auction: selectedTransport('spa_page_bids'), + }; + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify(payload))); + const observe = vi.spyOn(ts.traceGpt!, 'observePageBids'); + history.pushState({}, '', '/example-spa'); + const { native } = mount(); + register(native); + await vi.advanceTimersByTimeAsync(0); + tick(); + assertJoined(); + expect(observe).toHaveBeenCalledTimes(1); + const accepted = ts.publisherSlotRegistrationState!.payload!.slots[0]; + expect(ts.traceGpt!.identity(accepted)).toEqual({ + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + }); + const newer = configured(); + ts.traceGpt!.observeTransport([newer], gptTransport('spa_page_bids'), 'spa_page_bids'); + await vi.advanceTimersByTimeAsync(2000); + flush(); + expect(observe).toHaveBeenCalledTimes(1); + expect(ts.traceGpt!.identity(newer)).toBeDefined(); + expect(refresh).toHaveBeenCalledTimes(1); + }); + }); +} 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 index a68b41ffe6..96efd87b40 100644 --- 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 @@ -101,7 +101,7 @@ describe('scheduleInitialAdInit', () => { vi.unstubAllGlobals(); }); - it('records owned initial evidence after the final generation guard and before adInit', async () => { + it('records accepted initial evidence before registration settlement and deferred adInit', async () => { readyState = 'complete'; await importGptModule(); window.__tsjs_trace_active = true; @@ -141,14 +141,18 @@ describe('scheduleInitialAdInit', () => { expect(ts.bids).toBe(bids); }); ts.scheduleInitialAdInit!(bids, slots, { schema_version: 1, evidence }); - expect(ts.traceEvidence!.snapshot().value?.serverAuctions).toEqual([]); + expect(ts.traceEvidence!.snapshot().value?.serverAuctions).toEqual([evidence]); + expect(ts.traceGpt!.identity(slots[0]!)).toEqual({ + diagnostic_auction_id: AUCTION_TOKEN, + slot_ref: SLOT_TOKEN, + }); flushFrame(); flushFrame(); expect(ts.adInit).toHaveBeenCalledOnce(); expect(ts.traceEvidence!.snapshot().value?.serverAuctions).toEqual([evidence]); }); - it('does not capture an initial transport abandoned before hydration', async () => { + it('retains the accepted transport observation but drops automatic work abandoned before hydration', async () => { readyState = 'complete'; await importGptModule(); window.__tsjs_trace_active = true; @@ -159,9 +163,11 @@ describe('scheduleInitialAdInit', () => { schema_version: 1, unavailable_reason: 'evidence_projection_failed', }); + expect(ts.traceEvidence!.snapshot().value?.issues).toEqual(['evidence_projection_failed']); ts.navGeneration = 1; flushFrame(); flushFrame(); + expect(ts.traceEvidence!.snapshot().value?.issues).toEqual(['evidence_projection_failed']); expect(ts.traceEvidence!.snapshot().value?.serverAuctions).toEqual([]); expect(ts.adInit).not.toHaveBeenCalled(); }); diff --git a/docs/guide/integrations/gpt.md b/docs/guide/integrations/gpt.md index 46c6e2bb85..ce2a80beea 100644 --- a/docs/guide/integrations/gpt.md +++ b/docs/guide/integrations/gpt.md @@ -93,7 +93,7 @@ Successful proxy responses include the `X-GPT-Proxy: true` header for debugging. ## Client-Side Shim -The draft loading candidate uses the existing cacheable unified TSJS bundle. It removes the duplicated inline runtime from the supported GPT profile. This is synchronous loading, not a claim that the main bundle can safely defer. Final acceptance and real-GPT validation remain pending. +The supported loading contract uses the existing cacheable unified TSJS bundle. It removes the duplicated inline runtime from the supported GPT profile. This is synchronous loading, not a claim that the main bundle can safely defer. Synchronous loading and its critical-script outage boundary are explicitly accepted; real-GPT validation and full issue closure remain pending. ### Loading order @@ -116,7 +116,7 @@ Profiles containing another browser integration retain their previous provisiona Enforced `Content-Security-Policy` response headers retain the existing synchronous loading and script permissions. This includes actual origin headers and `Settings.response_headers`, matched case-insensitively, even when empty, permissive or repeated. An origin response without CSP cannot cancel a configured policy. Report-Only remains Report-Only. No policy is weakened and no new compiled-inline hash is required. Shared templates preserve the actual policy on cold and warm paths; settings and core fingerprints invalidate older templates. There is no new meta-policy evaluator or nonce handling. -If the sole critical unified script fails to load in the supported profile, the page has no TS initialization APIs or TS delivery. Core-dependent publisher code can report an error. There is no inline fallback or retry. This extends the previously tested enforced-CSP outage boundary to supported ordinary pages and remains an explicit acceptance decision. It is not equivalent to the earlier design, where a failed deferred GPT asset left the inline renderer working. +If the sole critical unified script fails to load in the supported profile, the page has no TS initialization APIs or TS delivery. Core-dependent publisher code can report an error. There is no inline fallback or retry. This is the accepted outage boundary for supported ordinary and enforced-CSP pages. It is not equivalent to the earlier design, where a failed deferred GPT asset left the inline renderer working. The automatic initializer retains its existing load, visibility and animation-frame checks. Registered delivery still requires actual publisher readiness and payload admission. Compare paint and ad-startup separately; a parser-blocking runtime cannot be described as proven deferral, and lab gains do not establish field Core Web Vitals. @@ -160,15 +160,17 @@ Takes over `googletag.cmd` so every queued callback is wrapped before GPT execut ### Server slot handoff -For automatic placements in the initial server-rendered auction, the head bootstrap installs -`window.tsjs.adInit` before the compiled bundle arrives. It reads the -server-injected slot and bid state synchronously, defines a fallback GPT slot +For automatic placements in the initial server-rendered auction, the synchronous +unified runtime installs `window.tsjs.adInit` before publisher scripts. Profiles +retaining the provisional head bootstrap can install the same API before the +compiled bundle arrives. Both implementations read the server-injected slot and +bid state synchronously and define a fallback GPT slot on the actual inner div, and records the slot in `gptSlotHandoffs`. It never defines a competing slot on an outer `-container` element. When publisher code later calls `googletag.defineSlot` for the same placement, the idempotent wrapper hands the existing inner-div binding back to GPT. -The bootstrap observes modern `googletag.setConfig({ disableInitialLoad: ... })` +Both implementations observe modern `googletag.setConfig({ disableInitialLoad: ... })` and the legacy `pubads().disableInitialLoad()` call. If initial load is disabled, it refreshes only the newly defined Trusted Server slots. It never calls an unbounded `refresh()` that could refresh publisher-owned inventory. @@ -185,8 +187,8 @@ legacy `GET /__ts/page-bids` alias remains available); it does not use Framework publishers can opt individual placements into `initialization = "publisher_registered"`. The default, `auto`, retains the existing automatic initializer and handoff behavior. Registration requires a -matched server, head bootstrap, runtime and publisher adapter. Do not enable it -on older artifacts that ignore the setting. +matched server, runtime and publisher adapter, plus a compatible bootstrap on +profiles that retain one. Do not enable it on older artifacts that ignore the setting. ```toml [[creative_opportunities.slot]] @@ -254,8 +256,8 @@ page-bids fetch do not authorize delivery. An accepted empty payload marks the registration inactive. A page that never emits an admitted seam can remain waiting until the publisher cancels it. -If the framework changes pathname before the full runtime arrives, the bootstrap -advances the same navigation generation and retires the old admission. New-route +On profiles retaining a provisional initializer, if the framework changes pathname +before the full runtime arrives, the bootstrap advances the same navigation generation and retires the old admission. New-route registrations wait without a request. The bootstrap does not fetch page bids. When the runtime takes over, it obtains the current route's payload through its existing SPA endpoint without cancelling the new generation's waiting handles. @@ -414,18 +416,20 @@ finite positive dimensions match both configured and declared eligible formats. Missing, unknown-size or incompatible bids still permit one ordinary GAM request. An empty finite eligibility list permits GAM delivery without TS demand. -The raw inline GPT bootstrap has no creative renderer on its own. Supported -profiles also install the compiled early renderer before publisher scripts, so -registration can select eligible TS demand without waiting for the deferred -runtime. If no renderer was installed, registered delivery omits TS candidate -targeting and correlation and makes the single ordinary GAM request. GAM can fill it with a publisher creative or return -no-fill. TS fill is deliberately unavailable on that path. A later runtime load -does not add the excluded candidate, replace the ad or request it again. +Supported profiles install the renderer through the synchronous unified bundle +before publisher scripts. A retained raw GPT bootstrap alone has no creative +renderer. If no renderer was installed, registered delivery omits TS candidate +targeting and makes the single ordinary GAM request. GAM can fill it with a +publisher creative or return no-fill. TS fill is deliberately unavailable on that +path. A later runtime load does not add the excluded candidate, replace the ad or +request it again. Optional trace diagnostics can still correlate the validated +server-auction opportunity with this no-candidate request; that correlation does +not assert that the excluded bid participated or was selected. #### Activation and verification Publish the matched code with the mode unused first. Verify protocol version, -bootstrap and selected module hashes, publisher setup and explicit ownership. +selected module hashes, any retained bootstrap, publisher setup and explicit ownership. Then activate only selected placements with the matching publisher adapter after a separately approved real-GPT staging canary proves one initial request, correct targeting, visible rendering, an unaffected unrelated slot and a legitimate later diff --git a/docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md b/docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md index 32a3415bcd..e3a8e95033 100644 --- a/docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md +++ b/docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md @@ -1,11 +1,31 @@ # Issue 854 closure acceptance -Status, 2026-11-03: The cacheable synchronous candidate improves the reproduced cold/warm lab timings. **Keep the PR draft. Do not merge or close the issue.** Acceptance of synchronous loading and the changed critical-script outage boundary remains pending, and real-GPT inventory is still missing. The November 2 contract and failed inline/deferred measurements below remain historical evidence. +Current review follow-up: **Synchronous unified loading and its no-fallback critical-script outage boundary are explicitly accepted. Keep the PR draft; do not merge or close the issue while dedicated real-GPT inventory and live proof are missing.** The earlier deferred-loading contract, diagnostic approval flags and measurements below remain historical evidence, not results for the changed runtime and migrated verifier. Issue: https://github.com/IABTechLab/trusted-server/issues/854 PR: https://github.com/IABTechLab/trusted-server/pull/1460 -## November 3 recovery candidate +## Approved loading contract and review follow-up + +The supported GPT profile must load its versioned unified runtime synchronously, after configuration and before publisher scripts. A held critical request blocks parser progress until release; release must restore the complete APIs and one eligible initial request. A failed critical request leaves TS APIs and delivery unavailable, without an inline fallback or retry, on ordinary and enforced-CSP pages. These dependencies and outage behavior are accepted instead of the earlier parser-progress requirement. Separately deferred integrations retain their own ordering. + +Full CWV, framework and real-GPT consumers now target that contract. A `runtime-deferred` negative control deliberately defers only the critical tag and must expose unavailable early publisher APIs and late execution; reservation removal must still reproduce measured layout shift. Test-only diagnostic scopes remain ineligible for full acceptance. + +The review follow-up also repairs registered-slot trace bindings, requires current production-JS source fingerprints and exact exported-runtime/distribution byte agreement, initializes acceptance journals before source capture, and checks creative-marker intersection through the complete frame chain. These changes require fresh compiled artifacts and verification; the earlier byte-equivalence claims do not apply to the changed source. + +### Review follow-up verification + +Evidence: `~/.cache/ts-854-acceptance/review-follow-up-1apsXh/`. The historical measurement JSON is unchanged. + +- All 32 selected verification commands pass: fresh 14-module JS build, 2,195 Vitest tests across 78 files, JS lint/format, docs format/build, Rust formatting, 106 Node tests, native core tests, all eight Clippy aliases, all five adapter test aliases including Fastly reuse, 28 parity cases, native digest test/lint, clock/Node-pin/domain checks, production WASM build/stamp and both production HTML exports. Existing ignored Rust cases remain ignored. Unchanged deadline-runtime, EOF and documentation-proposal fixture commands were not rerun. +- The five declared CWV conditions each pass all 64 samples, totaling 320 samples and 200 repeated measurement observations. Both `runtime-deferred` and `reservation-removed` controls pass in every profile. Real HTTP cache reuse, normal/CSP failure boundaries and explicit recovery assertions remain enforced. +- The initial controller journal remains **failed**, not relabeled: its new framework assertions incorrectly treated exclusive source end offsets as inclusive. A subsequent framework attempt also exposed React reporting the expected publisher failure through its console error boundary rather than an uncaught `pageerror`. Both test assumptions are corrected; the final actual Rust/Next.js gate passes all 20 inline and 20 shared-template cases, plus both real-provider preflights. The interrupted attempt's owned worker, Viceroy and container were cleaned up. +- Passing measurements are reused rather than rerun after these two test-spec corrections. `reuse-proof.json` verifies 523 unchanged captured source files, all 55 immutable input files, current production-JS source/distribution validation and every 64-sample profile. Only the framework/live test specs changed among the captured inputs. The final framework run uses the same frozen WASM, dist, Prebid and prepared configurations. The original failed controller journal remains intact; no new end-to-end controller success is claimed. +- Standalone TypeScript still reports 315 inherited diagnostics, with no new or increased file/code/message signatures. The configured Vitest type assertions pass. Final Node checks and framework/live-spec formatting also pass after the test refinements. + +Dedicated real-GPT inventory remains unavailable. Live delivery, genuine original-main GAM reproduction and deployed behavior are unverified, and `liveAcceptancePassed` remains false. Keep this PR draft and the issue open. No Google traffic, deployment or publisher activation was performed. + +## November 3 recovery candidate (historical checkpoint) The supported GPT profile now uses the existing versioned synchronous unified bundle, with configuration before execution and neither raw nor compiled early bootstrap embedded in HTML. Other browser-module profiles retain their provisional initializer unless actual enforced CSP requires omission. Diagnostics, trace, attribution/APS metadata, reservation and separately deferred integration order remain intact. No production flag, retry, codec or public registration-protocol change is introduced. @@ -30,7 +50,7 @@ The synchronous candidate does not satisfy outcome 1 of the earlier closure cont Evidence is under `/home/pav/.cache/ts-854-acceptance/recovery-2026-11-03/`: `screen-cold/`, `screen-warm/`, `outages/`, `full-contract-rejection/`, `gates/`, `unified-iife.log` and `all-node.log`. The measurement JSON adds `recovery20261103` with all 80 new metric rows, four outage observations, source/report/tooling hashes and the new WASM hash. Its original 200 measurements and historical blocked status are unchanged. The screen used its recorded initial tooling; the later opt-in outage extension is separately tested against the same production bytes, not relabeled as a timing rerun. Both independent read-only cross-reviews found no additional correctness findings in the loader or diagnostic changes. -## Closure contract +## Original closure contract (historical) The PR must demonstrate these outcomes through the production HTML transformation and compiled JavaScript: @@ -98,7 +118,7 @@ node scripts/test-client-cwv-acceptance.mjs \ --runs 5 --out /absolute/new-acceptance-evidence ``` -Keep evidence outside Git. The wrapper snapshots all supplied exports, bundles, WASM/stamps and inventory before execution, passes the selected dist to every consumer, verifies candidate source fingerprints and matching baseline toolchain/dependency pins, and checks that source and snapshotted inputs remain unchanged through completion. +Keep evidence outside Git. The wrapper snapshots all supplied exports, bundles, WASM/stamps and inventory before execution, passes the selected dist to every consumer, verifies complete candidate production-JS source fingerprints, matching baseline toolchain/dependency pins and exact runtime bytes against the selected dist, and checks that source and snapshotted inputs remain unchanged through completion. It invalidates an earlier successful journal before attempting source capture. It runs: @@ -106,7 +126,7 @@ It runs: - Offline registration contracts and explicit legacy automatic-mode diagnostic controls. The latter are not mislabeled as original-main real-GPT evidence. - Actual Rust/Next.js inline and shared-template contracts. - Four comparison arms in five declared conditions: normal cold/warm, slower mobile cold/warm, and a controlled 750 ms runtime hold. Every arm runs at mobile and desktop widths with alternating repeated samples. -- Parser-blocking and reservation-removal negative controls. Warm runs must observe genuine HTTP cache reuse, including the successful runtime, without Playwright routing disabling that cache. +- Critical-runtime deferral and reservation-removal negative controls under the approved synchronous contract. Warm runs must observe genuine HTTP cache reuse, including the successful runtime, without Playwright routing disabling that cache. - Original-main and candidate real-GPT acceptance with actual provider admission, per-slot network membership, public lifecycle correlation and visible creative checks. The slower mobile condition is 4x CPU, 100 ms network latency, 1,600 kbit/s download and 750 kbit/s upload. It is a declared lab condition, not a claim about all mobile visitors. Normal conditions have no artificial runtime hold or throttling. Timing distributions and transfer costs are reported without selecting a favorable sample or introducing an improvement threshold after measurement. diff --git a/scripts/test-client-cwv-acceptance.mjs b/scripts/test-client-cwv-acceptance.mjs index 4101ada3fc..e277cda39c 100644 --- a/scripts/test-client-cwv-acceptance.mjs +++ b/scripts/test-client-cwv-acceptance.mjs @@ -130,7 +130,7 @@ export function validateMeasurement(report, condition, runs) { 8 * runs + 24, 'Every repeated measurement and correctness sample must complete' ) - for (const name of ['parser-blocking', 'reservation-removed']) { + for (const name of ['runtime-deferred', 'reservation-removed']) { assert.equal( report.negativeControls?.[name]?.status, 'passed', @@ -273,7 +273,21 @@ export function validateExportSources(inputs, source, originalSources) { ) for (const [file, hash] of Object.entries(loading)) assert.equal(source.files[file], hash, `Candidate export is stale: ${file}`) - for (const file of ['Cargo.lock', 'rust-toolchain.toml', '.tool-versions']) { + for (const [file, hash] of Object.entries(source.files)) { + if (!file.startsWith('crates/trusted-server-js/lib/src/') || hash === null) + continue + assert.equal( + loading[file], + hash, + `Candidate export is stale or incomplete: ${file}` + ) + } + for (const file of [ + 'Cargo.lock', + 'rust-toolchain.toml', + '.tool-versions', + 'crates/trusted-server-js/lib/package-lock.json', + ]) { assert( baseline[file] && loading[file], `Missing baseline toolchain/dependency fingerprint: ${file}` @@ -286,6 +300,50 @@ export function validateExportSources(inputs, source, originalSources) { } } +/** Bind measured candidate runtime bytes to the same distribution used by framework tests. */ +export function validateExportAssets(inputs, dist) { + const moduleBytes = (id) => readFileSync(resolve(dist, `tsjs-${id}.js`)) + for (const name of ['loading', 'combined']) { + const { manifest, path } = inputs[name] + assert( + manifest.jsModules, + `${name}: export lacks runtime module identities` + ) + const immediate = [ + 'core', + ...manifest.jsModules.immediate.filter((id) => id !== 'core'), + ] + const unified = Buffer.concat( + immediate.flatMap((id, index) => + index === 0 ? [moduleBytes(id)] : [Buffer.from(';\n'), moduleBytes(id)] + ) + ) + const expected = new Map([ + ['/static/tsjs=tsjs-unified.min.js', unified], + ...manifest.jsModules.deferred.map((id) => [ + `/static/tsjs=tsjs-${id}.min.js`, + moduleBytes(id), + ]), + ]) + const observed = new Set() + for (const [src, file] of Object.entries(manifest.assets)) { + const pathname = new URL(src, 'https://publisher.example.com').pathname + assert(expected.has(pathname), `${name}: unexpected runtime asset ${src}`) + assert.equal( + sha256(readFileSync(resolve(dirname(path), file))), + sha256(expected.get(pathname)), + `${name}: exported runtime differs from selected distribution: ${src}` + ) + observed.add(pathname) + } + for (const pathname of expected.keys()) + assert( + observed.has(pathname), + `${name}: missing runtime asset ${pathname}` + ) + } +} + function sourceState() { const files = git([ 'ls-files', @@ -343,12 +401,11 @@ function unitTests() { export function runAcceptance(requestedOptions) { let options = requestedOptions mkdirSync(options.out, { recursive: true }) - const state = sourceState() const report = { schemaVersion: 1, status: 'running', startedAt: new Date().toISOString(), - source: state, + source: null, conditions, steps: [], } @@ -395,6 +452,8 @@ export function runAcceptance(requestedOptions) { } } try { + const state = sourceState() + report.source = state const snapshot = snapshotInputs(options) options = snapshot.options report.requestedInputs = requestedOptions @@ -432,6 +491,7 @@ export function runAcceptance(requestedOptions) { sha256(readFileSync(resolve(options.dist, name))), ]) ) + validateExportAssets(report.inputs, options.dist) save() step('units', process.execPath, ['--test', ...unitTests()]) step('offline-registration', process.execPath, [ diff --git a/scripts/test-client-cwv-acceptance.test.mjs b/scripts/test-client-cwv-acceptance.test.mjs index ea0ce64a01..9bec96e072 100644 --- a/scripts/test-client-cwv-acceptance.test.mjs +++ b/scripts/test-client-cwv-acceptance.test.mjs @@ -9,6 +9,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { spawnSync } from 'node:child_process' import { BASELINE_REVISION, conditions, @@ -16,6 +17,7 @@ import { measurementArgs, snapshotInputs, validateExportSources, + validateExportAssets, validateMeasurement, } from './test-client-cwv-acceptance.mjs' @@ -107,7 +109,7 @@ test('a successful command without completed negative controls is insufficient e }, samples: 64, negativeControls: { - 'parser-blocking': { status: 'passed' }, + 'runtime-deferred': { status: 'passed' }, 'reservation-removed': { status: 'passed' }, }, } @@ -143,10 +145,15 @@ test('candidate exports must match current source and share baseline toolchain/d 'crates/trusted-server-core/src/tsjs.rs', 'crates/trusted-server-core/src/html_processor.rs', 'crates/trusted-server-core/src/integrations/gpt_bootstrap.js', + 'crates/trusted-server-js/lib/package-lock.json', + 'crates/trusted-server-js/lib/src/core/index.ts', + 'crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts', ].map((file) => [file, 'a'.repeat(64)]) ) const originalSources = Object.fromEntries( - Object.entries(files).filter(([file]) => file.startsWith('crates/')) + Object.entries(files).filter(([file]) => + file.startsWith('crates/trusted-server-core/') + ) ) const manifest = { sourceRevision: BASELINE_REVISION, @@ -161,6 +168,57 @@ test('candidate exports must match current source and share baseline toolchain/d ]) ) validateExportSources(inputs, { files }, originalSources) + const jsSource = + 'crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts' + const withoutJs = { + ...manifest, + sourceFingerprints: manifest.sourceFingerprints + .split('\n') + .filter((line) => !line.includes('crates/trusted-server-js/lib/src/')) + .join('\n'), + } + assert.throws( + () => + validateExportSources( + { + ...inputs, + loading: { manifest: withoutJs }, + combined: { manifest: withoutJs }, + }, + { files }, + originalSources + ), + /stale or incomplete/ + ) + assert.throws( + () => + validateExportSources( + inputs, + { files: { ...files, [jsSource]: 'b'.repeat(64) } }, + originalSources + ), + /stale/ + ) + assert.throws( + () => + validateExportSources( + inputs, + { + files: { + ...files, + 'crates/trusted-server-js/lib/src/new.ts': 'b'.repeat(64), + }, + }, + originalSources + ), + /stale or incomplete/ + ) + const deletedSource = 'crates/trusted-server-js/lib/src/deleted.ts' + validateExportSources( + inputs, + { files: { ...files, [deletedSource]: null } }, + originalSources + ) assert.throws( () => validateExportSources( @@ -203,6 +261,113 @@ test('candidate exports must match current source and share baseline toolchain/d ) }) +test('exported runtime bytes must match the selected distribution, not only current source metadata', () => { + const directory = mkdtempSync(join(tmpdir(), 'cwv-acceptance-assets-')) + try { + const dist = join(directory, 'dist') + mkdirSync(dist) + for (const id of ['core', 'creative', 'gpt', 'prebid']) + writeFileSync(join(dist, `tsjs-${id}.js`), `// current ${id}\n`) + const inputs = {} + for (const name of ['loading', 'combined']) { + const out = join(directory, name) + mkdirSync(out) + writeFileSync( + join(out, 'runtime.js'), + '// current core\n;\n// current creative\n;\n// current gpt\n' + ) + writeFileSync(join(out, 'prebid.js'), '// current prebid\n') + inputs[name] = { + path: join(out, 'manifest.json'), + manifest: { + jsModules: { immediate: ['creative', 'gpt'], deferred: ['prebid'] }, + assets: { + '/static/tsjs=tsjs-unified.min.js?v=fixture': 'runtime.js', + '/static/tsjs=tsjs-prebid.min.js?v=fixture': 'prebid.js', + }, + }, + } + } + validateExportAssets(inputs, dist) + writeFileSync( + join(directory, 'loading', 'runtime.js'), + '// stale prebuilt runtime' + ) + assert.throws( + () => validateExportAssets(inputs, dist), + /differs from selected distribution/ + ) + writeFileSync( + join(directory, 'loading', 'runtime.js'), + readFileSync(join(directory, 'combined', 'runtime.js')) + ) + writeFileSync( + join(dist, 'tsjs-prebid.js'), + '// replacement deferred runtime' + ) + assert.throws( + () => validateExportAssets(inputs, dist), + /differs from selected distribution/ + ) + writeFileSync(join(dist, 'tsjs-prebid.js'), '// current prebid\n') + const moduleIds = inputs.loading.manifest.jsModules + delete inputs.loading.manifest.jsModules + assert.throws( + () => validateExportAssets(inputs, dist), + /lacks runtime module identities/ + ) + inputs.loading.manifest.jsModules = moduleIds + delete inputs.loading.manifest.assets[ + '/static/tsjs=tsjs-prebid.min.js?v=fixture' + ] + assert.throws( + () => validateExportAssets(inputs, dist), + /missing runtime asset/ + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test('source-capture failure replaces a previous passing acceptance journal', () => { + const directory = mkdtempSync(join(tmpdir(), 'cwv-acceptance-journal-')) + try { + const file = join(directory, 'acceptance.json') + writeFileSync( + file, + JSON.stringify({ status: 'passed', functionalGatesPassed: true }) + ) + const controller = new URL( + './test-client-cwv-acceptance.mjs', + import.meta.url + ).href + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { runAcceptance } from ${JSON.stringify(controller)}; + const result = runAcceptance({ out: process.argv[1] }); + if (result.status !== 'failed') process.exitCode = 1;`, + directory, + ], + { + encoding: 'utf8', + env: { ...process.env, PATH: join(directory, 'missing-tools') }, + } + ) + assert.equal(result.status, 0, result.stderr) + const report = JSON.parse(readFileSync(file, 'utf8')) + assert.equal(report.status, 'failed') + assert.equal(report.functionalGatesPassed, false) + assert.equal(report.source, null) + assert.equal(report.steps[0].name, 'acceptance-inputs') + assert.equal(report.steps[0].status, 'failed') + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + test('every external input is frozen, including HTML, runtime, WASM stamps and inventory', () => { const directory = mkdtempSync(join(tmpdir(), 'cwv-acceptance-inputs-')) try { From c0f77a2c563bd8171f07dfcfa016e2ced0fc58cc Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 3 Nov 2026 14:30:15 -0600 Subject: [PATCH 13/13] Preserve SPA retries and share GPT protocol logic Keep pending publisher registrations in the same navigation generation on explicit page-bids retries, while real pathname changes still retire old work. Cover the failure and recovery through source and browser regressions. Generate the existing standalone bootstrap from shared registration and targeting-cleanup source without adding a runtime artifact or dependency. Share synchronous-runtime ordering assertions across framework/live specs. Validation includes 2,215 JS tests, 136 Node tests, 111 offline browser cases, 26 CWV cases, 40 Rust/Next.js cases, core and adapter tests, all Clippy aliases and the production WASM build. Standalone TypeScript keeps 315 inherited diagnostics with no new signatures. Real-GPT delivery and full repeated performance acceptance remain unverified. --- .../src/integrations/gpt_bootstrap.js | 2045 +++++++---------- .../src/integrations/registry.rs | 4 +- .../browser/cwv/runtime-order.cjs | 42 + .../browser/cwv/runtime-order.test.cjs | 80 + .../browser/publisher-registration/README.md | 6 +- .../browser/publisher-registration/run.cjs | 81 +- .../publisher-registration.real-gpt.spec.ts | 65 +- .../nextjs/publisher-registration.spec.ts | 63 +- crates/trusted-server-js/README.md | 13 + crates/trusted-server-js/build.rs | 2 + crates/trusted-server-js/lib/build-all.mjs | 4 + .../lib/build-gpt-bootstrap.mjs | 62 + crates/trusted-server-js/lib/package.json | 1 + .../integrations/gpt/bootstrap.template.js | 978 ++++++++ .../lib/src/integrations/gpt/index.ts | 117 +- .../gpt/publisher_registration.ts | 79 +- .../lib/test/build-gpt-bootstrap.test.mjs | 65 + .../publisher_registration_contract.ts | 102 +- .../integrations/gpt/gpt_bootstrap.test.ts | 2 +- .../test/integrations/gpt/spa_hook.test.ts | 19 +- .../test/integrations/prebid/index.test.ts | 4 +- 21 files changed, 2333 insertions(+), 1501 deletions(-) create mode 100644 crates/trusted-server-integration-tests/browser/cwv/runtime-order.cjs create mode 100644 crates/trusted-server-integration-tests/browser/cwv/runtime-order.test.cjs create mode 100644 crates/trusted-server-js/lib/build-gpt-bootstrap.mjs create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt/bootstrap.template.js create mode 100644 crates/trusted-server-js/lib/test/build-gpt-bootstrap.test.mjs diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index c9547af52a..a1b0b6974b 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -1,352 +1,80 @@ -// Edge-injected GPT auction bootstrap. -// -// This is the minimal `window.tsjs.adInit` that runs on first page load -// before the TSJS bundle has had a chance to install its richer -// idempotent implementation. The bundle in -// crates/trusted-server-js/lib/src/integrations/gpt/index.ts overwrites `tsjs.adInit` -// once it loads. -// -// Contract with the bundle: -// - Both implementations must set `window.tsjs.servicesEnabled = true` -// after calling `enableSingleRequest()`/`enableServices()` so a -// subsequent call becomes a no-op. -// - `refresh()` is called only for the slots defined in this pass, -// never the global slot list. -// -// Only installed if `window.tsjs.adInit` isn't already defined. -(function () { - if (typeof window === "undefined") return; - var ts = (window.tsjs = window.tsjs || {}); - var tag; - - if (window.__tsjs_gam_attribution_enabled === true) { - tag = window.googletag = window.googletag || { cmd: [] }; - tag.cmd = tag.cmd || []; - tag.cmd.push(function () { - try { - var gpt = window.googletag; - if (gpt && typeof gpt.setConfig === "function") { - // "ts" is the fixed GAM key, not the local window.tsjs alias. - gpt.setConfig({ targeting: { ts: "true" } }); - } - } catch (error) { - // Attribution must not interrupt the existing bootstrap queue. - ts.log && - ts.log.warn && - ts.log.warn("GAM attribution targeting failed", error); - } - }); +// Generated from trusted-server-js/lib/src/integrations/gpt/bootstrap.template.js +// and publisher_registration.ts. Do not edit this file directly. +// Regenerate: cd crates/trusted-server-js/lib && npm run build:gpt-bootstrap -- --write +(function() { + "use strict"; + const CANDIDATE_KEYS = ["hb_pb", "hb_bidder", "hb_adid", "hb_cache_host", "hb_cache_path"]; + const BASE_TARGETING_KEYS = [...CANDIDATE_KEYS, "ts_initial"]; + function registrationTag() { + return window.googletag; } - - if (ts.adInit) return; - - // Track whether the publisher disabled GPT initial load. Read the effective - // googletag.getConfig() value when available, and wrap googletag.setConfig() - // and the legacy pubads().disableInitialLoad() method so changes are - // synchronized immediately and still detected when getConfig() is - // unavailable. With initial load disabled, display() only registers a slot - // and the ad request must come from a later refresh(); adInit() reads this to - // refresh its own freshly defined - // slots so they are not left blank. Pushed onto the command queue so it runs - // before the publisher's own GPT configuration. - function syncInitialLoadDisabled(gpt) { - if (typeof gpt.getConfig !== "function") return false; - var config = gpt.getConfig("disableInitialLoad"); - if (!config || typeof config.disableInitialLoad === "undefined") { - return false; + function clearPreviousTargetingSnapshot(g, snapshot) { + var _a, _b, _c; + const pubads = (_a = g.pubads) == null ? void 0 : _a.call(g); + if (!pubads) return false; + const liveSlots = (_c = (_b = pubads.getSlots) == null ? void 0 : _b.call(pubads)) != null ? _c : []; + for (const [slot, keys] of snapshot) { + if (!liveSlots.includes(slot)) continue; + if (typeof slot.clearTargeting !== "function") continue; + for (const key of new Set(keys)) slot.clearTargeting(key); } - ts.gptInitialLoadDisabled = config.disableInitialLoad === true; return true; } - - tag = tag || (window.googletag = window.googletag || { cmd: [] }); - tag.cmd = tag.cmd || []; - tag.cmd.push(function () { - var gpt = window.googletag; - syncInitialLoadDisabled(gpt); - if ( - typeof gpt.setConfig === "function" && - !gpt.__tsInitialLoadConfigHooked - ) { - var originalSetConfig = gpt.setConfig.bind(gpt); - gpt.setConfig = function (config) { - var result = originalSetConfig.apply(gpt, arguments); - if ( - !syncInitialLoadDisabled(gpt) && - config && - "disableInitialLoad" in config - ) { - ts.gptInitialLoadDisabled = config.disableInitialLoad === true; - } - return result; - }; - gpt.__tsInitialLoadConfigHooked = true; - } - - var pubads = gpt.pubads && gpt.pubads(); - if ( - !pubads || - typeof pubads.disableInitialLoad !== "function" || - pubads.__tsInitialLoadHooked - ) { - return; - } - var originalDisableInitialLoad = pubads.disableInitialLoad.bind(pubads); - pubads.disableInitialLoad = function () { - var result = originalDisableInitialLoad.apply(pubads, arguments); - if (!syncInitialLoadDisabled(gpt)) { - ts.gptInitialLoadDisabled = true; - } - return result; - }; - pubads.__tsInitialLoadHooked = true; - }); - - var FIRST_IMPRESSION_LEASE_MS = 5000; - var MAX_FIRST_IMPRESSION_SLOTS = 256; - - function firstImpressionState(now) { - var generation = ts.navGeneration || 0; - if (!ts.firstImpression || ts.firstImpression.generation !== generation) { - ts.firstImpression = { - generation: generation, - nextToken: 0, - slots: Object.create(null), - fallbackSlots: Object.create(null), - }; - } - var state = ts.firstImpression; - state.slots = Object.assign(Object.create(null), state.slots); - state.fallbackSlots = Object.assign( - Object.create(null), - state.fallbackSlots, + function clearPreviousNavigationTargeting(ts, g) { + var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j; + const previousKeys = Object.assign( + /* @__PURE__ */ Object.create(null), + ts.prevSlotTargetingKeys ); - Object.keys(state.slots).forEach(function (elementId) { - var claim = state.slots[elementId]; - if ( - claim.generation !== generation || - claim.slotElementId !== elementId || - claim.element.ownerDocument !== document || - claim.element !== document.getElementById(elementId) || - !claim.element.isConnected - ) { - delete state.slots[elementId]; - return; - } - var hasReservedFallback = - claim.owner === "publisher" && - (claim.phase === "auctioning" || claim.phase === "delivery_pending") && - state.fallbackSlots[elementId] === claim.element; - Object.keys(claim.publisherAuctions || {}).forEach(function (token) { - var auction = claim.publisherAuctions[token]; - if ( - auction.expiresAt <= now && - !hasReservedFallback && - !(claim.owner === "trusted_server" && auction.suppressDelivery) - ) { - delete claim.publisherAuctions[token]; - } - }); - if ( - claim.owner === "publisher" && - (claim.phase === "auctioning" || claim.phase === "delivery_pending") && - Object.keys(claim.publisherAuctions || {}).length === 0 && - claim.expiresAt <= now && - !hasReservedFallback - ) { - delete state.slots[elementId]; - } - }); - Object.keys(state.fallbackSlots).forEach(function (elementId) { - var element = state.fallbackSlots[elementId]; - if ( - !element.isConnected || - element.id !== elementId || - document.getElementById(elementId) !== element - ) { - delete state.fallbackSlots[elementId]; + const registeredRecords = Object.values((_b = (_a = ts.publisherSlotRegistrationState) == null ? void 0 : _a.records) != null ? _b : {}).filter( + (record) => record.consumed && record.targetingKeys + ); + for (const record of registeredRecords) previousKeys[record.elementId] = record.targetingKeys; + const touchedElementIds = /* @__PURE__ */ new Set([ + ...Object.keys(previousKeys), + ...Object.keys((_c = ts.divToSlotId) != null ? _c : {}) + ]); + const snapshot = /* @__PURE__ */ new Map(); + const registeredElementIds = new Set(registeredRecords.map((record) => record.elementId)); + for (const record of registeredRecords) { + if (record.gptSlot) { + snapshot.set(record.gptSlot, [...BASE_TARGETING_KEYS, ...record.targetingKeys]); } - }); - return state; - } - - function firstImpressionClaim(element) { - return firstImpressionState(Date.now()).slots[element.id]; - } - - function storeFirstImpressionClaim(state, claim) { - if ( - !state.slots[claim.slotElementId] && - Object.keys(state.slots).length >= MAX_FIRST_IMPRESSION_SLOTS - ) { - return false; } - state.slots[claim.slotElementId] = claim; - return true; - } - - function schedulePendingRenderDiagnostic(claim) { - var startedAt = Date.now(); - window.setTimeout(function () { - if ( - !ts.firstImpression || - ts.firstImpression.slots[claim.slotElementId] !== claim || - claim.generation !== (ts.navGeneration || 0) || - !claim.element.isConnected || - document.getElementById(claim.slotElementId) !== claim.element || - claim.owner !== "trusted_server" || - claim.phase === "rendered" - ) - return; - var diagnostic = { phase: claim.phase, ageMs: Date.now() - startedAt }; - claim.pendingRenderDiagnostic = diagnostic; - try { - if (ts.log && ts.log.debug) { - ts.log.debug("[tsjs-gpt] initial render remains pending", { - slot: claim.slotElementId, - generation: claim.generation, - phase: diagnostic.phase, - ageMs: diagnostic.ageMs, - }); - } - } catch (error) { - // Optional logging must never affect initial ownership or delivery. + for (const slot of [ + ...(_g = (_f = (_d = g.pubads) == null ? void 0 : (_e = _d.call(g)).getSlots) == null ? void 0 : _f.call(_e)) != null ? _g : [], + ...(_h = ts.prevGptSlots) != null ? _h : [] + ]) { + const elementId = slot.getSlotElementId(); + if (touchedElementIds.has(elementId) && !registeredElementIds.has(elementId)) { + snapshot.set(slot, [...BASE_TARGETING_KEYS, ...(_i = previousKeys[elementId]) != null ? _i : []]); } - }, FIRST_IMPRESSION_LEASE_MS); - } - - function claimFirstImpressionForTrustedServer(element) { - var now = Date.now(); - var state = firstImpressionState(now); - var existing = state.slots[element.id]; - if (existing) { - var canTransitionPublisherFallback = - existing.owner === "publisher" && - existing.phase !== "requested" && - existing.phase !== "rendered" && - existing.expiresAt <= now && - state.fallbackSlots[element.id] === element; - if (!canTransitionPublisherFallback) return null; - existing.owner = "trusted_server"; - existing.phase = "delivery_pending"; - // Deliberately unbounded, matching first_impression.ts: only render, - // navigation, or element replacement ends TS initial ownership. - existing.expiresAt = Number.POSITIVE_INFINITY; - Object.keys(existing.publisherAuctions || {}).forEach(function (token) { - existing.publisherAuctions[token].suppressDelivery = true; - }); - schedulePendingRenderDiagnostic(existing); - return existing; } - var claim = { - generation: state.generation, - slotElementId: element.id, - element: element, - owner: "trusted_server", - phase: "delivery_pending", - // Unbounded by contract; see the fallback transition above. - expiresAt: Number.POSITIVE_INFINITY, - publisherAuctions: Object.create(null), - }; - if (!storeFirstImpressionClaim(state, claim)) return null; - schedulePendingRenderDiagnostic(claim); - return claim; - } - - function releaseTrustedServerFirstImpressionClaim(element, claim) { - var state = firstImpressionState(Date.now()); - if ( - state.slots[element.id] === claim && - claim.owner === "trusted_server" && - claim.phase === "delivery_pending" - ) { - delete state.slots[element.id]; - if (state.fallbackSlots[element.id] === element) { - delete state.fallbackSlots[element.id]; - } + if (snapshot.size > 0 && !clearPreviousTargetingSnapshot(g, snapshot)) { + (_j = g.cmd) == null ? void 0 : _j.push(() => { + clearPreviousTargetingSnapshot(g, snapshot); + }); } - } - - function installFirstImpressionListeners() { - if (ts.firstImpressionListenersInstalled) return; - tag.cmd.push(function () { - if (ts.firstImpressionListenersInstalled) return; - var pubads = window.googletag.pubads(); - if (!pubads || typeof pubads.addEventListener !== "function") return; - var observe = function (phase) { - return function (event) { - var elementId = - event.slot && event.slot.getSlotElementId - ? event.slot.getSlotElementId() - : ""; - var element = elementId && document.getElementById(elementId); - if (!element) return; - if (phase === "rendered") observePublisherSlotRender(ts, event.slot); - var state = firstImpressionState(Date.now()); - var claim = state.slots[elementId]; - if (!claim) { - storeFirstImpressionClaim(state, { - generation: state.generation, - slotElementId: elementId, - element: element, - owner: "publisher", - phase: phase, - expiresAt: Number.POSITIVE_INFINITY, - publisherAuctions: Object.create(null), - }); - return; - } - if (claim.phase !== "rendered") claim.phase = phase; - if (claim.owner === "publisher") { - claim.expiresAt = Number.POSITIVE_INFINITY; - } else if (phase === "rendered") { - claim.publisherRegistrationClosed = true; - } - }; - }; - pubads.addEventListener("slotRequested", observe("requested")); - pubads.addEventListener("slotRenderEnded", observe("rendered")); - ts.firstImpressionListenersInstalled = true; - }); - } - - installFirstImpressionListeners(); - - // Protocol v1 deliberately mirrors publisher_registration.ts; the shared executable contract checks both. - const CANDIDATE_KEYS = [ - "hb_pb", - "hb_bidder", - "hb_adid", - "hb_cache_host", - "hb_cache_path", - ]; - function registrationTag() { - return window.googletag; + ts.prevSlotTargetingKeys = /* @__PURE__ */ Object.create(null); + ts.divToSlotId = /* @__PURE__ */ Object.create(null); } function invalidatePublisherSlotRegistrations(ts) { var _a, _b, _c; const previous = ts.publisherSlotRegistrationState; - for (const record of Object.values( - (_a = previous == null ? void 0 : previous.records) != null ? _a : {}, - )) { + for (const record of Object.values((_a = previous == null ? void 0 : previous.records) != null ? _a : {})) { record.status = { status: "cancelled", reason: "navigation" }; record.element = void 0; record.gptSlot = void 0; } ts.publisherSlotRegistrationState = { generation: (_b = ts.navGeneration) != null ? _b : 0, - nextToken: - (_c = previous == null ? void 0 : previous.nextToken) != null ? _c : 0, - records: /* @__PURE__ */ Object.create(null), + nextToken: (_c = previous == null ? void 0 : previous.nextToken) != null ? _c : 0, + records: /* @__PURE__ */ Object.create(null) }; } function registrationState(ts) { var _a, _b; - if ( - ((_a = ts.publisherSlotRegistrationState) == null - ? void 0 - : _a.generation) !== ((_b = ts.navGeneration) != null ? _b : 0) - ) { + if (((_a = ts.publisherSlotRegistrationState) == null ? void 0 : _a.generation) !== ((_b = ts.navGeneration) != null ? _b : 0)) { invalidatePublisherSlotRegistrations(ts); } return ts.publisherSlotRegistrationState; @@ -368,64 +96,33 @@ function observePublisherSlotRender(ts, native) { var _a, _b; const state = ts.publisherSlotRegistrationState; - if ( - (state == null ? void 0 : state.generation) !== - ((_a = ts.navGeneration) != null ? _a : 0) - ) - return; + if ((state == null ? void 0 : state.generation) !== ((_a = ts.navGeneration) != null ? _a : 0)) return; for (const record of Object.values(state.records)) { - if ( - record.gptSlot === native && - record.consumed && - ((_b = record.element) == null ? void 0 : _b.isConnected) && - record.element.id === record.elementId && - document.getElementById(record.elementId) === record.element && - (record.status.status === "requested" || - record.status.status === "dispatching") - ) { + if (record.gptSlot === native && record.consumed && ((_b = record.element) == null ? void 0 : _b.isConnected) && record.element.id === record.elementId && document.getElementById(record.elementId) === record.element && (record.status.status === "requested" || record.status.status === "dispatching")) { record.status = { status: "rendered" }; } } } function finiteSize(size) { - return ( - Array.isArray(size) && - size.length === 2 && - size.every( - (value) => - typeof value === "number" && Number.isInteger(value) && value > 0, - ) + return Array.isArray(size) && size.length === 2 && size.every( + (value) => typeof value === "number" && Number.isInteger(value) && value > 0 ); } function normalizedFormats(formats) { return [ - ...new Map( - formats.map((size) => [size.join("x"), [size[0], size[1]]]), - ).values(), + ...new Map(formats.map((size) => [size.join("x"), [size[0], size[1]]])).values() ].sort((left, right) => left[0] - right[0] || left[1] - right[1]); } function loadPolicy(ts, tag) { var _a, _b; try { - const effective = - (_b = - (_a = tag == null ? void 0 : tag.getConfig) == null - ? void 0 - : _a.call(tag, "disableInitialLoad")) == null - ? void 0 - : _b.disableInitialLoad; + const effective = (_b = (_a = tag == null ? void 0 : tag.getConfig) == null ? void 0 : _a.call(tag, "disableInitialLoad")) == null ? void 0 : _b.disableInitialLoad; if (effective !== void 0) ts.gptInitialLoadDisabled = effective === true; - if ( - effective === true || - (effective === void 0 && ts.gptInitialLoadDisabled === true) - ) + if (effective === true || effective === void 0 && ts.gptInitialLoadDisabled === true) return; return { status: "rejected", - reason: - effective === false || ts.gptInitialLoadDisabled === false - ? "initial_load_enabled" - : "initial_load_unknown", + reason: effective === false || ts.gptInitialLoadDisabled === false ? "initial_load_enabled" : "initial_load_unknown" }; } catch (e) { return { status: "rejected", reason: "initial_load_unknown" }; @@ -435,11 +132,7 @@ const element = document.getElementById(id); if (!id || !(element == null ? void 0 : element.isConnected)) return { error: { status: "rejected", reason: "missing_element" } }; - if ( - Array.from(document.querySelectorAll("[id]")).filter( - (candidate) => candidate.id === id, - ).length !== 1 - ) { + if (Array.from(document.querySelectorAll("[id]")).filter((candidate) => candidate.id === id).length !== 1) { return { error: { status: "rejected", reason: "duplicate_dom_id" } }; } return { element }; @@ -448,29 +141,22 @@ return { getStatus: () => { var _a; - if ( - record.generation !== ((_a = ts.navGeneration) != null ? _a : 0) && - !record.consumed - ) { + if (record.generation !== ((_a = ts.navGeneration) != null ? _a : 0) && !record.consumed) { record.status = { status: "cancelled", reason: "navigation" }; } return { ...record.status }; }, cancel: () => { - if ( - record.status.status === "rejected" || - record.status.status === "cancelled" - ) - return; + if (record.status.status === "rejected" || record.status.status === "cancelled") return; record.status = { status: "cancelled", reason: "explicit" }; if (!record.consumed) { record.element = void 0; record.gptSlot = void 0; } - }, + } }; } - function installPublisherSlotRegistration(ts, claim) { + function installPublisherSlotRegistration(ts, claim, getTraceBridge) { registrationState(ts); ts.publisherSlotRegistrationVersion = 1; ts.registerPublisherSlot = (options) => { @@ -478,23 +164,12 @@ const state = registrationState(ts); const rejected = (status) => ({ getStatus: () => ({ ...status }), - cancel: () => {}, + cancel: () => { + } }); - if ( - !options || - typeof options.slotId !== "string" || - !options.slotId || - !Number.isInteger(options.navigationGeneration) || - !Array.isArray(options.eligibleFormats) || - !options.eligibleFormats.every(finiteSize) || - !options.gptSlot || - ![ - "getSlotElementId", - "getAdUnitPath", - "setTargeting", - "clearTargeting", - ].every((key) => typeof options.gptSlot[key] === "function") - ) { + if (!options || typeof options.slotId !== "string" || !options.slotId || !Number.isInteger(options.navigationGeneration) || !Array.isArray(options.eligibleFormats) || !options.eligibleFormats.every(finiteSize) || !options.gptSlot || !["getSlotElementId", "getAdUnitPath", "setTargeting", "clearTargeting"].every( + (key) => typeof options.gptSlot[key] === "function" + )) { return rejected({ status: "rejected", reason: "invalid_arguments" }); } if (options.navigationGeneration !== state.generation) @@ -509,47 +184,23 @@ adUnitPath = options.gptSlot.getAdUnitPath(); if (typeof elementId !== "string" || typeof adUnitPath !== "string") throw new Error("invalid slot identity"); - if ( - !((_c = - (_a = tag == null ? void 0 : tag.pubads) == null - ? void 0 - : (_b = _a.call(tag)).getSlots) == null - ? void 0 - : _c.call(_b).includes(options.gptSlot)) - ) - return rejected({ - status: "rejected", - reason: "native_slot_missing", - }); + if (!((_c = (_a = tag == null ? void 0 : tag.pubads) == null ? void 0 : (_b = _a.call(tag)).getSlots) == null ? void 0 : _c.call(_b).includes(options.gptSlot))) + return rejected({ status: "rejected", reason: "native_slot_missing" }); } catch (e) { return rejected({ status: "rejected", reason: "invalid_arguments" }); } const { element, error } = exactElement(elementId); if (error || !element) return rejected(error); - const configured = - (_d = state.payload) == null - ? void 0 - : _d.slots.find((slot) => slot.id === options.slotId); + const configured = (_d = state.payload) == null ? void 0 : _d.slots.find((slot) => slot.id === options.slotId); if (configured && configured.gam_unit_path !== adUnitPath) { return rejected({ status: "rejected", reason: "path_mismatch" }); } const eligibleFormats = normalizedFormats(options.eligibleFormats); const previous = state.records[options.slotId]; - if ( - previous && - (previous.consumed || previous.status.status !== "cancelled") - ) { - if ( - previous.gptSlot === options.gptSlot && - previous.element === element && - JSON.stringify(previous.eligibleFormats) === - JSON.stringify(eligibleFormats) - ) + if (previous && (previous.consumed || previous.status.status !== "cancelled")) { + if (previous.gptSlot === options.gptSlot && previous.element === element && JSON.stringify(previous.eligibleFormats) === JSON.stringify(eligibleFormats)) return registrationHandle(ts, previous); - return rejected({ - status: "rejected", - reason: "conflicting_registration", - }); + return rejected({ status: "rejected", reason: "conflicting_registration" }); } const record = { generation: state.generation, @@ -562,35 +213,26 @@ eligibleFormats, status: { status: "waiting_payload" }, consumed: false, - queued: false, + queued: false }; state.records[record.slotId] = record; - (_e = ts.publisherSlotRegistrationDispatch) == null - ? void 0 - : _e.call(ts); + (_e = ts.publisherSlotRegistrationDispatch) == null ? void 0 : _e.call(ts); return registrationHandle(ts, record); }; ts.publisherSlotRegistrationDispatch = (readyRecord) => { const state = registrationState(ts); - const current = (record) => - state.records[record.slotId] === record && - record.generation === state.generation && - !record.consumed && - (record.status.status === "waiting_payload" || - record.status.status === "waiting_delivery"); + const current = (record) => state.records[record.slotId] === record && record.generation === state.generation && !record.consumed && (record.status.status === "waiting_payload" || record.status.status === "waiting_delivery"); const dispatch = (record) => { - var _a, _b, _c, _d, _e; + var _a, _b, _c, _d, _e, _f, _g; if (!current(record)) return; record.queued = false; const payload = state.payload; if (!payload || payload.generation !== state.generation) return; - const slot = payload.slots.find( - (candidate) => candidate.id === record.slotId, - ); + const slot = payload.slots.find((candidate) => candidate.id === record.slotId); if (!slot || slot.initialization !== "publisher_registered") { record.status = { status: "inactive", - reason: slot ? "not_registered_mode" : "not_admitted", + reason: slot ? "not_registered_mode" : "not_admitted" }; return; } @@ -601,36 +243,17 @@ return; } const identity = exactElement(record.elementId); - if ( - !record.element || - identity.element !== record.element || - record.element.id !== record.elementId - ) { + if (!record.element || identity.element !== record.element || record.element.id !== record.elementId) { record.status = { status: "cancelled", reason: "element_changed" }; return; } const native = record.gptSlot; - const service = - (_a = tag == null ? void 0 : tag.pubads) == null - ? void 0 - : _a.call(tag); - if ( - !native || - !((_b = service == null ? void 0 : service.getSlots) == null - ? void 0 - : _b.call(service).includes(native)) || - native.getSlotElementId() !== record.elementId - ) { - record.status = { - status: "cancelled", - reason: "native_slot_missing", - }; + const service = (_a = tag == null ? void 0 : tag.pubads) == null ? void 0 : _a.call(tag); + if (!native || !((_b = service == null ? void 0 : service.getSlots) == null ? void 0 : _b.call(service).includes(native)) || native.getSlotElementId() !== record.elementId) { + record.status = { status: "cancelled", reason: "native_slot_missing" }; return; } - if ( - native.getAdUnitPath() !== record.adUnitPath || - record.adUnitPath !== slot.gam_unit_path - ) { + if (native.getAdUnitPath() !== record.adUnitPath || record.adUnitPath !== slot.gam_unit_path) { record.status = { status: "rejected", reason: "path_mismatch" }; return; } @@ -649,93 +272,48 @@ const wasRefreshing = ts.adInitRefreshInProgress; try { const bid = payload.bids[record.slotId]; - const fits = - bid && - finiteSize([bid.w, bid.h]) && - slot.formats.some( - (size) => size[0] === bid.w && size[1] === bid.h, - ) && - record.eligibleFormats.some( - (size) => size[0] === bid.w && size[1] === bid.h, - ); - const candidate = - ts.tsRenderBridgeVersion === 1 && fits ? bid : void 0; + const fits = bid && finiteSize([bid.w, bid.h]) && slot.formats.some((size) => size[0] === bid.w && size[1] === bid.h) && record.eligibleFormats.some((size) => size[0] === bid.w && size[1] === bid.h); + const candidate = ts.tsRenderBridgeVersion === 1 && fits ? bid : void 0; record.selectedCandidate = candidate; if (!candidate) - record.noCandidateReason = !bid - ? "no_bid" - : ts.tsRenderBridgeVersion !== 1 - ? "renderer_unavailable" - : "incompatible_dimensions"; - const previousKeys = - (_d = - (_c = ts.prevSlotTargetingKeys) == null - ? void 0 - : _c[record.elementId]) != null - ? _d - : []; - for (const key of /* @__PURE__ */ new Set([ - ...CANDIDATE_KEYS, - "ts_initial", - ...previousKeys, - ])) + record.noCandidateReason = !bid ? "no_bid" : ts.tsRenderBridgeVersion !== 1 ? "renderer_unavailable" : "incompatible_dimensions"; + const previousKeys = (_d = (_c = ts.prevSlotTargetingKeys) == null ? void 0 : _c[record.elementId]) != null ? _d : []; + for (const key of /* @__PURE__ */ new Set([...BASE_TARGETING_KEYS, ...previousKeys])) native.clearTargeting(key); const targeting = { ...slot.targeting, ts_initial: "1" }; for (const key of CANDIDATE_KEYS) { delete targeting[key]; - if (candidate == null ? void 0 : candidate[key]) - targeting[key] = String(candidate[key]); + if (candidate == null ? void 0 : candidate[key]) targeting[key] = String(candidate[key]); } record.targetingKeys = Object.keys(targeting); - for (const [key, value] of Object.entries(targeting)) - native.setTargeting(key, value); + for (const [key, value] of Object.entries(targeting)) native.setTargeting(key, value); first.targeting = targeting; - ts.divToSlotId = Object.assign( - /* @__PURE__ */ Object.create(null), - ts.divToSlotId, - ); + ts.divToSlotId = Object.assign(/* @__PURE__ */ Object.create(null), ts.divToSlotId); ts.prevSlotTargetingKeys = Object.assign( /* @__PURE__ */ Object.create(null), - ts.prevSlotTargetingKeys, - ); - retainPublisherSlotMappings( - ts, - ts.divToSlotId, - ts.prevSlotTargetingKeys, + ts.prevSlotTargetingKeys ); + retainPublisherSlotMappings(ts, ts.divToSlotId, ts.prevSlotTargetingKeys); try { - const auctionId = - candidate == null ? void 0 : candidate.hb_auction_id; - const trace = - window.__tsjs_trace_active === true && ts.traceGpt - ? ts.traceGpt.opportunity(slot, auctionId) - : { auctionId }; - (_e = ts.gptDiagnosticsRecorder) == null - ? void 0 - : _e.recordTrustedServerOpportunity( - native, - slot.id, - (candidate == null ? void 0 : candidate.hb_adid) - ? candidate.adm || candidate.hb_cache_host - ? "renderable_candidate" - : "unrenderable_candidate" - : "no_candidate", - trace.auctionId, - record.eligibleFormats, - ...(trace.identity ? [trace.identity] : []), - ); - } catch (e) {} + const auctionId = candidate == null ? void 0 : candidate.hb_auction_id; + const trace = (_f = (_e = getTraceBridge == null ? void 0 : getTraceBridge()) == null ? void 0 : _e.opportunity(slot, auctionId)) != null ? _f : { auctionId }; + (_g = ts.gptDiagnosticsRecorder) == null ? void 0 : _g.recordTrustedServerOpportunity( + native, + slot.id, + (candidate == null ? void 0 : candidate.hb_adid) ? candidate.adm || candidate.hb_cache_host ? "renderable_candidate" : "unrenderable_candidate" : "no_candidate", + trace.auctionId, + record.eligibleFormats, + ...trace.identity ? [trace.identity] : [] + ); + } catch (e) { + } ts.gptSlotHandoffInternal = true; ts.adInitRefreshInProgress = true; tag.display(record.elementId); service.refresh([native]); - if (record.status.status === "dispatching") - record.status = { status: "requested" }; + if (record.status.status === "dispatching") record.status = { status: "requested" }; } catch (e) { - record.status = { - status: "failed_after_dispatch", - reason: "delivery_exception", - }; + record.status = { status: "failed_after_dispatch", reason: "delivery_exception" }; } finally { ts.gptSlotHandoffInternal = wasInternal; ts.adInitRefreshInProgress = wasRefreshing; @@ -748,9 +326,7 @@ const foreground = () => { if (!current(record)) return; if (document.hidden) { - document.addEventListener("visibilitychange", foreground, { - once: true, - }); + document.addEventListener("visibilitychange", foreground, { once: true }); return; } window.requestAnimationFrame(() => { @@ -760,14 +336,10 @@ foreground(); return; } - (_b = (_a = registrationTag()) == null ? void 0 : _a.cmd) == null - ? void 0 - : _b.push(() => { - var _a2; - return (_a2 = ts.publisherSlotRegistrationDispatch) == null - ? void 0 - : _a2.call(ts, record); - }); + (_b = (_a = registrationTag()) == null ? void 0 : _a.cmd) == null ? void 0 : _b.push(() => { + var _a2; + return (_a2 = ts.publisherSlotRegistrationDispatch) == null ? void 0 : _a2.call(ts, record); + }); }); }; foreground(); @@ -782,13 +354,11 @@ record.status = { status: "waiting_payload" }; continue; } - const admitted = state.payload.slots.find( - (slot) => slot.id === record.slotId, - ); + const admitted = state.payload.slots.find((slot) => slot.id === record.slotId); if (!admitted || admitted.initialization !== "publisher_registered") { record.status = { status: "inactive", - reason: admitted ? "not_registered_mode" : "not_admitted", + reason: admitted ? "not_registered_mode" : "not_admitted" }; continue; } @@ -796,781 +366,724 @@ } }; } - - installPublisherSlotRegistration(ts, claimFirstImpressionForTrustedServer); - - // Snapshot exact old native identities before retiring their registration records. - // This mirrors runtime navigation cleanup, without adding a bootstrap auction client. - function clearBootstrapNavigationTargeting() { - var gpt = window.googletag; - var previousKeys = Object.assign( - Object.create(null), - ts.prevSlotTargetingKeys, - ); - var registeredRecords = Object.values( - (ts.publisherSlotRegistrationState && - ts.publisherSlotRegistrationState.records) || - {}, - ).filter(function (record) { - return record.consumed && record.targetingKeys; - }); - var registeredIds = new Set(); - var snapshot = new Map(); - var baseKeys = CANDIDATE_KEYS.concat(["ts_initial"]); - registeredRecords.forEach(function (record) { - registeredIds.add(record.elementId); - previousKeys[record.elementId] = record.targetingKeys; - if (record.gptSlot) - snapshot.set(record.gptSlot, baseKeys.concat(record.targetingKeys)); - }); - var touchedIds = new Set( - Object.keys(previousKeys).concat(Object.keys(ts.divToSlotId || {})), - ); - var pubads = gpt.pubads && gpt.pubads(); - var live = pubads && pubads.getSlots ? pubads.getSlots() : []; - live.concat(ts.prevGptSlots || []).forEach(function (slot) { - var id = slot.getSlotElementId(); - if (touchedIds.has(id) && !registeredIds.has(id)) { - snapshot.set(slot, baseKeys.concat(previousKeys[id] || [])); + (function() { + if (typeof window === "undefined") return; + var ts = window.tsjs = window.tsjs || {}; + var tag; + if (window.__tsjs_gam_attribution_enabled === true) { + tag = window.googletag = window.googletag || { cmd: [] }; + tag.cmd = tag.cmd || []; + tag.cmd.push(function() { + try { + var gpt = window.googletag; + if (gpt && typeof gpt.setConfig === "function") { + gpt.setConfig({ targeting: { ts: "true" } }); + } + } catch (error) { + if (ts.log && ts.log.warn) ts.log.warn("GAM attribution targeting failed", error); + } + }); + } + if (ts.adInit) return; + function syncInitialLoadDisabled(gpt) { + if (typeof gpt.getConfig !== "function") return false; + var config = gpt.getConfig("disableInitialLoad"); + if (!config || typeof config.disableInitialLoad === "undefined") { + return false; } + ts.gptInitialLoadDisabled = config.disableInitialLoad === true; + return true; + } + tag = tag || (window.googletag = window.googletag || { cmd: [] }); + tag.cmd = tag.cmd || []; + tag.cmd.push(function() { + var gpt = window.googletag; + syncInitialLoadDisabled(gpt); + if (typeof gpt.setConfig === "function" && !gpt.__tsInitialLoadConfigHooked) { + var originalSetConfig = gpt.setConfig.bind(gpt); + gpt.setConfig = function(config) { + var result = originalSetConfig.apply(gpt, arguments); + if (!syncInitialLoadDisabled(gpt) && config && "disableInitialLoad" in config) { + ts.gptInitialLoadDisabled = config.disableInitialLoad === true; + } + return result; + }; + gpt.__tsInitialLoadConfigHooked = true; + } + var pubads = gpt.pubads && gpt.pubads(); + if (!pubads || typeof pubads.disableInitialLoad !== "function" || pubads.__tsInitialLoadHooked) { + return; + } + var originalDisableInitialLoad = pubads.disableInitialLoad.bind(pubads); + pubads.disableInitialLoad = function() { + var result = originalDisableInitialLoad.apply(pubads, arguments); + if (!syncInitialLoadDisabled(gpt)) { + ts.gptInitialLoadDisabled = true; + } + return result; + }; + pubads.__tsInitialLoadHooked = true; }); - var clearSnapshot = function () { - var current = window.googletag.pubads && window.googletag.pubads(); - if (!current) return false; - var currentSlots = current.getSlots ? current.getSlots() : []; - snapshot.forEach(function (keys, slot) { - if ( - !currentSlots.includes(slot) || - typeof slot.clearTargeting !== "function" - ) + var FIRST_IMPRESSION_LEASE_MS = 5e3; + var MAX_FIRST_IMPRESSION_SLOTS = 256; + function firstImpressionState(now) { + var generation = ts.navGeneration || 0; + if (!ts.firstImpression || ts.firstImpression.generation !== generation) { + ts.firstImpression = { + generation, + nextToken: 0, + slots: /* @__PURE__ */ Object.create(null), + fallbackSlots: /* @__PURE__ */ Object.create(null) + }; + } + var state = ts.firstImpression; + state.slots = Object.assign(/* @__PURE__ */ Object.create(null), state.slots); + state.fallbackSlots = Object.assign(/* @__PURE__ */ Object.create(null), state.fallbackSlots); + Object.keys(state.slots).forEach(function(elementId) { + var claim = state.slots[elementId]; + if (claim.generation !== generation || claim.slotElementId !== elementId || claim.element.ownerDocument !== document || claim.element !== document.getElementById(elementId) || !claim.element.isConnected) { + delete state.slots[elementId]; return; - new Set(keys).forEach(function (key) { - slot.clearTargeting(key); + } + var hasReservedFallback = claim.owner === "publisher" && (claim.phase === "auctioning" || claim.phase === "delivery_pending") && state.fallbackSlots[elementId] === claim.element; + Object.keys(claim.publisherAuctions || {}).forEach(function(token) { + var auction = claim.publisherAuctions[token]; + if (auction.expiresAt <= now && !hasReservedFallback && !(claim.owner === "trusted_server" && auction.suppressDelivery)) { + delete claim.publisherAuctions[token]; + } }); + if (claim.owner === "publisher" && (claim.phase === "auctioning" || claim.phase === "delivery_pending") && Object.keys(claim.publisherAuctions || {}).length === 0 && claim.expiresAt <= now && !hasReservedFallback) { + delete state.slots[elementId]; + } + }); + Object.keys(state.fallbackSlots).forEach(function(elementId) { + var element = state.fallbackSlots[elementId]; + if (!element.isConnected || element.id !== elementId || document.getElementById(elementId) !== element) { + delete state.fallbackSlots[elementId]; + } }); + return state; + } + function firstImpressionClaim(element) { + return firstImpressionState(Date.now()).slots[element.id]; + } + function storeFirstImpressionClaim(state, claim) { + if (!state.slots[claim.slotElementId] && Object.keys(state.slots).length >= MAX_FIRST_IMPRESSION_SLOTS) { + return false; + } + state.slots[claim.slotElementId] = claim; return true; - }; - if (snapshot.size > 0 && !clearSnapshot()) tag.cmd.push(clearSnapshot); - ts.prevSlotTargetingKeys = Object.create(null); - ts.divToSlotId = Object.create(null); - } - - // One observer resolves a replaceable handler, so runtime takeover preserves - // framework wrappers and transfers counter ownership instead of nesting writers. - function installBootstrapNavigationBridge() { - if (ts.gptNavigationBridge) return; - var currentPath = location.pathname; - ts.gptNavigationBridge = { - onNavigate: function (path) { - if (path === currentPath) return; - currentPath = path; - clearBootstrapNavigationTargeting(); - ts.navGeneration = (ts.navGeneration || 0) + 1; - invalidatePublisherSlotRegistrations(ts); - delete ts.firstImpression; - Object.entries(ts.gptSlotHandoffs || {}).forEach(function (entry) { - if (!entry[1].publisherClaimed) delete ts.gptSlotHandoffs[entry[0]]; + } + function schedulePendingRenderDiagnostic(claim) { + var startedAt = Date.now(); + window.setTimeout(function() { + if (!ts.firstImpression || ts.firstImpression.slots[claim.slotElementId] !== claim || claim.generation !== (ts.navGeneration || 0) || !claim.element.isConnected || document.getElementById(claim.slotElementId) !== claim.element || claim.owner !== "trusted_server" || claim.phase === "rendered") + return; + var diagnostic = { phase: claim.phase, ageMs: Date.now() - startedAt }; + claim.pendingRenderDiagnostic = diagnostic; + try { + if (ts.log && ts.log.debug) { + ts.log.debug("[tsjs-gpt] initial render remains pending", { + slot: claim.slotElementId, + generation: claim.generation, + phase: diagnostic.phase, + ageMs: diagnostic.ageMs + }); + } + } catch (_error) { + } + }, FIRST_IMPRESSION_LEASE_MS); + } + function claimFirstImpressionForTrustedServer(element) { + var now = Date.now(); + var state = firstImpressionState(now); + var existing = state.slots[element.id]; + if (existing) { + var canTransitionPublisherFallback = existing.owner === "publisher" && existing.phase !== "requested" && existing.phase !== "rendered" && existing.expiresAt <= now && state.fallbackSlots[element.id] === element; + if (!canTransitionPublisherFallback) return null; + existing.owner = "trusted_server"; + existing.phase = "delivery_pending"; + existing.expiresAt = Number.POSITIVE_INFINITY; + Object.keys(existing.publisherAuctions || {}).forEach(function(token) { + existing.publisherAuctions[token].suppressDelivery = true; }); - ts.gptNavigationBridge.pendingGeneration = ts.navGeneration; - }, - }; - var observe = function () { - ts.gptNavigationBridge.onNavigate(location.pathname); - }; - ["pushState", "replaceState"].forEach(function (method) { - var original = history[method].bind(history); - history[method] = function (state, unused, url) { - original(state, unused, url); - observe(); - }; - }); - window.addEventListener("popstate", observe, true); - } - installBootstrapNavigationBridge(); - - // Minimal fallback for tsjs.scheduleInitialAdInit, mirroring the bundle's - // hydration-safe scheduler in - // crates/trusted-server-js/lib/src/integrations/gpt/index.ts: the - // bids script hands the SSR bids payload to this scheduler, which applies - // it and runs adInit only while the page is still on navigation - // generation 0 (the SSR document), after window load plus a double - // requestAnimationFrame so the call lands outside React's hydration - // window. Keeps initial server-side ads working when the main TSJS bundle - // fails to load; the bundle overwrites this with the full implementation. - // - // Hidden documents: rAF is not serviced while the document is hidden, so a - // background-tab load holds the initial adInit until first view. Intended, - // and deliberately identical to the bundle scheduler — the impression is - // spent on a viewed tab, and the post-hydration guarantee holds whenever - // the request is actually issued. - ts.scheduleInitialAdInit = function ( - initialBids, - initialSlots, - traceAuctionTransport, - ) { - // The bundle may replace this scheduler after the fallback claims the initial - // pass. Keep the latch on the shared document API so replacement cannot reset it. - if ((ts.navGeneration || 0) !== 0 || ts.initialAdInitScheduled) return; - ts.initialAdInitScheduled = true; - // Slots are generation-guarded for the same reason the bids are: the - // shared-template seam sends both, and an assignment made before this call - // would overwrite a committed SPA navigation's slots. - if (initialSlots !== undefined) ts.adSlots = initialSlots; - if (initialBids !== undefined) ts.bids = initialBids; - // Bind before registered dispatch; the deferred automatic pass must not - // re-observe this batch and replace newer trace bindings. - try { - if (window.__tsjs_trace_active === true && ts.traceGpt) { - ts.traceGpt.observeTransport( - initialSlots === undefined ? ts.adSlots : initialSlots, - traceAuctionTransport, - "initial_navigation_ssat", - ); + schedulePendingRenderDiagnostic(existing); + return existing; } - } catch (_) { - // Trace failure cannot interrupt the initial advertising pass. + var claim = { + generation: state.generation, + slotElementId: element.id, + element, + owner: "trusted_server", + phase: "delivery_pending", + // Unbounded by contract; see the fallback transition above. + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: /* @__PURE__ */ Object.create(null) + }; + if (!storeFirstImpressionClaim(state, claim)) return null; + schedulePendingRenderDiagnostic(claim); + return claim; } - if (initialBids !== undefined || initialSlots !== undefined) { - settlePublisherSlotPayload(ts, 0, ts.adSlots || [], ts.bids || {}); + function releaseTrustedServerFirstImpressionClaim(element, claim) { + var state = firstImpressionState(Date.now()); + if (state.slots[element.id] === claim && claim.owner === "trusted_server" && claim.phase === "delivery_pending") { + delete state.slots[element.id]; + if (state.fallbackSlots[element.id] === element) { + delete state.fallbackSlots[element.id]; + } + } } - var fire = function () { - if ((ts.navGeneration || 0) !== 0) return; - if (typeof ts.adInit === "function") ts.adInit(); - }; - var afterFrames = function () { - window.requestAnimationFrame(function () { - window.requestAnimationFrame(fire); + function installFirstImpressionListeners() { + if (ts.firstImpressionListenersInstalled) return; + tag.cmd.push(function() { + if (ts.firstImpressionListenersInstalled) return; + var pubads = window.googletag.pubads(); + if (!pubads || typeof pubads.addEventListener !== "function") return; + var observe = function(phase) { + return function(event) { + var elementId = event.slot && event.slot.getSlotElementId ? event.slot.getSlotElementId() : ""; + var element = elementId && document.getElementById(elementId); + if (!element) return; + if (phase === "rendered") observePublisherSlotRender(ts, event.slot); + var state = firstImpressionState(Date.now()); + var claim = state.slots[elementId]; + if (!claim) { + storeFirstImpressionClaim(state, { + generation: state.generation, + slotElementId: elementId, + element, + owner: "publisher", + phase, + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: /* @__PURE__ */ Object.create(null) + }); + return; + } + if (claim.phase !== "rendered") claim.phase = phase; + if (claim.owner === "publisher") { + claim.expiresAt = Number.POSITIVE_INFINITY; + } else if (phase === "rendered") { + claim.publisherRegistrationClosed = true; + } + }; + }; + pubads.addEventListener("slotRequested", observe("requested")); + pubads.addEventListener("slotRenderEnded", observe("rendered")); + ts.firstImpressionListenersInstalled = true; }); - }; - if (document.readyState === "complete") afterFrames(); - else window.addEventListener("load", afterFrames, { once: true }); - }; - - function recordTraceOpportunity(gptSlot, slot, bid, fallback) { - if (window.__tsjs_trace_active !== true) return; - try { - var bridge = ts.traceGpt; - if (!bridge || !bridge.identity(slot) || !ts.gptDiagnosticsRecorder) - return; - var trace = bridge.opportunity(slot, bid.hb_auction_id); - var handoff = - ts.gptSlotHandoffs && ts.gptSlotHandoffs[gptSlot.getSlotElementId()]; - var requestedSizes = fallback ? slot.formats : handoff && handoff.formats; - var nonempty = function (value) { - return typeof value === "string" && value.length > 0; + } + installFirstImpressionListeners(); + installPublisherSlotRegistration(ts, claimFirstImpressionForTrustedServer, function() { + return window.__tsjs_trace_active === true ? ts.traceGpt : void 0; + }); + function installBootstrapNavigationBridge() { + if (ts.gptNavigationBridge) return; + var currentPath = location.pathname; + ts.gptNavigationBridge = { + onNavigate: function(path) { + if (path === currentPath) return; + currentPath = path; + clearPreviousNavigationTargeting(ts, window.googletag); + ts.navGeneration = (ts.navGeneration || 0) + 1; + invalidatePublisherSlotRegistrations(ts); + delete ts.firstImpression; + Object.entries(ts.gptSlotHandoffs || {}).forEach(function(entry) { + if (!entry[1].publisherClaimed) delete ts.gptSlotHandoffs[entry[0]]; + }); + ts.gptNavigationBridge.pendingGeneration = ts.navGeneration; + } + }; + var observe = function() { + ts.gptNavigationBridge.onNavigate(location.pathname); }; - // Mirror the bundle's opportunity classification at the same concrete - // GPT binding. Token validation remains in the single core bridge. - var hasTargeting = [ - "hb_pb", - "hb_bidder", - "hb_adid", - "hb_cache_host", - "hb_cache_path", - ].some(function (key) { - return nonempty(bid[key]); + ["pushState", "replaceState"].forEach(function(method) { + var original = history[method].bind(history); + history[method] = function(state, unused, url) { + original(state, unused, url); + observe(); + }; }); - var opportunity = !hasTargeting - ? "no_candidate" - : nonempty(bid.hb_adid) && - (nonempty(bid.adm) || - (nonempty(bid.hb_cache_host) && nonempty(bid.hb_cache_path))) - ? "renderable_candidate" - : "unrenderable_candidate"; - ts.gptDiagnosticsRecorder.recordTrustedServerOpportunity( - gptSlot, - slot.id, - opportunity, - trace.auctionId, - requestedSizes, - trace.identity, - ); - } catch (_) { - // Diagnostics never change targeting, display, or refresh. + window.addEventListener("popstate", observe, true); } - } - - function findSlotByElementId(pubads, elementId) { - var slots = pubads.getSlots ? pubads.getSlots() : []; - return ( - slots.find(function (slot) { - return slot.getSlotElementId() === elementId; - }) || null - ); - } - - function normalizedGptFormats(formats) { - return formats.length === 2 && - formats.every(function (format) { - return typeof format === "number"; - }) - ? [formats] - : formats; - } - - function handoffFormatsMatch(handoff, formats) { - return ( - JSON.stringify(handoff.formats) === - JSON.stringify(normalizedGptFormats(formats)) - ); - } - - function matchingHandoff(pubads, adUnitPath, formats, elementId) { - var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; - if (exact) return exact.publisherClaimed ? null : exact; - - var candidates = Object.values(ts.gptSlotHandoffs || {}).filter( - function (handoff, index, allHandoffs) { - return ( - allHandoffs.indexOf(handoff) === index && - !handoff.publisherClaimed && - !document.getElementById(handoff.slotElementId) && - elementId.startsWith(handoff.divIdPrefix) && - handoff.gamUnitPath === adUnitPath && - handoffFormatsMatch(handoff, formats) && - findSlotByElementId(pubads, handoff.slotElementId) + installBootstrapNavigationBridge(); + ts.scheduleInitialAdInit = function(initialBids, initialSlots, traceAuctionTransport) { + if ((ts.navGeneration || 0) !== 0 || ts.initialAdInitScheduled) return; + ts.initialAdInitScheduled = true; + if (initialSlots !== void 0) ts.adSlots = initialSlots; + if (initialBids !== void 0) ts.bids = initialBids; + try { + if (window.__tsjs_trace_active === true && ts.traceGpt) { + ts.traceGpt.observeTransport( + initialSlots === void 0 ? ts.adSlots : initialSlots, + traceAuctionTransport, + "initial_navigation_ssat" + ); + } + } catch (_) { + } + if (initialBids !== void 0 || initialSlots !== void 0) { + settlePublisherSlotPayload(ts, 0, ts.adSlots || [], ts.bids || {}); + } + var fire = function() { + if ((ts.navGeneration || 0) !== 0) return; + if (typeof ts.adInit === "function") ts.adInit(); + }; + var afterFrames = function() { + window.requestAnimationFrame(function() { + window.requestAnimationFrame(fire); + }); + }; + if (document.readyState === "complete") afterFrames(); + else window.addEventListener("load", afterFrames, { once: true }); + }; + function recordTraceOpportunity(gptSlot, slot, bid, fallback) { + if (window.__tsjs_trace_active !== true) return; + try { + var bridge = ts.traceGpt; + if (!bridge || !bridge.identity(slot) || !ts.gptDiagnosticsRecorder) return; + var trace = bridge.opportunity(slot, bid.hb_auction_id); + var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[gptSlot.getSlotElementId()]; + var requestedSizes = fallback ? slot.formats : handoff && handoff.formats; + var nonempty = function(value) { + return typeof value === "string" && value.length > 0; + }; + var hasTargeting = ["hb_pb", "hb_bidder", "hb_adid", "hb_cache_host", "hb_cache_path"].some( + function(key) { + return nonempty(bid[key]); + } ); - }, - ); - return candidates.length === 1 ? candidates[0] : null; - } - - function displayTargetElementId(target) { - if (typeof target === "string") return target; - if (target && typeof target.getSlotElementId === "function") { - return target.getSlotElementId(); + var opportunity = !hasTargeting ? "no_candidate" : nonempty(bid.hb_adid) && (nonempty(bid.adm) || nonempty(bid.hb_cache_host) && nonempty(bid.hb_cache_path)) ? "renderable_candidate" : "unrenderable_candidate"; + ts.gptDiagnosticsRecorder.recordTrustedServerOpportunity( + gptSlot, + slot.id, + opportunity, + trace.auctionId, + requestedSizes, + trace.identity + ); + } catch (_) { + } } - return target && target.id ? target.id : null; - } - - function isElementVisible(element) { - if (typeof element.checkVisibility === "function") { - return element.checkVisibility({ - checkVisibilityCSS: true, - visibilityProperty: true, - }); + function findSlotByElementId(pubads, elementId) { + var slots = pubads.getSlots ? pubads.getSlots() : []; + return slots.find(function(slot) { + return slot.getSlotElementId() === elementId; + }) || null; } - - for (var current = element; current; current = current.parentElement) { - var style = window.getComputedStyle(current); - if ( - style.display === "none" || - style.visibility === "hidden" || - style.visibility === "collapse" - ) { - return false; - } + function normalizedGptFormats(formats) { + return formats.length === 2 && formats.every(function(format) { + return typeof format === "number"; + }) ? [formats] : formats; } - return true; - } - - function slotElementHasLayout(element) { - if (!isElementVisible(element)) return false; - var elementRect = element.getBoundingClientRect(); - if (elementRect.width > 0 && elementRect.height > 0) return true; - - var container = document.getElementById(element.id + "-container"); - if (!container || !isElementVisible(container)) return false; - var containerRect = container.getBoundingClientRect(); - return containerRect.width > 0; - } - - function resolveSlotElementByDivId(divId) { - if (!divId) { - return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; + function handoffFormatsMatch(handoff, formats) { + return JSON.stringify(handoff.formats) === JSON.stringify(normalizedGptFormats(formats)); } - // Exact-id matches intentionally skip the visibility tiers below: a - // configured literal id is unambiguous, so a hidden match is still the - // right element. Prefix matches go through the tiers because a prefix can - // match several candidates and only visibility/layout disambiguates them — - // so a hidden exact-id match resolves while a hidden prefix match does not. - var exact = document.getElementById(divId); - if (exact) { - return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; + function matchingHandoff(pubads, adUnitPath, formats, elementId) { + var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if (exact) return exact.publisherClaimed ? null : exact; + var candidates = Object.values(ts.gptSlotHandoffs || {}).filter( + function(handoff, index, allHandoffs) { + return allHandoffs.indexOf(handoff) === index && !handoff.publisherClaimed && !document.getElementById(handoff.slotElementId) && elementId.startsWith(handoff.divIdPrefix) && handoff.gamUnitPath === adUnitPath && handoffFormatsMatch(handoff, formats) && findSlotByElementId(pubads, handoff.slotElementId); + } + ); + return candidates.length === 1 ? candidates[0] : null; } - - var idElements = document.querySelectorAll("[id]"); - var prefixMatches = []; - for (var i = 0; i < idElements.length; i++) { - var candidate = idElements[i]; - if ( - candidate.id.startsWith(divId) && - !candidate.id.endsWith("-container") - ) { - prefixMatches.push(candidate); + function displayTargetElementId(target) { + if (typeof target === "string") return target; + if (target && typeof target.getSlotElementId === "function") { + return target.getSlotElementId(); } + return target && target.id ? target.id : null; } - // A unique prefix match may be a lazy slot that has not been sized yet, - // but it must still be visible through its ancestor containers. - if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0])) { - return { - element: prefixMatches[0], - prefixMatchCount: 1, - activeMatchCount: 1, - }; + function isElementVisible(element) { + if (typeof element.checkVisibility === "function") { + return element.checkVisibility({ + checkVisibilityCSS: true, + visibilityProperty: true + }); + } + for (var current = element; current; current = current.parentElement) { + var style = window.getComputedStyle(current); + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") { + return false; + } + } + return true; + } + function slotElementHasLayout(element) { + if (!isElementVisible(element)) return false; + var elementRect = element.getBoundingClientRect(); + if (elementRect.width > 0 && elementRect.height > 0) return true; + var container = document.getElementById(element.id + "-container"); + if (!container || !isElementVisible(container)) return false; + var containerRect = container.getBoundingClientRect(); + return containerRect.width > 0; } - - var visibleMatches = prefixMatches.filter(isElementVisible); - if (visibleMatches.length === 1) { + function resolveSlotElementByDivId(divId) { + if (!divId) { + return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; + } + var exact = document.getElementById(divId); + if (exact) { + return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; + } + var idElements = document.querySelectorAll("[id]"); + var prefixMatches = []; + for (var i = 0; i < idElements.length; i++) { + var candidate = idElements[i]; + if (candidate.id.startsWith(divId) && !candidate.id.endsWith("-container")) { + prefixMatches.push(candidate); + } + } + if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0])) { + return { + element: prefixMatches[0], + prefixMatchCount: 1, + activeMatchCount: 1 + }; + } + var visibleMatches = prefixMatches.filter(isElementVisible); + if (visibleMatches.length === 1) { + return { + element: visibleMatches[0], + prefixMatchCount: prefixMatches.length, + activeMatchCount: 1 + }; + } + var activeMatches = visibleMatches.filter(slotElementHasLayout); return { - element: visibleMatches[0], + element: activeMatches.length === 1 ? activeMatches[0] : null, prefixMatchCount: prefixMatches.length, - activeMatchCount: 1, + activeMatchCount: activeMatches.length }; } - - var activeMatches = visibleMatches.filter(slotElementHasLayout); - return { - element: activeMatches.length === 1 ? activeMatches[0] : null, - prefixMatchCount: prefixMatches.length, - activeMatchCount: activeMatches.length, - }; - } - - function runHandoffInternal(callback) { - var wasInternal = ts.gptSlotHandoffInternal; - ts.gptSlotHandoffInternal = true; - try { - return callback(); - } finally { - ts.gptSlotHandoffInternal = wasInternal; + function runHandoffInternal(callback) { + var wasInternal = ts.gptSlotHandoffInternal; + ts.gptSlotHandoffInternal = true; + try { + return callback(); + } finally { + ts.gptSlotHandoffInternal = wasInternal; + } } - } - - // TS cannot wait an arbitrary amount of time for a framework to define a - // slot: publishers that never define one would render blank. Instead, TS - // defines its fallback on the actual inner div and aliases only a later - // publisher defineSlot() for that exact div, or a hydration-renamed replacement - // after the original div is gone, to the same GPT slot. - function installSlotHandoff() { - window.googletag.cmd.push(function () { - var tag = window.googletag; - var pubads = tag.pubads && tag.pubads(); - if (!tag.defineSlot || !tag.display || !pubads) return; - - if (!tag.defineSlot.__tsSlotHandoffPatched) { - var originalDefineSlot = tag.defineSlot.bind(tag); - var patchedDefineSlot = function (adUnitPath, formats, elementId) { - if (!ts.gptSlotHandoffInternal && typeof elementId === "string") { - var handoff = matchingHandoff( - pubads, - adUnitPath, - formats, - elementId, - ); - if (handoff) { - var existingSlot = findSlotByElementId( - pubads, - handoff.slotElementId, - ); - if (existingSlot) { - ts.gptSlotHandoffs[elementId] = handoff; - handoff.publisherClaimed = true; - // The supported publisher lifecycle is defineSlot → addService → display. - // Intentionally wait for that display instead of applying a time heuristic. - handoff.suppressPublisherDisplay = true; - handoff.suppressPublisherRefresh = - ts.gptInitialLoadDisabled === true; - ts.prevGptSlots = (ts.prevGptSlots || []).filter( - function (ownedSlot) { + function installSlotHandoff() { + window.googletag.cmd.push(function() { + var tag2 = window.googletag; + var pubads = tag2.pubads && tag2.pubads(); + if (!tag2.defineSlot || !tag2.display || !pubads) return; + if (!tag2.defineSlot.__tsSlotHandoffPatched) { + var originalDefineSlot = tag2.defineSlot.bind(tag2); + var patchedDefineSlot = function(adUnitPath, formats, elementId) { + if (!ts.gptSlotHandoffInternal && typeof elementId === "string") { + var handoff = matchingHandoff(pubads, adUnitPath, formats, elementId); + if (handoff) { + var existingSlot = findSlotByElementId(pubads, handoff.slotElementId); + if (existingSlot) { + ts.gptSlotHandoffs[elementId] = handoff; + handoff.publisherClaimed = true; + handoff.suppressPublisherDisplay = true; + handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; + ts.prevGptSlots = (ts.prevGptSlots || []).filter(function(ownedSlot) { return ownedSlot !== existingSlot; - }, - ); - if ( - handoff.gamUnitPath !== adUnitPath || - !handoffFormatsMatch(handoff, formats) - ) { - ts.log && - ts.log.warn && - ts.log.warn( - "GPT slot handoff: publisher definition differs from TS configuration", - elementId, - ); + }); + if (handoff.gamUnitPath !== adUnitPath || !handoffFormatsMatch(handoff, formats)) { + if (ts.log && ts.log.warn) { + ts.log.warn( + "GPT slot handoff: publisher definition differs from TS configuration", + elementId + ); + } + } + return existingSlot; } - return existingSlot; } } - } - return elementId === undefined - ? originalDefineSlot(adUnitPath, formats) - : originalDefineSlot(adUnitPath, formats, elementId); - }; - patchedDefineSlot.__tsSlotHandoffPatched = true; - tag.defineSlot = patchedDefineSlot; - } - - if (!tag.display.__tsSlotHandoffPatched) { - var originalDisplay = tag.display.bind(tag); - var patchedDisplay = function (target) { - var elementId = displayTargetElementId(target); - var handoff = - elementId && ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; - if ( - !ts.gptSlotHandoffInternal && - handoff && - handoff.suppressPublisherDisplay - ) { - handoff.suppressPublisherDisplay = false; + return elementId === void 0 ? originalDefineSlot(adUnitPath, formats) : originalDefineSlot(adUnitPath, formats, elementId); + }; + patchedDefineSlot.__tsSlotHandoffPatched = true; + tag2.defineSlot = patchedDefineSlot; + } + if (!tag2.display.__tsSlotHandoffPatched) { + var originalDisplay = tag2.display.bind(tag2); + var patchedDisplay = function(target) { + var elementId = displayTargetElementId(target); + var handoff = elementId && ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if (!ts.gptSlotHandoffInternal && handoff && handoff.suppressPublisherDisplay) { + handoff.suppressPublisherDisplay = false; + return; + } + originalDisplay(target); + }; + patchedDisplay.__tsSlotHandoffPatched = true; + tag2.display = patchedDisplay; + } + if (!pubads.refresh.__tsSlotHandoffPatched) { + var originalRefresh = pubads.refresh.bind(pubads); + var callRefresh = function(slots, options) { + if (options === void 0) { + originalRefresh(slots); + } else { + originalRefresh(slots, options); + } + }; + var patchedRefresh = function(requestedSlots, options) { + if (ts.gptSlotHandoffInternal) { + callRefresh(requestedSlots, options); + return; + } + var slots = requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); + if (!slots) { + callRefresh(requestedSlots, options); + return; + } + var suppressed = false; + var remainingSlots = slots.filter(function(slot) { + var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; + if (!handoff || !handoff.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + }); + if (!suppressed) { + callRefresh(requestedSlots, options); + } else if (remainingSlots.length > 0) { + callRefresh(remainingSlots, options); + } + }; + patchedRefresh.__tsSlotHandoffPatched = true; + pubads.refresh = patchedRefresh; + } + }); + } + installSlotHandoff(); + function bootstrapTargeting(slot, bid) { + var targeting = Object.assign({}, slot.targeting || {}); + ["hb_pb", "hb_bidder", "hb_adid", "hb_cache_host", "hb_cache_path"].forEach(function(key) { + if (bid[key]) targeting[key] = String(bid[key]); + }); + targeting.ts_initial = "1"; + return targeting; + } + function scheduleFirstImpressionFallback(slot, bid, element, generation) { + var state = firstImpressionState(Date.now()); + if (state.fallbackSlots[element.id]) return; + state.fallbackSlots[element.id] = element; + var retry = function() { + if ((ts.navGeneration || 0) !== generation || !element.isConnected || document.getElementById(element.id) !== element) { + return; + } + var claim = firstImpressionClaim(element); + if (claim) { + if (claim.owner !== "publisher" || claim.phase === "requested" || claim.phase === "rendered") { return; } - originalDisplay(target); - }; - patchedDisplay.__tsSlotHandoffPatched = true; - tag.display = patchedDisplay; - } - - if (!pubads.refresh.__tsSlotHandoffPatched) { - var originalRefresh = pubads.refresh.bind(pubads); - var callRefresh = function (slots, options) { - if (options === undefined) { - originalRefresh(slots); - } else { - originalRefresh(slots, options); - } - }; - var patchedRefresh = function (requestedSlots, options) { - if (ts.gptSlotHandoffInternal) { - callRefresh(requestedSlots, options); + var delay = Math.max(0, claim.expiresAt - Date.now()); + if (delay > 0) { + window.setTimeout(retry, delay + 1); return; } - var slots = - requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); - if (!slots) { - callRefresh(requestedSlots, options); + } + tag.cmd.push(function() { + if ((ts.navGeneration || 0) !== generation || !element.isConnected || document.getElementById(element.id) !== element) { return; } - var suppressed = false; - var remainingSlots = slots.filter(function (slot) { - var handoff = - ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; - if (!handoff || !handoff.suppressPublisherRefresh) return true; - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; - }); - if (!suppressed) { - callRefresh(requestedSlots, options); - } else if (remainingSlots.length > 0) { - callRefresh(remainingSlots, options); - } - }; - patchedRefresh.__tsSlotHandoffPatched = true; - pubads.refresh = patchedRefresh; - } - }); - } - - installSlotHandoff(); - - function bootstrapTargeting(slot, bid) { - var targeting = Object.assign({}, slot.targeting || {}); - ["hb_pb", "hb_bidder", "hb_adid", "hb_cache_host", "hb_cache_path"].forEach( - function (key) { - if (bid[key]) targeting[key] = String(bid[key]); - }, - ); - targeting.ts_initial = "1"; - return targeting; - } - - function scheduleFirstImpressionFallback(slot, bid, element, generation) { - var state = firstImpressionState(Date.now()); - if (state.fallbackSlots[element.id]) return; - state.fallbackSlots[element.id] = element; - - var retry = function () { - if ( - (ts.navGeneration || 0) !== generation || - !element.isConnected || - document.getElementById(element.id) !== element - ) { - return; - } - var claim = firstImpressionClaim(element); - if (claim) { - if ( - claim.owner !== "publisher" || - claim.phase === "requested" || - claim.phase === "rendered" - ) { - return; - } - var delay = Math.max(0, claim.expiresAt - Date.now()); - if (delay > 0) { - window.setTimeout(retry, delay + 1); - return; - } - } - - tag.cmd.push(function () { - if ( - (ts.navGeneration || 0) !== generation || - !element.isConnected || - document.getElementById(element.id) !== element - ) { - return; - } - var fallbackClaim = claimFirstImpressionForTrustedServer(element); - if (!fallbackClaim) return; - var pubads = window.googletag.pubads(); - var existingSlots = pubads.getSlots ? pubads.getSlots() : []; - var gptSlot = - existingSlots.find(function (candidate) { + var fallbackClaim = claimFirstImpressionForTrustedServer(element); + if (!fallbackClaim) return; + var pubads = window.googletag.pubads(); + var existingSlots = pubads.getSlots ? pubads.getSlots() : []; + var gptSlot = existingSlots.find(function(candidate) { return candidate.getSlotElementId() === element.id; }) || null; - var tsOwned = false; - if (!gptSlot) { - gptSlot = runHandoffInternal(function () { - return window.googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - element.id, - ); - }); + var tsOwned = false; if (!gptSlot) { - releaseTrustedServerFirstImpressionClaim(element, fallbackClaim); - return; + gptSlot = runHandoffInternal(function() { + return window.googletag.defineSlot(slot.gam_unit_path, slot.formats, element.id); + }); + if (!gptSlot) { + releaseTrustedServerFirstImpressionClaim(element, fallbackClaim); + return; + } + gptSlot.addService(pubads); + tsOwned = true; + ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; + ts.gptSlotHandoffs[element.id] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: element.id, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false + }; } - gptSlot.addService(pubads); - tsOwned = true; - ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; - ts.gptSlotHandoffs[element.id] = { - gamUnitPath: slot.gam_unit_path, - formats: slot.formats, - divIdPrefix: slot.div_id, - slotElementId: element.id, - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - } - - var targeting = bootstrapTargeting(slot, bid); - Object.entries(targeting).forEach(function (entry) { - gptSlot.setTargeting(entry[0], entry[1]); - }); - fallbackClaim.targeting = targeting; - var slotElementId = gptSlot.getSlotElementId() || element.id; - ts.divToSlotId = ts.divToSlotId || {}; - ts.divToSlotId[element.id] = slot.id; - ts.divToSlotId[slotElementId] = slot.id; - ts.prevSlotTargetingKeys = ts.prevSlotTargetingKeys || {}; - var targetingKeys = Object.keys(slot.targeting || {}); - ts.prevSlotTargetingKeys[element.id] = targetingKeys; - ts.prevSlotTargetingKeys[slotElementId] = targetingKeys; - if (tsOwned) { - ts.prevGptSlots = ts.prevGptSlots || []; - ts.prevGptSlots.push(gptSlot); - } - recordTraceOpportunity(gptSlot, slot, bid, true); - if (!ts.servicesEnabled) { - pubads.enableSingleRequest(); - window.googletag.enableServices(); - ts.servicesEnabled = true; - } - if (tsOwned) { - runHandoffInternal(function () { - window.googletag.display(slotElementId); + var targeting = bootstrapTargeting(slot, bid); + Object.entries(targeting).forEach(function(entry) { + gptSlot.setTargeting(entry[0], entry[1]); }); - } - syncInitialLoadDisabled(window.googletag); - if (!tsOwned || ts.gptInitialLoadDisabled) { - ts.adInitRefreshInProgress = true; - try { - runHandoffInternal(function () { - pubads.refresh([gptSlot]); + fallbackClaim.targeting = targeting; + var slotElementId = gptSlot.getSlotElementId() || element.id; + ts.divToSlotId = ts.divToSlotId || {}; + ts.divToSlotId[element.id] = slot.id; + ts.divToSlotId[slotElementId] = slot.id; + ts.prevSlotTargetingKeys = ts.prevSlotTargetingKeys || {}; + var targetingKeys = Object.keys(slot.targeting || {}); + ts.prevSlotTargetingKeys[element.id] = targetingKeys; + ts.prevSlotTargetingKeys[slotElementId] = targetingKeys; + if (tsOwned) { + ts.prevGptSlots = ts.prevGptSlots || []; + ts.prevGptSlots.push(gptSlot); + } + recordTraceOpportunity(gptSlot, slot, bid, true); + if (!ts.servicesEnabled) { + pubads.enableSingleRequest(); + window.googletag.enableServices(); + ts.servicesEnabled = true; + } + if (tsOwned) { + runHandoffInternal(function() { + window.googletag.display(slotElementId); }); - } finally { - ts.adInitRefreshInProgress = false; } - } + syncInitialLoadDisabled(window.googletag); + if (!tsOwned || ts.gptInitialLoadDisabled) { + ts.adInitRefreshInProgress = true; + try { + runHandoffInternal(function() { + pubads.refresh([gptSlot]); + }); + } finally { + ts.adInitRefreshInProgress = false; + } + } + }); + }; + retry(); + } + ts.adInit = function() { + var slots = (ts.adSlots || []).filter(function(slot) { + return slot.initialization !== "publisher_registered"; }); - }; - - retry(); - } - - ts.adInit = function () { - var slots = (ts.adSlots || []).filter(function (slot) { - return slot.initialization !== "publisher_registered"; - }); - var bids = ts.bids || {}; - var divToSlotId = Object.create(null); - var nextSlotTargetingKeys = Object.create(null); - // Generation this invocation belongs to. The slot work below is queued on - // googletag.cmd, which drains only when GPT loads; recheck first inside - // the queued callback so a navigation committed in the gap cancels the - // stale mutation — mirrors the bundle's adInit. - var generation = ts.navGeneration || 0; - var warnedResolutionFailures = Object.create(null); - - googletag.cmd.push(function () { - if ((ts.navGeneration || 0) !== generation) return; - // Slots TS defined itself — tracked for SPA destroy. Publisher-owned - // slots are reused but never destroyed by TS on navigation. - var newSlots = []; - // Publisher-owned slots TS reused — refreshed to pick up server-side - // targeting. The publisher already display()ed these. - var slotsToRefresh = []; - // Element IDs of slots TS defined itself. GPT requires display() to - // register/render a freshly-defined slot; refresh() alone no-ops for a - // slot that was never displayed, so these are display()ed instead. - var slotsToDisplay = []; - slots.forEach(function (slot) { - // Resolve actual div ID: exact match first, then the visibility and - // geometry tiers for prefix matches. Responsive publishers may emit - // several mutually exclusive siblings for one stable prefix, so - // document order is not sufficient. - var resolution = resolveSlotElementByDivId(slot.div_id); - var el = resolution.element; - if (!el) { - if (!warnedResolutionFailures[slot.div_id]) { - if (resolution.prefixMatchCount > 1) { - warnedResolutionFailures[slot.div_id] = true; - if (ts.log && typeof ts.log.warn === "function") { - ts.log.warn( - "GPT slot prefix did not resolve to one active element", - { + var bids = ts.bids || {}; + var divToSlotId = /* @__PURE__ */ Object.create(null); + var nextSlotTargetingKeys = /* @__PURE__ */ Object.create(null); + var generation = ts.navGeneration || 0; + var warnedResolutionFailures = /* @__PURE__ */ Object.create(null); + googletag.cmd.push(function() { + if ((ts.navGeneration || 0) !== generation) return; + var newSlots = []; + var slotsToRefresh = []; + var slotsToDisplay = []; + slots.forEach(function(slot) { + var resolution = resolveSlotElementByDivId(slot.div_id); + var el = resolution.element; + if (!el) { + if (!warnedResolutionFailures[slot.div_id]) { + if (resolution.prefixMatchCount > 1) { + warnedResolutionFailures[slot.div_id] = true; + if (ts.log && typeof ts.log.warn === "function") { + ts.log.warn("GPT slot prefix did not resolve to one active element", { divId: slot.div_id, prefixMatchCount: resolution.prefixMatchCount, - activeMatchCount: resolution.activeMatchCount, - }, - ); - } - } else if ( - resolution.prefixMatchCount === 1 && - resolution.activeMatchCount === 0 - ) { - // The common breakpoint-hidden config: the prefix matched one - // element but it is hidden, so the slot is skipped. Logged so a - // blank placement is diagnosable without stepping the resolver. - warnedResolutionFailures[slot.div_id] = true; - if (ts.log && typeof ts.log.debug === "function") { - ts.log.debug( - "GPT slot prefix matched only a hidden element; skipping slot", - { divId: slot.div_id }, - ); + activeMatchCount: resolution.activeMatchCount + }); + } + } else if (resolution.prefixMatchCount === 1 && resolution.activeMatchCount === 0) { + warnedResolutionFailures[slot.div_id] = true; + if (ts.log && typeof ts.log.debug === "function") { + ts.log.debug("GPT slot prefix matched only a hidden element; skipping slot", { + divId: slot.div_id + }); + } } } + return; } - return; - } - var actualDivId = el.id; - var b = bids[slot.id] || {}; - var tsClaim = claimFirstImpressionForTrustedServer(el); - if (!tsClaim) { - var currentClaim = firstImpressionClaim(el); - if (currentClaim && currentClaim.owner === "publisher") { - scheduleFirstImpressionFallback(slot, b, el, generation); + var actualDivId = el.id; + var b = bids[slot.id] || {}; + var tsClaim = claimFirstImpressionForTrustedServer(el); + if (!tsClaim) { + var currentClaim = firstImpressionClaim(el); + if (currentClaim && currentClaim.owner === "publisher") { + scheduleFirstImpressionFallback(slot, b, el, generation); + } + return; } - return; - } - - var existingSlots = googletag.pubads().getSlots(); - var s = - existingSlots.find(function (gs) { + var existingSlots = googletag.pubads().getSlots(); + var s = existingSlots.find(function(gs) { return gs.getSlotElementId() === actualDivId; }) || null; - var tsOwned = false; - if (!s) { - // Define TS's fallback on the publisher's actual div. The scoped - // handoff wrapper returns this slot if the publisher defines it later. - s = runHandoffInternal(function () { - return googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - actualDivId, - ); - }); + var tsOwned = false; if (!s) { - releaseTrustedServerFirstImpressionClaim(el, tsClaim); - return; + s = runHandoffInternal(function() { + return googletag.defineSlot(slot.gam_unit_path, slot.formats, actualDivId); + }); + if (!s) { + releaseTrustedServerFirstImpressionClaim(el, tsClaim); + return; + } + s.addService(googletag.pubads()); + tsOwned = true; + ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; + ts.gptSlotHandoffs[actualDivId] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: actualDivId, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false + }; + } + var targeting = bootstrapTargeting(slot, b); + Object.entries(targeting).forEach(function(entry) { + s.setTargeting(entry[0], entry[1]); + }); + tsClaim.targeting = targeting; + divToSlotId[actualDivId] = slot.id; + var slotElementId = s.getSlotElementId(); + recordTraceOpportunity(s, slot, b, false); + var targetingKeys = Object.keys(slot.targeting || {}); + nextSlotTargetingKeys[actualDivId] = targetingKeys; + if (slotElementId && slotElementId !== actualDivId) { + divToSlotId[slotElementId] = slot.id; + nextSlotTargetingKeys[slotElementId] = targetingKeys; + } + if (tsOwned) { + newSlots.push(s); + var displayId = s.getSlotElementId() || actualDivId; + slotsToDisplay.push(displayId); + } else { + slotsToRefresh.push(s); } - s.addService(googletag.pubads()); - tsOwned = true; - ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; - ts.gptSlotHandoffs[actualDivId] = { - gamUnitPath: slot.gam_unit_path, - formats: slot.formats, - divIdPrefix: slot.div_id, - slotElementId: actualDivId, - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - } - - var targeting = bootstrapTargeting(slot, b); - Object.entries(targeting).forEach(function (entry) { - s.setTargeting(entry[0], entry[1]); }); - tsClaim.targeting = targeting; - // Map the resolved inner div to the slot ID. This bootstrap fires no - // beacons and registers no slotRenderEnded listener; the map is consumed - // by the bundle's render bridge (index.ts) once it loads. - divToSlotId[actualDivId] = slot.id; - var slotElementId = s.getSlotElementId(); - recordTraceOpportunity(s, slot, b, false); - var targetingKeys = Object.keys(slot.targeting || {}); - nextSlotTargetingKeys[actualDivId] = targetingKeys; - if (slotElementId && slotElementId !== actualDivId) { - divToSlotId[slotElementId] = slot.id; - nextSlotTargetingKeys[slotElementId] = targetingKeys; - } - if (tsOwned) { - newSlots.push(s); - var displayId = s.getSlotElementId() || actualDivId; - slotsToDisplay.push(displayId); - } else { - slotsToRefresh.push(s); + ts.prevGptSlots = newSlots; + retainPublisherSlotMappings(ts, divToSlotId, nextSlotTargetingKeys); + ts.divToSlotId = divToSlotId; + ts.prevSlotTargetingKeys = nextSlotTargetingKeys; + var hasRenderableWork = slotsToDisplay.length > 0 || slotsToRefresh.length > 0; + if (!ts.servicesEnabled && hasRenderableWork) { + googletag.pubads().enableSingleRequest(); + googletag.enableServices(); + ts.servicesEnabled = true; } - }); - ts.prevGptSlots = newSlots; - retainPublisherSlotMappings(ts, divToSlotId, nextSlotTargetingKeys); - ts.divToSlotId = divToSlotId; - ts.prevSlotTargetingKeys = nextSlotTargetingKeys; - var hasRenderableWork = - slotsToDisplay.length > 0 || slotsToRefresh.length > 0; - if (!ts.servicesEnabled && hasRenderableWork) { - googletag.pubads().enableSingleRequest(); - googletag.enableServices(); - ts.servicesEnabled = true; - } - // Register and render TS-defined slots. GPT requires display() for a - // freshly-defined slot; without it the slot no-ops and misses its - // impression. Runs after enableServices(); on SPA navigation services are - // already enabled, so this runs unconditionally for new slots. - slotsToDisplay.forEach(function (divId) { - runHandoffInternal(function () { - googletag.display(divId); - }); - }); - syncInitialLoadDisabled(window.googletag); - - // Reused publisher-owned slots always need a refresh to pick up the - // server-side targeting. TS-defined slots are fetched by display() above - // unless the publisher disabled initial load, in which case display() only - // registers them and refresh() must request the ad — otherwise they render - // blank. Only add them in that case to avoid double-requesting. - var slotsNeedingRefresh = ts.gptInitialLoadDisabled - ? slotsToRefresh.concat(newSlots) - : slotsToRefresh; - - if (slotsNeedingRefresh.length > 0) { - // One-shot bypass: this internal refresh delivers the just-applied - // server-side targeting to GAM. If slim-Prebid has already wrapped - // refresh(), it must pass this call straight through — not clear the - // targeting and run a duplicate client-side auction. Mirrors the - // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts. - ts.adInitRefreshInProgress = true; - try { - runHandoffInternal(function () { - googletag.pubads().refresh(slotsNeedingRefresh); + slotsToDisplay.forEach(function(divId) { + runHandoffInternal(function() { + googletag.display(divId); }); - } finally { - ts.adInitRefreshInProgress = false; + }); + syncInitialLoadDisabled(window.googletag); + var slotsNeedingRefresh = ts.gptInitialLoadDisabled ? slotsToRefresh.concat(newSlots) : slotsToRefresh; + if (slotsNeedingRefresh.length > 0) { + ts.adInitRefreshInProgress = true; + try { + runHandoffInternal(function() { + googletag.pubads().refresh(slotsNeedingRefresh); + }); + } finally { + ts.adInitRefreshInProgress = false; + } } - } - }); - }; + }); + }; + })(); })(); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 85508306a4..588fca71c0 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -2423,9 +2423,7 @@ mod tests { for enforced_csp in [false, true] { let inserts = registry.head_inserts_with_response_csp(&context, enforced_csp); assert_eq!( - inserts - .join("") - .contains("// Edge-injected GPT auction bootstrap."), + inserts.join("").contains(include_str!("gpt_bootstrap.js")), gpt_enabled && didomi_enabled && !enforced_csp, "should select provisional initialization from real CSP and module dependencies" ); diff --git a/crates/trusted-server-integration-tests/browser/cwv/runtime-order.cjs b/crates/trusted-server-integration-tests/browser/cwv/runtime-order.cjs new file mode 100644 index 0000000000..f0f88550da --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/runtime-order.cjs @@ -0,0 +1,42 @@ +const assert = require("node:assert/strict"); +const { scriptBlocks } = require("./html.cjs"); + +function assertRuntimeOrder(html, origin, placeholderId) { + const scripts = scriptBlocks(html); + const runtimes = scripts.filter((script) => script.id === "trustedserver-js"); + assert.equal(runtimes.length, 1); + const runtime = runtimes[0]; + assert.equal( + new URL(runtime.src, origin).pathname, + "/static/tsjs=tsjs-unified.min.js", + ); + assert.doesNotMatch( + html.slice(runtime.startOffset, runtime.endOffset), + /\s(?:async|defer)(?:\s|=|>)/, + ); + for (const config of [ + "window.__tsjs_gpt_enabled=true", + "window.__tsjs_prebid=", + ]) { + const script = scripts.find((entry) => entry.content.includes(config)); + assert.ok(script, `${config} must precede the synchronous runtime`); + assert.ok(script.endOffset <= runtime.startOffset); + } + assert.ok(runtime.endOffset <= html.indexOf(`id="${placeholderId}"`)); + assert.equal( + scripts.some((script) => script.id === "trustedserver-bootstrap"), + false, + ); + for (const api of [ + "publisherSlotRegistrationVersion", + "tsRenderBridgeVersion", + ]) { + assert.equal( + scripts.some((script) => !script.src && script.content.includes(api)), + false, + `no inline ${api} fallback`, + ); + } +} + +module.exports = { assertRuntimeOrder }; diff --git a/crates/trusted-server-integration-tests/browser/cwv/runtime-order.test.cjs b/crates/trusted-server-integration-tests/browser/cwv/runtime-order.test.cjs new file mode 100644 index 0000000000..6e04adfd57 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/runtime-order.test.cjs @@ -0,0 +1,80 @@ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { assertRuntimeOrder } = require("./runtime-order.cjs"); + +const origin = "https://publisher.example.com"; +const configs = [ + "window.__tsjs_gpt_enabled=true", + "window.__tsjs_prebid={}", +].map((content) => ``); +const runtime = + ''; + +for (const placeholderId of [ + "fixture-header-placeholder", + "real-gpt-placeholder", +]) { + const placeholder = `
`; + const html = configs.join("") + runtime + placeholder; + + test(`${placeholderId}: accepts the synchronous runtime with explicit origin and placeholder`, () => { + assert.doesNotThrow(() => assertRuntimeOrder(html, origin, placeholderId)); + assert.doesNotThrow(() => + assertRuntimeOrder( + html.replace('src="/static/', 'src="static/'), + origin, + placeholderId, + ), + ); + assert.throws(() => assertRuntimeOrder(html, "", placeholderId)); + assert.throws(() => assertRuntimeOrder(html, origin, "wrong-placeholder")); + for (const api of [ + "publisherSlotRegistrationVersion", + "tsRenderBridgeVersion", + ]) { + assert.doesNotThrow(() => + assertRuntimeOrder( + html + ``, + origin, + placeholderId, + ), + ); + } + }); + + const invalid = [ + ["missing runtime", html.replace(runtime, "")], + ["duplicate runtime", html + runtime], + ["wrong runtime path", html.replace("tsjs-unified", "tsjs-gpt")], + ...["async", "defer"].map((attribute) => [ + `${attribute} runtime`, + html.replace( + 'id="trustedserver-js"', + `${attribute} id="trustedserver-js"`, + ), + ]), + ...configs.flatMap((config, index) => [ + [`missing config ${index}`, html.replace(config, "")], + [`late config ${index}`, html.replace(config, "") + config], + ]), + ["late runtime", configs.join("") + placeholder + runtime], + ["missing placeholder", html.replace(placeholder, "")], + [ + "inline bootstrap", + html + '', + ], + ...["publisherSlotRegistrationVersion", "tsRenderBridgeVersion"].map( + (api) => [ + `inline ${api} fallback`, + html + ``, + ], + ), + ]; + for (const [name, invalidHtml] of invalid) { + test(`${placeholderId}: rejects ${name}`, () => { + assert.throws(() => + assertRuntimeOrder(invalidHtml, origin, placeholderId), + ); + }); + } +} diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/README.md b/crates/trusted-server-integration-tests/browser/publisher-registration/README.md index 4700def0be..49512fada7 100644 --- a/crates/trusted-server-integration-tests/browser/publisher-registration/README.md +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/README.md @@ -37,8 +37,10 @@ ordinary bootstrap fill followed by late runtime with no candidate resurrection. Real history push/popstate cases keep bootstrap-only A-to-B-to-A registrations unadmitted, prove one late-runtime current-path catchup without replacing the waiting handle or generation, and reject a forced late B response after C. -A failed catchup can retry through a same-path history replace with query/hash -changes while preserving the waiting handle, token, native slot and generation. +Both an ordinary runtime navigation and a provisional catchup can retry a failed +page-bids request through a same-path history replace with query/hash changes. +The retry preserves the waiting handle, token, native slot and generation; a +successful retry delivers once and later same-path updates do not refetch. No-navigation runtime arrival must not fetch or request again. Every successful initial delivery independently checks the exact native object, diff --git a/crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs b/crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs index 7ca03d6bae..ce768d0be8 100644 --- a/crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs @@ -594,7 +594,12 @@ function fixedCases(implementation) { return [ ...commonCases, ...(implementation !== "bootstrap" - ? ["navigation-stale", "mixed-navigation-cleanup", "runtime-reinstall"] + ? [ + "navigation-stale", + "mixed-navigation-cleanup", + "runtime-reinstall", + "history-runtime-failure-retry", + ] : ["stale-generation", "bootstrap-history-a-b-a"]), ...(implementation === "handoff" ? [ @@ -659,6 +664,80 @@ async function fixed(page, scenario, implementation, transport) { if (implementation === "handoff") await runtime(page); }; + if (scenario === "history-runtime-failure-retry") { + await takeover(); + await accept(page, data); + await page.evaluate(() => history.pushState({}, "", "/ordinary-retry-b")); + const failed = await transport.next("/_ts/page-bids"); + await mount(page); + await page.evaluate(() => { + window.pendingRetryRecord = + tsjs.publisherSlotRegistrationState.records.header; + window.pendingRetryHandle = publisherRefs.header.handle; + }); + const response = page.waitForResponse(failed.request().url()); + await failed.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: "fixture-unavailable" }), + }); + await response; + await drain(page); + assert.equal((await status(page)).status, "waiting_payload"); + assert.equal(await page.evaluate(() => fixture.requests.length), 0); + await page.evaluate(() => + history.replaceState({}, "", "/ordinary-retry-b?retry=1#placement"), + ); + const retried = await transport.next("/_ts/page-bids"); + const retained = await page.evaluate(() => ({ + generation: tsjs.navGeneration, + sameRecord: + tsjs.publisherSlotRegistrationState.records.header === + pendingRetryRecord, + sameHandle: publisherRefs.header.handle === pendingRetryHandle, + sameNative: publisherRefs.header.native === pendingRetryRecord.gptSlot, + sameElement: publisherRefs.header.element === pendingRetryRecord.element, + status: pendingRetryHandle.getStatus(), + })); + assert.equal(retained.generation, 1, "Retry is not a new navigation"); + assert( + retained.sameRecord && + retained.sameHandle && + retained.sameNative && + retained.sameElement, + "Same-path retry retains the pending registration and its exact identities", + ); + assert.equal(retained.status.status, "waiting_payload"); + const bData = structuredClone(data); + bData.bids.header.hb_adid = "example-retried-header"; + await retried.fulfill({ + contentType: "application/json", + body: JSON.stringify(bData), + }); + const facts = await assertDelivery(page, "header", true); + assert.deepEqual(facts.entries[0].targeting.hb_adid, [ + "example-retried-header", + ]); + assert.equal( + await page.evaluate(() => pendingRetryHandle.getStatus().status), + "rendered", + ); + await page.evaluate(() => + history.replaceState({}, "", "/ordinary-retry-b?after=success"), + ); + await drain(page); + assert.equal(await page.evaluate(() => tsjs.navGeneration), 1); + assert.equal(await page.evaluate(() => fixture.requests.length), 1); + assert.equal( + transport.network.filter( + (entry) => new URL(entry.url).pathname === "/_ts/page-bids", + ).length, + 2, + "Only the failed request and its explicit retry may fetch page-bids", + ); + return { retained, facts }; + } + if ( scenario === "bootstrap-history-a-b-a" || scenario === "history-runtime-catchup" || diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts index 26e4ce4ae6..83f90e4fbd 100644 --- a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.real-gpt.spec.ts @@ -14,7 +14,8 @@ const { sha256, assertEvidence, } = require("../../publisher-registration/real-gpt.cjs"); -const { rendererAsset, scriptBlocks } = require("../../cwv/html.cjs"); +const { rendererAsset } = require("../../cwv/html.cjs"); +const { assertRuntimeOrder } = require("../../cwv/runtime-order.cjs"); const { marker, } = require("../../publisher-registration/marker-visibility.cjs"); @@ -268,54 +269,6 @@ async function install( return session; } -function assertRuntimeOrder(html: string) { - const scripts = scriptBlocks(html); - const runtimes = scripts.filter( - (script: { id: string }) => script.id === "trustedserver-js", - ); - expect(runtimes).toHaveLength(1); - const runtime = runtimes[0]; - expect(new URL(runtime.src, baseUrl()).pathname).toBe( - "/static/tsjs=tsjs-unified.min.js", - ); - expect(html.slice(runtime.startOffset, runtime.endOffset)).not.toMatch( - /\s(?:async|defer)(?:\s|=|>)/, - ); - for (const config of [ - "window.__tsjs_gpt_enabled=true", - "window.__tsjs_prebid=", - ]) { - const script = scripts.find((entry: { content: string }) => - entry.content.includes(config), - ); - expect( - script, - `${config} must precede the synchronous runtime`, - ).toBeTruthy(); - expect(script.endOffset).toBeLessThanOrEqual(runtime.startOffset); - } - expect(runtime.endOffset).toBeLessThanOrEqual( - html.indexOf('id="real-gpt-placeholder"'), - ); - expect( - scripts.some( - (script: { id: string }) => script.id === "trustedserver-bootstrap", - ), - ).toBe(false); - for (const api of [ - "publisherSlotRegistrationVersion", - "tsRenderBridgeVersion", - ]) { - expect( - scripts.some( - (script: { src: string | null; content: string }) => - !script.src && script.content.includes(api), - ), - `no inline ${api} fallback`, - ).toBe(false); - } -} - async function ready(page: Page, variant = "") { const response = await page.goto( `${baseUrl()}/publisher-registration-real-gpt${variant}`, @@ -323,7 +276,7 @@ async function ready(page: Page, variant = "") { waitUntil: "commit", }, ); - assertRuntimeOrder(await response!.text()); + assertRuntimeOrder(await response!.text(), baseUrl(), "real-gpt-placeholder"); await waitReady(page); } @@ -624,7 +577,11 @@ if (!baselineMode) { ), ).toEqual([]); await session.releaseRuntime(); - assertRuntimeOrder(await response!.text()); + assertRuntimeOrder( + await response!.text(), + baseUrl(), + "real-gpt-placeholder", + ); await waitReady(page); await mount(page); await verify(page, [await delivered(page, true)]); @@ -640,7 +597,11 @@ if (!baselineMode) { const response = await page.goto( `${baseUrl()}/publisher-registration-real-gpt`, ); - assertRuntimeOrder(await response!.text()); + assertRuntimeOrder( + await response!.text(), + baseUrl(), + "real-gpt-placeholder", + ); // GPT and React may still initialize. That does not make TS registration ready. await expect .poll(() => diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts index 9ca920af77..9f6e42f53b 100644 --- a/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts @@ -13,6 +13,7 @@ import type { PublisherController } from "../../../fixtures/frameworks/nextjs/ap const { creative, pucPage } = require("../../publisher-registration/pages.cjs"); const { scriptBlocks, rendererAsset } = require("../../cwv/html.cjs"); +const { assertRuntimeOrder } = require("../../cwv/runtime-order.cjs"); const enabled = process.env.PUBLISHER_REGISTRATION_RUN === "1"; test.skip( !enabled, @@ -316,54 +317,6 @@ async function install( }); } -function assertRuntimeOrder(html: string) { - const scripts = scriptBlocks(html); - const runtimes = scripts.filter( - (script: { id: string }) => script.id === "trustedserver-js", - ); - expect(runtimes).toHaveLength(1); - const runtime = runtimes[0]; - expect(new URL(runtime.src, runtimeUrl("/")).pathname).toBe( - "/static/tsjs=tsjs-unified.min.js", - ); - expect(html.slice(runtime.startOffset, runtime.endOffset)).not.toMatch( - /\s(?:async|defer)(?:\s|=|>)/, - ); - for (const config of [ - "window.__tsjs_gpt_enabled=true", - "window.__tsjs_prebid=", - ]) { - const script = scripts.find((entry: { content: string }) => - entry.content.includes(config), - ); - expect( - script, - `${config} must precede the synchronous runtime`, - ).toBeTruthy(); - expect(script.endOffset).toBeLessThanOrEqual(runtime.startOffset); - } - expect(runtime.endOffset).toBeLessThanOrEqual( - html.indexOf('id="fixture-header-placeholder"'), - ); - expect( - scripts.some( - (script: { id: string }) => script.id === "trustedserver-bootstrap", - ), - ).toBe(false); - for (const api of [ - "publisherSlotRegistrationVersion", - "tsRenderBridgeVersion", - ]) { - expect( - scripts.some( - (script: { src: string | null; content: string }) => - !script.src && script.content.includes(api), - ), - `no inline ${api} fallback`, - ).toBe(false); - } -} - async function ready(page: Page) { // Node-side polling still observes the real readiness conditions when a case // deliberately holds the page's animation frames to control dispatch order. @@ -655,7 +608,7 @@ test("actual Rust HTML, admitted payload, delayed mount and later real Prebid re const html = (await response!.text()).replaceAll('\\"', '"'); expect(html).toContain('"initialization":"publisher_registered"'); expect(html).toContain("scheduleInitialAdInit"); - assertRuntimeOrder(html); + assertRuntimeOrder(html, runtimeUrl("/"), "fixture-header-placeholder"); if (process.env.PUBLISHER_REGISTRATION_ASSEMBLY === "shared") expect(html).toContain('if(typeof s==="function")s(b,a)'); else expect(html).toContain('if(typeof s==="function")s(b);'); @@ -1004,7 +957,11 @@ test("held critical unified runtime blocks parsing and readiness until release", }); await session.releaseRuntime(); await ready(page); - assertRuntimeOrder(await response!.text()); + assertRuntimeOrder( + await response!.text(), + runtimeUrl("/"), + "fixture-header-placeholder", + ); await mount(page); const initial = await delivered(page); expect(initial.targeting.hb_adid).toEqual(["fixture-win-fixture-header"]); @@ -1021,7 +978,11 @@ test("failed critical unified runtime leaves no TS APIs or registered delivery a const response = await page.goto( runtimeUrl("/publisher-registration/registered-only"), ); - assertRuntimeOrder(await response!.text()); + assertRuntimeOrder( + await response!.text(), + runtimeUrl("/"), + "fixture-header-placeholder", + ); await expect(page.getByTestId("ssr-placeholder")).toBeVisible(); await expect .poll(() => diff --git a/crates/trusted-server-js/README.md b/crates/trusted-server-js/README.md index 254ab6f55c..72c8dcd338 100644 --- a/crates/trusted-server-js/README.md +++ b/crates/trusted-server-js/README.md @@ -22,6 +22,19 @@ npm run format npm run build ``` +The provisional GPT bootstrap embedded by `trusted-server-core` is generated +from `lib/src/integrations/gpt/bootstrap.template.js` and the shared +`publisher_registration.ts` protocol. After changing either source, regenerate +the checked-in artifact from `lib`: + +```bash +npm run build:gpt-bootstrap -- --write +``` + +`npm run build:gpt-bootstrap -- --check` checks freshness without writing files. +The regular JS build also rejects stale output before touching bundle directories. +Cargo prebuilt mode embeds the checked-in bootstrap without invoking Node. + Rust target checks include this crate through `cargo test-fastly`. See [Trusted Server JavaScript](../../docs/guide/tsjs.md) for the 12 integration entries, 13 emitted bundles, and immediate, deferred, and standalone loading diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 0fc7b01abd..294069a6d9 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -32,6 +32,8 @@ fn main() { for path in [ "lib/src", "lib/build-all.mjs", + "lib/build-gpt-bootstrap.mjs", + "../trusted-server-core/src/integrations/gpt_bootstrap.js", "lib/trace-asset-sources.mjs", "lib/build-trace.mjs", "lib/trace-build-inputs.json", diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 9745d06d95..16b4995349 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -23,6 +23,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; +import { checkGptBootstrap } from './build-gpt-bootstrap.mjs'; import { buildTraceAssets } from './build-trace.mjs'; import { moduleBuildOptions } from './build-module-options.mjs'; @@ -44,6 +45,9 @@ function resolveOutDir(args) { return path.resolve(value); } +// Check before touching output, including Cargo's private OUT_DIR bundles. +await checkGptBootstrap(); + // Remove only the bundles this script writes, so a wrong --out-dir cannot // delete unrelated files. fs.mkdirSync(distDir, { recursive: true }); diff --git a/crates/trusted-server-js/lib/build-gpt-bootstrap.mjs b/crates/trusted-server-js/lib/build-gpt-bootstrap.mjs new file mode 100644 index 0000000000..54ba9f56f5 --- /dev/null +++ b/crates/trusted-server-js/lib/build-gpt-bootstrap.mjs @@ -0,0 +1,62 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'vite'; + +const libDir = path.dirname(fileURLToPath(import.meta.url)); +const templatePath = path.join(libDir, 'src/integrations/gpt/bootstrap.template.js'); +const bootstrapPath = path.resolve( + libDir, + '../../trusted-server-core/src/integrations/gpt_bootstrap.js' +); +const header = `// Generated from trusted-server-js/lib/src/integrations/gpt/bootstrap.template.js +// and publisher_registration.ts. Do not edit this file directly. +// Regenerate: cd crates/trusted-server-js/lib && npm run build:gpt-bootstrap -- --write +`; + +/** Render the standalone inline bootstrap with the existing Vite toolchain. */ +export async function renderGptBootstrap(entryPath = templatePath) { + const result = await build({ + configFile: false, + root: libDir, + esbuild: { supported: { 'inline-script': true } }, + build: { + write: false, + emptyOutDir: false, + // Object spread already exists in the retained bootstrap. Avoid downlevel + // helpers outside Rollup's IIFE, which would introduce browser globals. + target: 'es2018', + sourcemap: false, + minify: false, + rollupOptions: { + input: entryPath, + output: { format: 'iife', inlineDynamicImports: true }, + }, + }, + logLevel: 'warn', + }); + return header + result.output[0].code; +} + +/** Refuse stale checked-in output without mutating source or build directories. */ +export async function checkGptBootstrap(artifactPath = bootstrapPath) { + const generated = await renderGptBootstrap(); + if ((await fs.readFile(artifactPath, 'utf8')) !== generated) { + throw new Error( + 'GPT bootstrap is stale. Run `npm run build:gpt-bootstrap -- --write` in crates/trusted-server-js/lib.' + ); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + if (args.length !== 1 || !['--check', '--write'].includes(args[0])) { + throw new Error('Usage: node build-gpt-bootstrap.mjs --check|--write'); + } + if (args[0] === '--write') { + await fs.writeFile(bootstrapPath, await renderGptBootstrap()); + } else { + await checkGptBootstrap(); + } +} diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 6b40f82a63..2a00feca78 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -6,6 +6,7 @@ "description": "Trusted Server tsjs TypeScript library with queue and simple banner rendering.", "scripts": { "build": "node build-all.mjs", + "build:gpt-bootstrap": "node build-gpt-bootstrap.mjs", "build:trace-assets": "node build-trace-assets.mjs", "build:prebid-external": "node build-prebid-external.mjs", "dev": "vite build --watch", diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/bootstrap.template.js b/crates/trusted-server-js/lib/src/integrations/gpt/bootstrap.template.js new file mode 100644 index 0000000000..04a982080a --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt/bootstrap.template.js @@ -0,0 +1,978 @@ +/* global window, document, location, history, googletag */ + +import { + clearPreviousNavigationTargeting, + installPublisherSlotRegistration, + invalidatePublisherSlotRegistrations, + observePublisherSlotRender, + retainPublisherSlotMappings, + settlePublisherSlotPayload, +} from './publisher_registration'; + +// Edge-injected GPT auction bootstrap. +// +// This is the minimal `window.tsjs.adInit` that runs on first page load +// before the TSJS bundle has had a chance to install its richer +// idempotent implementation. The bundle in +// crates/trusted-server-js/lib/src/integrations/gpt/index.ts overwrites `tsjs.adInit` +// once it loads. +// +// Contract with the bundle: +// - Both implementations must set `window.tsjs.servicesEnabled = true` +// after calling `enableSingleRequest()`/`enableServices()` so a +// subsequent call becomes a no-op. +// - `refresh()` is called only for the slots defined in this pass, +// never the global slot list. +// +// Only installed if `window.tsjs.adInit` isn't already defined. +(function () { + if (typeof window === 'undefined') return; + var ts = (window.tsjs = window.tsjs || {}); + var tag; + + if (window.__tsjs_gam_attribution_enabled === true) { + tag = window.googletag = window.googletag || { cmd: [] }; + tag.cmd = tag.cmd || []; + tag.cmd.push(function () { + try { + var gpt = window.googletag; + if (gpt && typeof gpt.setConfig === 'function') { + // "ts" is the fixed GAM key, not the local window.tsjs alias. + gpt.setConfig({ targeting: { ts: 'true' } }); + } + } catch (error) { + // Attribution must not interrupt the existing bootstrap queue. + if (ts.log && ts.log.warn) ts.log.warn('GAM attribution targeting failed', error); + } + }); + } + + if (ts.adInit) return; + + // Track whether the publisher disabled GPT initial load. Read the effective + // googletag.getConfig() value when available, and wrap googletag.setConfig() + // and the legacy pubads().disableInitialLoad() method so changes are + // synchronized immediately and still detected when getConfig() is + // unavailable. With initial load disabled, display() only registers a slot + // and the ad request must come from a later refresh(); adInit() reads this to + // refresh its own freshly defined + // slots so they are not left blank. Pushed onto the command queue so it runs + // before the publisher's own GPT configuration. + function syncInitialLoadDisabled(gpt) { + if (typeof gpt.getConfig !== 'function') return false; + var config = gpt.getConfig('disableInitialLoad'); + if (!config || typeof config.disableInitialLoad === 'undefined') { + return false; + } + ts.gptInitialLoadDisabled = config.disableInitialLoad === true; + return true; + } + + tag = tag || (window.googletag = window.googletag || { cmd: [] }); + tag.cmd = tag.cmd || []; + tag.cmd.push(function () { + var gpt = window.googletag; + syncInitialLoadDisabled(gpt); + if (typeof gpt.setConfig === 'function' && !gpt.__tsInitialLoadConfigHooked) { + var originalSetConfig = gpt.setConfig.bind(gpt); + gpt.setConfig = function (config) { + var result = originalSetConfig.apply(gpt, arguments); + if (!syncInitialLoadDisabled(gpt) && config && 'disableInitialLoad' in config) { + ts.gptInitialLoadDisabled = config.disableInitialLoad === true; + } + return result; + }; + gpt.__tsInitialLoadConfigHooked = true; + } + + var pubads = gpt.pubads && gpt.pubads(); + if ( + !pubads || + typeof pubads.disableInitialLoad !== 'function' || + pubads.__tsInitialLoadHooked + ) { + return; + } + var originalDisableInitialLoad = pubads.disableInitialLoad.bind(pubads); + pubads.disableInitialLoad = function () { + var result = originalDisableInitialLoad.apply(pubads, arguments); + if (!syncInitialLoadDisabled(gpt)) { + ts.gptInitialLoadDisabled = true; + } + return result; + }; + pubads.__tsInitialLoadHooked = true; + }); + + var FIRST_IMPRESSION_LEASE_MS = 5000; + var MAX_FIRST_IMPRESSION_SLOTS = 256; + + function firstImpressionState(now) { + var generation = ts.navGeneration || 0; + if (!ts.firstImpression || ts.firstImpression.generation !== generation) { + ts.firstImpression = { + generation: generation, + nextToken: 0, + slots: Object.create(null), + fallbackSlots: Object.create(null), + }; + } + var state = ts.firstImpression; + state.slots = Object.assign(Object.create(null), state.slots); + state.fallbackSlots = Object.assign(Object.create(null), state.fallbackSlots); + Object.keys(state.slots).forEach(function (elementId) { + var claim = state.slots[elementId]; + if ( + claim.generation !== generation || + claim.slotElementId !== elementId || + claim.element.ownerDocument !== document || + claim.element !== document.getElementById(elementId) || + !claim.element.isConnected + ) { + delete state.slots[elementId]; + return; + } + var hasReservedFallback = + claim.owner === 'publisher' && + (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && + state.fallbackSlots[elementId] === claim.element; + Object.keys(claim.publisherAuctions || {}).forEach(function (token) { + var auction = claim.publisherAuctions[token]; + if ( + auction.expiresAt <= now && + !hasReservedFallback && + !(claim.owner === 'trusted_server' && auction.suppressDelivery) + ) { + delete claim.publisherAuctions[token]; + } + }); + if ( + claim.owner === 'publisher' && + (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && + Object.keys(claim.publisherAuctions || {}).length === 0 && + claim.expiresAt <= now && + !hasReservedFallback + ) { + delete state.slots[elementId]; + } + }); + Object.keys(state.fallbackSlots).forEach(function (elementId) { + var element = state.fallbackSlots[elementId]; + if ( + !element.isConnected || + element.id !== elementId || + document.getElementById(elementId) !== element + ) { + delete state.fallbackSlots[elementId]; + } + }); + return state; + } + + function firstImpressionClaim(element) { + return firstImpressionState(Date.now()).slots[element.id]; + } + + function storeFirstImpressionClaim(state, claim) { + if ( + !state.slots[claim.slotElementId] && + Object.keys(state.slots).length >= MAX_FIRST_IMPRESSION_SLOTS + ) { + return false; + } + state.slots[claim.slotElementId] = claim; + return true; + } + + function schedulePendingRenderDiagnostic(claim) { + var startedAt = Date.now(); + window.setTimeout(function () { + if ( + !ts.firstImpression || + ts.firstImpression.slots[claim.slotElementId] !== claim || + claim.generation !== (ts.navGeneration || 0) || + !claim.element.isConnected || + document.getElementById(claim.slotElementId) !== claim.element || + claim.owner !== 'trusted_server' || + claim.phase === 'rendered' + ) + return; + var diagnostic = { phase: claim.phase, ageMs: Date.now() - startedAt }; + claim.pendingRenderDiagnostic = diagnostic; + try { + if (ts.log && ts.log.debug) { + ts.log.debug('[tsjs-gpt] initial render remains pending', { + slot: claim.slotElementId, + generation: claim.generation, + phase: diagnostic.phase, + ageMs: diagnostic.ageMs, + }); + } + } catch (_error) { + // Optional logging must never affect initial ownership or delivery. + } + }, FIRST_IMPRESSION_LEASE_MS); + } + + function claimFirstImpressionForTrustedServer(element) { + var now = Date.now(); + var state = firstImpressionState(now); + var existing = state.slots[element.id]; + if (existing) { + var canTransitionPublisherFallback = + existing.owner === 'publisher' && + existing.phase !== 'requested' && + existing.phase !== 'rendered' && + existing.expiresAt <= now && + state.fallbackSlots[element.id] === element; + if (!canTransitionPublisherFallback) return null; + existing.owner = 'trusted_server'; + existing.phase = 'delivery_pending'; + // Deliberately unbounded, matching first_impression.ts: only render, + // navigation, or element replacement ends TS initial ownership. + existing.expiresAt = Number.POSITIVE_INFINITY; + Object.keys(existing.publisherAuctions || {}).forEach(function (token) { + existing.publisherAuctions[token].suppressDelivery = true; + }); + schedulePendingRenderDiagnostic(existing); + return existing; + } + var claim = { + generation: state.generation, + slotElementId: element.id, + element: element, + owner: 'trusted_server', + phase: 'delivery_pending', + // Unbounded by contract; see the fallback transition above. + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: Object.create(null), + }; + if (!storeFirstImpressionClaim(state, claim)) return null; + schedulePendingRenderDiagnostic(claim); + return claim; + } + + function releaseTrustedServerFirstImpressionClaim(element, claim) { + var state = firstImpressionState(Date.now()); + if ( + state.slots[element.id] === claim && + claim.owner === 'trusted_server' && + claim.phase === 'delivery_pending' + ) { + delete state.slots[element.id]; + if (state.fallbackSlots[element.id] === element) { + delete state.fallbackSlots[element.id]; + } + } + } + + function installFirstImpressionListeners() { + if (ts.firstImpressionListenersInstalled) return; + tag.cmd.push(function () { + if (ts.firstImpressionListenersInstalled) return; + var pubads = window.googletag.pubads(); + if (!pubads || typeof pubads.addEventListener !== 'function') return; + var observe = function (phase) { + return function (event) { + var elementId = + event.slot && event.slot.getSlotElementId ? event.slot.getSlotElementId() : ''; + var element = elementId && document.getElementById(elementId); + if (!element) return; + if (phase === 'rendered') observePublisherSlotRender(ts, event.slot); + var state = firstImpressionState(Date.now()); + var claim = state.slots[elementId]; + if (!claim) { + storeFirstImpressionClaim(state, { + generation: state.generation, + slotElementId: elementId, + element: element, + owner: 'publisher', + phase: phase, + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: Object.create(null), + }); + return; + } + if (claim.phase !== 'rendered') claim.phase = phase; + if (claim.owner === 'publisher') { + claim.expiresAt = Number.POSITIVE_INFINITY; + } else if (phase === 'rendered') { + claim.publisherRegistrationClosed = true; + } + }; + }; + pubads.addEventListener('slotRequested', observe('requested')); + pubads.addEventListener('slotRenderEnded', observe('rendered')); + ts.firstImpressionListenersInstalled = true; + }); + } + + installFirstImpressionListeners(); + + installPublisherSlotRegistration(ts, claimFirstImpressionForTrustedServer, function () { + return window.__tsjs_trace_active === true ? ts.traceGpt : undefined; + }); + + // One observer resolves a replaceable handler, so runtime takeover preserves + // framework wrappers and transfers counter ownership instead of nesting writers. + function installBootstrapNavigationBridge() { + if (ts.gptNavigationBridge) return; + var currentPath = location.pathname; + ts.gptNavigationBridge = { + onNavigate: function (path) { + if (path === currentPath) return; + currentPath = path; + clearPreviousNavigationTargeting(ts, window.googletag); + ts.navGeneration = (ts.navGeneration || 0) + 1; + invalidatePublisherSlotRegistrations(ts); + delete ts.firstImpression; + Object.entries(ts.gptSlotHandoffs || {}).forEach(function (entry) { + if (!entry[1].publisherClaimed) delete ts.gptSlotHandoffs[entry[0]]; + }); + ts.gptNavigationBridge.pendingGeneration = ts.navGeneration; + }, + }; + var observe = function () { + ts.gptNavigationBridge.onNavigate(location.pathname); + }; + ['pushState', 'replaceState'].forEach(function (method) { + var original = history[method].bind(history); + history[method] = function (state, unused, url) { + original(state, unused, url); + observe(); + }; + }); + window.addEventListener('popstate', observe, true); + } + installBootstrapNavigationBridge(); + + // Minimal fallback for tsjs.scheduleInitialAdInit, mirroring the bundle's + // hydration-safe scheduler in + // crates/trusted-server-js/lib/src/integrations/gpt/index.ts: the + // bids script hands the SSR bids payload to this scheduler, which applies + // it and runs adInit only while the page is still on navigation + // generation 0 (the SSR document), after window load plus a double + // requestAnimationFrame so the call lands outside React's hydration + // window. Keeps initial server-side ads working when the main TSJS bundle + // fails to load; the bundle overwrites this with the full implementation. + // + // Hidden documents: rAF is not serviced while the document is hidden, so a + // background-tab load holds the initial adInit until first view. Intended, + // and deliberately identical to the bundle scheduler — the impression is + // spent on a viewed tab, and the post-hydration guarantee holds whenever + // the request is actually issued. + ts.scheduleInitialAdInit = function (initialBids, initialSlots, traceAuctionTransport) { + // The bundle may replace this scheduler after the fallback claims the initial + // pass. Keep the latch on the shared document API so replacement cannot reset it. + if ((ts.navGeneration || 0) !== 0 || ts.initialAdInitScheduled) return; + ts.initialAdInitScheduled = true; + // Slots are generation-guarded for the same reason the bids are: the + // shared-template seam sends both, and an assignment made before this call + // would overwrite a committed SPA navigation's slots. + if (initialSlots !== undefined) ts.adSlots = initialSlots; + if (initialBids !== undefined) ts.bids = initialBids; + // Bind before registered dispatch; the deferred automatic pass must not + // re-observe this batch and replace newer trace bindings. + try { + if (window.__tsjs_trace_active === true && ts.traceGpt) { + ts.traceGpt.observeTransport( + initialSlots === undefined ? ts.adSlots : initialSlots, + traceAuctionTransport, + 'initial_navigation_ssat' + ); + } + } catch (_) { + // Trace failure cannot interrupt the initial advertising pass. + } + if (initialBids !== undefined || initialSlots !== undefined) { + settlePublisherSlotPayload(ts, 0, ts.adSlots || [], ts.bids || {}); + } + var fire = function () { + if ((ts.navGeneration || 0) !== 0) return; + if (typeof ts.adInit === 'function') ts.adInit(); + }; + var afterFrames = function () { + window.requestAnimationFrame(function () { + window.requestAnimationFrame(fire); + }); + }; + if (document.readyState === 'complete') afterFrames(); + else window.addEventListener('load', afterFrames, { once: true }); + }; + + function recordTraceOpportunity(gptSlot, slot, bid, fallback) { + if (window.__tsjs_trace_active !== true) return; + try { + var bridge = ts.traceGpt; + if (!bridge || !bridge.identity(slot) || !ts.gptDiagnosticsRecorder) return; + var trace = bridge.opportunity(slot, bid.hb_auction_id); + var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[gptSlot.getSlotElementId()]; + var requestedSizes = fallback ? slot.formats : handoff && handoff.formats; + var nonempty = function (value) { + return typeof value === 'string' && value.length > 0; + }; + // Mirror the bundle's opportunity classification at the same concrete + // GPT binding. Token validation remains in the single core bridge. + var hasTargeting = ['hb_pb', 'hb_bidder', 'hb_adid', 'hb_cache_host', 'hb_cache_path'].some( + function (key) { + return nonempty(bid[key]); + } + ); + var opportunity = !hasTargeting + ? 'no_candidate' + : nonempty(bid.hb_adid) && + (nonempty(bid.adm) || (nonempty(bid.hb_cache_host) && nonempty(bid.hb_cache_path))) + ? 'renderable_candidate' + : 'unrenderable_candidate'; + ts.gptDiagnosticsRecorder.recordTrustedServerOpportunity( + gptSlot, + slot.id, + opportunity, + trace.auctionId, + requestedSizes, + trace.identity + ); + } catch (_) { + // Diagnostics never change targeting, display, or refresh. + } + } + + function findSlotByElementId(pubads, elementId) { + var slots = pubads.getSlots ? pubads.getSlots() : []; + return ( + slots.find(function (slot) { + return slot.getSlotElementId() === elementId; + }) || null + ); + } + + function normalizedGptFormats(formats) { + return formats.length === 2 && + formats.every(function (format) { + return typeof format === 'number'; + }) + ? [formats] + : formats; + } + + function handoffFormatsMatch(handoff, formats) { + return JSON.stringify(handoff.formats) === JSON.stringify(normalizedGptFormats(formats)); + } + + function matchingHandoff(pubads, adUnitPath, formats, elementId) { + var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if (exact) return exact.publisherClaimed ? null : exact; + + var candidates = Object.values(ts.gptSlotHandoffs || {}).filter( + function (handoff, index, allHandoffs) { + return ( + allHandoffs.indexOf(handoff) === index && + !handoff.publisherClaimed && + !document.getElementById(handoff.slotElementId) && + elementId.startsWith(handoff.divIdPrefix) && + handoff.gamUnitPath === adUnitPath && + handoffFormatsMatch(handoff, formats) && + findSlotByElementId(pubads, handoff.slotElementId) + ); + } + ); + return candidates.length === 1 ? candidates[0] : null; + } + + function displayTargetElementId(target) { + if (typeof target === 'string') return target; + if (target && typeof target.getSlotElementId === 'function') { + return target.getSlotElementId(); + } + return target && target.id ? target.id : null; + } + + function isElementVisible(element) { + if (typeof element.checkVisibility === 'function') { + return element.checkVisibility({ + checkVisibilityCSS: true, + visibilityProperty: true, + }); + } + + for (var current = element; current; current = current.parentElement) { + var style = window.getComputedStyle(current); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.visibility === 'collapse' + ) { + return false; + } + } + return true; + } + + function slotElementHasLayout(element) { + if (!isElementVisible(element)) return false; + var elementRect = element.getBoundingClientRect(); + if (elementRect.width > 0 && elementRect.height > 0) return true; + + var container = document.getElementById(element.id + '-container'); + if (!container || !isElementVisible(container)) return false; + var containerRect = container.getBoundingClientRect(); + return containerRect.width > 0; + } + + function resolveSlotElementByDivId(divId) { + if (!divId) { + return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; + } + // Exact-id matches intentionally skip the visibility tiers below: a + // configured literal id is unambiguous, so a hidden match is still the + // right element. Prefix matches go through the tiers because a prefix can + // match several candidates and only visibility/layout disambiguates them — + // so a hidden exact-id match resolves while a hidden prefix match does not. + var exact = document.getElementById(divId); + if (exact) { + return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; + } + + var idElements = document.querySelectorAll('[id]'); + var prefixMatches = []; + for (var i = 0; i < idElements.length; i++) { + var candidate = idElements[i]; + if (candidate.id.startsWith(divId) && !candidate.id.endsWith('-container')) { + prefixMatches.push(candidate); + } + } + // A unique prefix match may be a lazy slot that has not been sized yet, + // but it must still be visible through its ancestor containers. + if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0])) { + return { + element: prefixMatches[0], + prefixMatchCount: 1, + activeMatchCount: 1, + }; + } + + var visibleMatches = prefixMatches.filter(isElementVisible); + if (visibleMatches.length === 1) { + return { + element: visibleMatches[0], + prefixMatchCount: prefixMatches.length, + activeMatchCount: 1, + }; + } + + var activeMatches = visibleMatches.filter(slotElementHasLayout); + return { + element: activeMatches.length === 1 ? activeMatches[0] : null, + prefixMatchCount: prefixMatches.length, + activeMatchCount: activeMatches.length, + }; + } + + function runHandoffInternal(callback) { + var wasInternal = ts.gptSlotHandoffInternal; + ts.gptSlotHandoffInternal = true; + try { + return callback(); + } finally { + ts.gptSlotHandoffInternal = wasInternal; + } + } + + // TS cannot wait an arbitrary amount of time for a framework to define a + // slot: publishers that never define one would render blank. Instead, TS + // defines its fallback on the actual inner div and aliases only a later + // publisher defineSlot() for that exact div, or a hydration-renamed replacement + // after the original div is gone, to the same GPT slot. + function installSlotHandoff() { + window.googletag.cmd.push(function () { + var tag = window.googletag; + var pubads = tag.pubads && tag.pubads(); + if (!tag.defineSlot || !tag.display || !pubads) return; + + if (!tag.defineSlot.__tsSlotHandoffPatched) { + var originalDefineSlot = tag.defineSlot.bind(tag); + var patchedDefineSlot = function (adUnitPath, formats, elementId) { + if (!ts.gptSlotHandoffInternal && typeof elementId === 'string') { + var handoff = matchingHandoff(pubads, adUnitPath, formats, elementId); + if (handoff) { + var existingSlot = findSlotByElementId(pubads, handoff.slotElementId); + if (existingSlot) { + ts.gptSlotHandoffs[elementId] = handoff; + handoff.publisherClaimed = true; + // The supported publisher lifecycle is defineSlot → addService → display. + // Intentionally wait for that display instead of applying a time heuristic. + handoff.suppressPublisherDisplay = true; + handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; + ts.prevGptSlots = (ts.prevGptSlots || []).filter(function (ownedSlot) { + return ownedSlot !== existingSlot; + }); + if (handoff.gamUnitPath !== adUnitPath || !handoffFormatsMatch(handoff, formats)) { + if (ts.log && ts.log.warn) { + ts.log.warn( + 'GPT slot handoff: publisher definition differs from TS configuration', + elementId + ); + } + } + return existingSlot; + } + } + } + return elementId === undefined + ? originalDefineSlot(adUnitPath, formats) + : originalDefineSlot(adUnitPath, formats, elementId); + }; + patchedDefineSlot.__tsSlotHandoffPatched = true; + tag.defineSlot = patchedDefineSlot; + } + + if (!tag.display.__tsSlotHandoffPatched) { + var originalDisplay = tag.display.bind(tag); + var patchedDisplay = function (target) { + var elementId = displayTargetElementId(target); + var handoff = elementId && ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if (!ts.gptSlotHandoffInternal && handoff && handoff.suppressPublisherDisplay) { + handoff.suppressPublisherDisplay = false; + return; + } + originalDisplay(target); + }; + patchedDisplay.__tsSlotHandoffPatched = true; + tag.display = patchedDisplay; + } + + if (!pubads.refresh.__tsSlotHandoffPatched) { + var originalRefresh = pubads.refresh.bind(pubads); + var callRefresh = function (slots, options) { + if (options === undefined) { + originalRefresh(slots); + } else { + originalRefresh(slots, options); + } + }; + var patchedRefresh = function (requestedSlots, options) { + if (ts.gptSlotHandoffInternal) { + callRefresh(requestedSlots, options); + return; + } + var slots = requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); + if (!slots) { + callRefresh(requestedSlots, options); + return; + } + var suppressed = false; + var remainingSlots = slots.filter(function (slot) { + var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; + if (!handoff || !handoff.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + }); + if (!suppressed) { + callRefresh(requestedSlots, options); + } else if (remainingSlots.length > 0) { + callRefresh(remainingSlots, options); + } + }; + patchedRefresh.__tsSlotHandoffPatched = true; + pubads.refresh = patchedRefresh; + } + }); + } + + installSlotHandoff(); + + function bootstrapTargeting(slot, bid) { + var targeting = Object.assign({}, slot.targeting || {}); + ['hb_pb', 'hb_bidder', 'hb_adid', 'hb_cache_host', 'hb_cache_path'].forEach(function (key) { + if (bid[key]) targeting[key] = String(bid[key]); + }); + targeting.ts_initial = '1'; + return targeting; + } + + function scheduleFirstImpressionFallback(slot, bid, element, generation) { + var state = firstImpressionState(Date.now()); + if (state.fallbackSlots[element.id]) return; + state.fallbackSlots[element.id] = element; + + var retry = function () { + if ( + (ts.navGeneration || 0) !== generation || + !element.isConnected || + document.getElementById(element.id) !== element + ) { + return; + } + var claim = firstImpressionClaim(element); + if (claim) { + if ( + claim.owner !== 'publisher' || + claim.phase === 'requested' || + claim.phase === 'rendered' + ) { + return; + } + var delay = Math.max(0, claim.expiresAt - Date.now()); + if (delay > 0) { + window.setTimeout(retry, delay + 1); + return; + } + } + + tag.cmd.push(function () { + if ( + (ts.navGeneration || 0) !== generation || + !element.isConnected || + document.getElementById(element.id) !== element + ) { + return; + } + var fallbackClaim = claimFirstImpressionForTrustedServer(element); + if (!fallbackClaim) return; + var pubads = window.googletag.pubads(); + var existingSlots = pubads.getSlots ? pubads.getSlots() : []; + var gptSlot = + existingSlots.find(function (candidate) { + return candidate.getSlotElementId() === element.id; + }) || null; + var tsOwned = false; + if (!gptSlot) { + gptSlot = runHandoffInternal(function () { + return window.googletag.defineSlot(slot.gam_unit_path, slot.formats, element.id); + }); + if (!gptSlot) { + releaseTrustedServerFirstImpressionClaim(element, fallbackClaim); + return; + } + gptSlot.addService(pubads); + tsOwned = true; + ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; + ts.gptSlotHandoffs[element.id] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: element.id, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + } + + var targeting = bootstrapTargeting(slot, bid); + Object.entries(targeting).forEach(function (entry) { + gptSlot.setTargeting(entry[0], entry[1]); + }); + fallbackClaim.targeting = targeting; + var slotElementId = gptSlot.getSlotElementId() || element.id; + ts.divToSlotId = ts.divToSlotId || {}; + ts.divToSlotId[element.id] = slot.id; + ts.divToSlotId[slotElementId] = slot.id; + ts.prevSlotTargetingKeys = ts.prevSlotTargetingKeys || {}; + var targetingKeys = Object.keys(slot.targeting || {}); + ts.prevSlotTargetingKeys[element.id] = targetingKeys; + ts.prevSlotTargetingKeys[slotElementId] = targetingKeys; + if (tsOwned) { + ts.prevGptSlots = ts.prevGptSlots || []; + ts.prevGptSlots.push(gptSlot); + } + recordTraceOpportunity(gptSlot, slot, bid, true); + if (!ts.servicesEnabled) { + pubads.enableSingleRequest(); + window.googletag.enableServices(); + ts.servicesEnabled = true; + } + if (tsOwned) { + runHandoffInternal(function () { + window.googletag.display(slotElementId); + }); + } + syncInitialLoadDisabled(window.googletag); + if (!tsOwned || ts.gptInitialLoadDisabled) { + ts.adInitRefreshInProgress = true; + try { + runHandoffInternal(function () { + pubads.refresh([gptSlot]); + }); + } finally { + ts.adInitRefreshInProgress = false; + } + } + }); + }; + + retry(); + } + + ts.adInit = function () { + var slots = (ts.adSlots || []).filter(function (slot) { + return slot.initialization !== 'publisher_registered'; + }); + var bids = ts.bids || {}; + var divToSlotId = Object.create(null); + var nextSlotTargetingKeys = Object.create(null); + // Generation this invocation belongs to. The slot work below is queued on + // googletag.cmd, which drains only when GPT loads; recheck first inside + // the queued callback so a navigation committed in the gap cancels the + // stale mutation — mirrors the bundle's adInit. + var generation = ts.navGeneration || 0; + var warnedResolutionFailures = Object.create(null); + + googletag.cmd.push(function () { + if ((ts.navGeneration || 0) !== generation) return; + // Slots TS defined itself — tracked for SPA destroy. Publisher-owned + // slots are reused but never destroyed by TS on navigation. + var newSlots = []; + // Publisher-owned slots TS reused — refreshed to pick up server-side + // targeting. The publisher already display()ed these. + var slotsToRefresh = []; + // Element IDs of slots TS defined itself. GPT requires display() to + // register/render a freshly-defined slot; refresh() alone no-ops for a + // slot that was never displayed, so these are display()ed instead. + var slotsToDisplay = []; + slots.forEach(function (slot) { + // Resolve actual div ID: exact match first, then the visibility and + // geometry tiers for prefix matches. Responsive publishers may emit + // several mutually exclusive siblings for one stable prefix, so + // document order is not sufficient. + var resolution = resolveSlotElementByDivId(slot.div_id); + var el = resolution.element; + if (!el) { + if (!warnedResolutionFailures[slot.div_id]) { + if (resolution.prefixMatchCount > 1) { + warnedResolutionFailures[slot.div_id] = true; + if (ts.log && typeof ts.log.warn === 'function') { + ts.log.warn('GPT slot prefix did not resolve to one active element', { + divId: slot.div_id, + prefixMatchCount: resolution.prefixMatchCount, + activeMatchCount: resolution.activeMatchCount, + }); + } + } else if (resolution.prefixMatchCount === 1 && resolution.activeMatchCount === 0) { + // The common breakpoint-hidden config: the prefix matched one + // element but it is hidden, so the slot is skipped. Logged so a + // blank placement is diagnosable without stepping the resolver. + warnedResolutionFailures[slot.div_id] = true; + if (ts.log && typeof ts.log.debug === 'function') { + ts.log.debug('GPT slot prefix matched only a hidden element; skipping slot', { + divId: slot.div_id, + }); + } + } + } + return; + } + var actualDivId = el.id; + var b = bids[slot.id] || {}; + var tsClaim = claimFirstImpressionForTrustedServer(el); + if (!tsClaim) { + var currentClaim = firstImpressionClaim(el); + if (currentClaim && currentClaim.owner === 'publisher') { + scheduleFirstImpressionFallback(slot, b, el, generation); + } + return; + } + + var existingSlots = googletag.pubads().getSlots(); + var s = + existingSlots.find(function (gs) { + return gs.getSlotElementId() === actualDivId; + }) || null; + var tsOwned = false; + if (!s) { + // Define TS's fallback on the publisher's actual div. The scoped + // handoff wrapper returns this slot if the publisher defines it later. + s = runHandoffInternal(function () { + return googletag.defineSlot(slot.gam_unit_path, slot.formats, actualDivId); + }); + if (!s) { + releaseTrustedServerFirstImpressionClaim(el, tsClaim); + return; + } + s.addService(googletag.pubads()); + tsOwned = true; + ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; + ts.gptSlotHandoffs[actualDivId] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: actualDivId, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + } + + var targeting = bootstrapTargeting(slot, b); + Object.entries(targeting).forEach(function (entry) { + s.setTargeting(entry[0], entry[1]); + }); + tsClaim.targeting = targeting; + // Map the resolved inner div to the slot ID. This bootstrap fires no + // beacons and registers no slotRenderEnded listener; the map is consumed + // by the bundle's render bridge (index.ts) once it loads. + divToSlotId[actualDivId] = slot.id; + var slotElementId = s.getSlotElementId(); + recordTraceOpportunity(s, slot, b, false); + var targetingKeys = Object.keys(slot.targeting || {}); + nextSlotTargetingKeys[actualDivId] = targetingKeys; + if (slotElementId && slotElementId !== actualDivId) { + divToSlotId[slotElementId] = slot.id; + nextSlotTargetingKeys[slotElementId] = targetingKeys; + } + if (tsOwned) { + newSlots.push(s); + var displayId = s.getSlotElementId() || actualDivId; + slotsToDisplay.push(displayId); + } else { + slotsToRefresh.push(s); + } + }); + ts.prevGptSlots = newSlots; + retainPublisherSlotMappings(ts, divToSlotId, nextSlotTargetingKeys); + ts.divToSlotId = divToSlotId; + ts.prevSlotTargetingKeys = nextSlotTargetingKeys; + var hasRenderableWork = slotsToDisplay.length > 0 || slotsToRefresh.length > 0; + if (!ts.servicesEnabled && hasRenderableWork) { + googletag.pubads().enableSingleRequest(); + googletag.enableServices(); + ts.servicesEnabled = true; + } + // Register and render TS-defined slots. GPT requires display() for a + // freshly-defined slot; without it the slot no-ops and misses its + // impression. Runs after enableServices(); on SPA navigation services are + // already enabled, so this runs unconditionally for new slots. + slotsToDisplay.forEach(function (divId) { + runHandoffInternal(function () { + googletag.display(divId); + }); + }); + syncInitialLoadDisabled(window.googletag); + + // Reused publisher-owned slots always need a refresh to pick up the + // server-side targeting. TS-defined slots are fetched by display() above + // unless the publisher disabled initial load, in which case display() only + // registers them and refresh() must request the ad — otherwise they render + // blank. Only add them in that case to avoid double-requesting. + var slotsNeedingRefresh = ts.gptInitialLoadDisabled + ? slotsToRefresh.concat(newSlots) + : slotsToRefresh; + + if (slotsNeedingRefresh.length > 0) { + // One-shot bypass: this internal refresh delivers the just-applied + // server-side targeting to GAM. If slim-Prebid has already wrapped + // refresh(), it must pass this call straight through — not clear the + // targeting and run a duplicate client-side auction. Mirrors the + // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts. + ts.adInitRefreshInProgress = true; + try { + runHandoffInternal(function () { + googletag.pubads().refresh(slotsNeedingRefresh); + }); + } finally { + ts.adInitRefreshInProgress = false; + } + } + }); + }; +})(); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 6e78c5c26a..05a5b471b1 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -19,6 +19,7 @@ import type { } from '../../core/types'; import { + clearPreviousNavigationTargeting, installPublisherSlotRegistration, invalidatePublisherSlotRegistrations, observePublisherSlotRender, @@ -631,64 +632,6 @@ function applyTrustedServerTargeting( return Object.keys(slot.targeting ?? {}); } -function clearPreviousTargetingSnapshot( - g: Partial, - snapshot: ReadonlyMap -): boolean { - const pubads = g.pubads?.(); - if (!pubads) return false; - const liveSlots = pubads.getSlots?.() ?? []; - for (const [slot, keys] of snapshot) { - if (!liveSlots.includes(slot)) continue; - clearTargetingKeys(slot, keys); - } - return true; -} - -function clearPreviousNavigationTargeting(ts: TsjsApi, g: Partial): void { - const previousKeys: Record = Object.assign( - Object.create(null), - ts.prevSlotTargetingKeys - ); - const registeredRecords = Object.values(ts.publisherSlotRegistrationState?.records ?? {}).filter( - (record) => record.consumed && record.targetingKeys - ); - for (const record of registeredRecords) previousKeys[record.elementId] = record.targetingKeys!; - const touchedElementIds = new Set([ - ...Object.keys(previousKeys), - ...Object.keys(ts.divToSlotId ?? {}), - ]); - - // Capture exact old native identities before retiring records or advancing navigation. - const snapshot = new Map(); - const registeredElementIds = new Set(registeredRecords.map((record) => record.elementId)); - for (const record of registeredRecords) { - if (record.gptSlot) { - snapshot.set(record.gptSlot as GoogleTagSlot, [ - ...TS_BASE_TARGETING_KEYS, - ...record.targetingKeys!, - ]); - } - } - for (const slot of [ - ...(g.pubads?.().getSlots?.() ?? []), - ...((ts.prevGptSlots ?? []) as GoogleTagSlot[]), - ]) { - const elementId = slot.getSlotElementId(); - if (touchedElementIds.has(elementId) && !registeredElementIds.has(elementId)) { - snapshot.set(slot, [...TS_BASE_TARGETING_KEYS, ...(previousKeys[elementId] ?? [])]); - } - } - if (snapshot.size > 0 && !clearPreviousTargetingSnapshot(g, snapshot)) { - g.cmd?.push(() => { - clearPreviousTargetingSnapshot(g, snapshot); - }); - } - - ts.prevSlotTargetingKeys = Object.create(null) as Record; - ts.divToSlotId = Object.create(null) as Record; -} - function schedulePublisherFirstImpressionFallback( ts: TsjsApi, g: Partial, @@ -805,7 +748,11 @@ function schedulePublisherFirstImpressionFallback( export function installTsAdInit(): void { const ts = (window.tsjs ??= {} as TsjsApi); installInitialLoadDetector(ts); - installPublisherSlotRegistration(ts); + installPublisherSlotRegistration( + ts, + (element) => claimFirstImpressionForTrustedServer(ts, element), + getActiveTraceGptBridge + ); installScheduleInitialAdInit(ts); const g = (window as GptWindow).googletag; @@ -1270,18 +1217,12 @@ export function installSpaAuctionHook(): void { const pendingGeneration = ts.gptNavigationBridge?.pendingGeneration; const catchingUp = pendingGeneration !== undefined && pendingGeneration === ts.navGeneration; let inflight: AbortController | null = null; - // Keep the captured path/generation for a failed startup catch-up. Retrying - // that still-unapplied navigation must preserve its pending publisher records. - const catchUpPath = catchingUp ? location.pathname : undefined; - // A provisional navigation is not applied merely because its URL is current. - // The null state also permits a later same-path retry after catch-up fails. - let currentPath: string | null = catchingUp ? null : location.pathname; - // Last path whose slots/bids were actually applied — the initial SSR page - // counts. A failed navigation rolls `currentPath` back to this rather than to - // the immediately-previous committed value: on rapid A→B where A was aborted - // mid-flight and B then fails, rolling back to A (never loaded) would strand - // it behind the no-op guard, so we roll back to the last applied route instead. - let lastAppliedPath: string | null = catchingUp ? null : location.pathname; + // Navigation identity follows the observed URL, not whether its payload loaded. + // A failed request leaves its generation and pending registrations current. + let currentPath = location.pathname; + // The initial SSR page counts as applied unless a provisional navigation needs + // catch-up. Same-path history calls retry only an unapplied, idle navigation. + let payloadApplied = !catchingUp; // Endpoint this session requests. Starts canonical; if the deployment does // not serve page-bids there, one navigation retries on the deprecated alias // and the session stays on it rather than re-probing every navigation. @@ -1307,18 +1248,14 @@ export function installSpaAuctionHook(): void { } async function onNavigate(path: string): Promise { - if (path === currentPath) return; - currentPath = path; - if ( - catchingUp && - path === catchUpPath && - pendingGeneration === ts.navGeneration && - lastAppliedPath === null && - !ts.publisherSlotRegistrationState?.payload - ) { - await fetchAndApplyPageBids(path, pendingGeneration); + if (path === currentPath) { + if (!payloadApplied && !inflight) { + await fetchAndApplyPageBids(path, ts.navGeneration ?? 0); + } return; } + currentPath = path; + payloadApplied = false; const g = (window as GptWindow).googletag; if (g) clearPreviousNavigationTargeting(ts, g); ts.navGeneration = (ts.navGeneration ?? 0) + 1; @@ -1339,15 +1276,7 @@ export function installSpaAuctionHook(): void { try { const data = await requestPageBids(path, controller.signal); - if (!data) { - // A transient page-bids failure must not strand this route: roll the - // committed path back so a later navigation here retries instead of - // being skipped by the no-op guard at the top. Only roll back when no - // newer navigation has already advanced currentPath. - if (inflight === controller && ts.navGeneration === generation) - currentPath = lastAppliedPath; - return; - } + if (!data) return; if (inflight !== controller || ts.navGeneration !== generation) return; // Registered delivery and its renderer need the accepted current payload now. // Automatic-only routes retain the existing delayed public assignment below. @@ -1369,9 +1298,8 @@ export function installSpaAuctionHook(): void { if (inflight !== controller || ts.navGeneration !== generation) return; ts.adSlots = data.slots; ts.bids = data.bids; - // This route is now the committed, loaded state — a later failed - // navigation rolls back here, and a return trip no-ops correctly. - lastAppliedPath = path; + // Successful same-path history calls must not request another impression. + payloadApplied = true; // An empty page-bids response (template switch, auction, or consent gate) // carries no TS slots. Only run adInit() when there are slots to apply or // prior TS state to sweep — otherwise a gated navigation must not enter @@ -1385,8 +1313,9 @@ export function installSpaAuctionHook(): void { } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; - if (inflight === controller && ts.navGeneration === generation) currentPath = lastAppliedPath; log.warn('SPA auction hook: fetch failed', err); + } finally { + if (inflight === controller) inflight = null; } } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts b/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts index 9ca530fb57..0acd5cd508 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts @@ -1,5 +1,4 @@ -import { claimFirstImpressionForTrustedServer } from '../../core/first_impression'; -import { getActiveTraceGptBridge } from '../../trace/runtime'; +import type { TraceGptBridge } from '../../trace/gpt'; import type { AuctionBidData, AuctionSlot, @@ -25,12 +24,80 @@ interface RegistrationTag { display?(id: string): void; } +interface NavigationTargetingSlot { + getSlotElementId(): string; + clearTargeting?(key?: string): unknown; +} + +interface NavigationTargetingTag { + cmd?: RegistrationTag['cmd']; + pubads?(): { getSlots?(): NavigationTargetingSlot[] }; +} + const CANDIDATE_KEYS = ['hb_pb', 'hb_bidder', 'hb_adid', 'hb_cache_host', 'hb_cache_path'] as const; +const BASE_TARGETING_KEYS = [...CANDIDATE_KEYS, 'ts_initial'] as const; function registrationTag(): RegistrationTag | undefined { return (window as unknown as { googletag?: RegistrationTag }).googletag; } +function clearPreviousTargetingSnapshot( + g: NavigationTargetingTag, + snapshot: ReadonlyMap +): boolean { + const pubads = g.pubads?.(); + if (!pubads) return false; + const liveSlots = pubads.getSlots?.() ?? []; + for (const [slot, keys] of snapshot) { + if (!liveSlots.includes(slot)) continue; + if (typeof slot.clearTargeting !== 'function') continue; + for (const key of new Set(keys)) slot.clearTargeting(key); + } + return true; +} + +/** Clear owned targeting on exact old native identities before advancing navigation. */ +export function clearPreviousNavigationTargeting(ts: TsjsApi, g: NavigationTargetingTag): void { + const previousKeys: Record = Object.assign( + Object.create(null), + ts.prevSlotTargetingKeys + ); + const registeredRecords = Object.values(ts.publisherSlotRegistrationState?.records ?? {}).filter( + (record) => record.consumed && record.targetingKeys + ); + for (const record of registeredRecords) previousKeys[record.elementId] = record.targetingKeys!; + const touchedElementIds = new Set([ + ...Object.keys(previousKeys), + ...Object.keys(ts.divToSlotId ?? {}), + ]); + + // Capture exact old native identities before retiring records or advancing navigation. + const snapshot = new Map(); + const registeredElementIds = new Set(registeredRecords.map((record) => record.elementId)); + for (const record of registeredRecords) { + if (record.gptSlot) { + snapshot.set(record.gptSlot, [...BASE_TARGETING_KEYS, ...record.targetingKeys!]); + } + } + for (const slot of [ + ...(g.pubads?.().getSlots?.() ?? []), + ...((ts.prevGptSlots ?? []) as NavigationTargetingSlot[]), + ]) { + const elementId = slot.getSlotElementId(); + if (touchedElementIds.has(elementId) && !registeredElementIds.has(elementId)) { + snapshot.set(slot, [...BASE_TARGETING_KEYS, ...(previousKeys[elementId] ?? [])]); + } + } + if (snapshot.size > 0 && !clearPreviousTargetingSnapshot(g, snapshot)) { + g.cmd?.push(() => { + clearPreviousTargetingSnapshot(g, snapshot); + }); + } + + ts.prevSlotTargetingKeys = Object.create(null) as Record; + ts.divToSlotId = Object.create(null) as Record; +} + /** Retire old records before accepting another navigation's registrations. */ export function invalidatePublisherSlotRegistrations(ts: TsjsApi): void { const previous = ts.publisherSlotRegistrationState; @@ -207,8 +274,8 @@ function registrationHandle( /** Install protocol v1 without replacing browser-local records or committed requests. */ export function installPublisherSlotRegistration( ts: TsjsApi, - claim: (element: HTMLElement) => FirstImpressionSlotClaim | undefined = (element) => - claimFirstImpressionForTrustedServer(ts, element) + claim: (element: HTMLElement) => FirstImpressionSlotClaim | undefined, + getTraceBridge?: () => TraceGptBridge | undefined ): void { registrationState(ts); ts.publisherSlotRegistrationVersion = 1; @@ -369,7 +436,7 @@ export function installPublisherSlotRegistration( ? 'renderer_unavailable' : 'incompatible_dimensions'; const previousKeys = ts.prevSlotTargetingKeys?.[record.elementId] ?? []; - for (const key of new Set([...CANDIDATE_KEYS, 'ts_initial', ...previousKeys])) + for (const key of new Set([...BASE_TARGETING_KEYS, ...previousKeys])) native.clearTargeting(key); const targeting: Record = { ...slot.targeting, ts_initial: '1' }; // Configured hb_* values are also candidate keys, never a back door around eligibility. @@ -391,7 +458,7 @@ export function installPublisherSlotRegistration( retainPublisherSlotMappings(ts, ts.divToSlotId, ts.prevSlotTargetingKeys); try { const auctionId = candidate?.hb_auction_id; - const trace = getActiveTraceGptBridge()?.opportunity(slot, auctionId) ?? { auctionId }; + const trace = getTraceBridge?.()?.opportunity(slot, auctionId) ?? { auctionId }; ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( native, slot.id, diff --git a/crates/trusted-server-js/lib/test/build-gpt-bootstrap.test.mjs b/crates/trusted-server-js/lib/test/build-gpt-bootstrap.test.mjs new file mode 100644 index 0000000000..89170aa3be --- /dev/null +++ b/crates/trusted-server-js/lib/test/build-gpt-bootstrap.test.mjs @@ -0,0 +1,65 @@ +// @vitest-environment node + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Script } from 'node:vm'; + +import { beforeAll, describe, expect, it } from 'vitest'; + +import { checkGptBootstrap, renderGptBootstrap } from '../build-gpt-bootstrap.mjs'; + +const bootstrapPath = new URL( + '../../../trusted-server-core/src/integrations/gpt_bootstrap.js', + import.meta.url +); +let generated; + +describe('generated standalone GPT bootstrap', () => { + beforeAll(async () => { + generated = await renderGptBootstrap(); + }); + + it('matches the checked-in artifact that Rust embeds and the shared contracts execute', async () => { + expect(generated).toBe(await fs.readFile(bootstrapPath, 'utf8')); + await expect(checkGptBootstrap()).resolves.toBeUndefined(); + expect(generated).toContain('npm run build:gpt-bootstrap -- --write'); + }); + + it('rejects stale output without overwriting it', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tsjs-gpt-bootstrap-stale-')); + try { + const artifact = path.join(directory, 'gpt_bootstrap.js'); + await fs.writeFile(artifact, '// stale output\n'); + await expect(checkGptBootstrap(artifact)).rejects.toThrow( + 'npm run build:gpt-bootstrap -- --write' + ); + expect(await fs.readFile(artifact, 'utf8')).toBe('// stale output\n'); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } + }); + + it('is a classic script without a new global and preserves the preinstalled runtime guard', () => { + const adInit = () => {}; + const scope = { window: { tsjs: { adInit } } }; + new Script(generated).runInNewContext(scope); + expect(Object.keys(scope)).toEqual(['window']); + expect(scope.window).toEqual({ tsjs: { adInit } }); + }); + + it('escapes closing inline script tags while preserving string values', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tsjs-gpt-bootstrap-')); + try { + const entry = path.join(directory, 'entry.js'); + await fs.writeFile(entry, "globalThis.exampleText = '