diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 8500af3508..ed24308430 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -26,6 +26,15 @@ 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: @@ -42,15 +51,44 @@ jobs: 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: Export production HTML for CWV contracts + env: + TSJS_PREBUILT_DIR: ${{ env.ARTIFACTS_DIR }}/publisher-registration-dist + run: | + HOST_TARGET="$(rustc -vV | awk '/^host:/ {print $2}')" + cargo run -p trusted-server-integration-tests --example cwv_html --target "$HOST_TARGET" -- "$ARTIFACTS_DIR/cwv/unreserved" unreserved + cargo run -p trusted-server-integration-tests --example cwv_html --target "$HOST_TARGET" -- "$ARTIFACTS_DIR/cwv/reserved" reserved + - 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 @@ -297,6 +335,20 @@ jobs: npm ci npx playwright install --with-deps chromium + - name: Test CWV metrics and production HTML contracts + working-directory: crates/trusted-server-integration-tests/browser + run: | + npm run test:cwv:unit + npm run test:cwv -- --loading "$ARTIFACTS_DIR/cwv/unreserved" --combined "$ARTIFACTS_DIR/cwv/reserved" --correctness-only --out "$RUNNER_TEMP/cwv-evidence" + + - name: Upload CWV evidence + uses: actions/upload-artifact@v4 + if: always() + with: + name: cwv-evidence + path: ${{ runner.temp }}/cwv-evidence/ + retention-days: 7 + - name: Test initial render fixture escaping working-directory: crates/trusted-server-integration-tests/browser run: node --test initial-render/pages.test.cjs @@ -368,3 +420,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: | + npm --prefix crates/trusted-server-integration-tests/browser run test:publisher-registration:fixture + 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/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 49d2ed83ca..a356cdbad8 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -7,6 +7,9 @@ use trusted_server_core::streaming_processor::StreamProcessor as _; fn make_config() -> HtmlProcessorConfig { HtmlProcessorConfig { + enforced_csp: false, + slot_reservations: Vec::new(), + request_path: None, csp_nonce_observed: None, origin_host: "origin.bench.example.com".to_string(), request_host: "proxy.bench.example.com".to_string(), diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 8a20c72965..45c0154e06 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -4,7 +4,8 @@ //! it appears on (via glob patterns), what ad formats it supports, and how it //! maps to provider-specific identifiers such as GAM unit paths and APS slot IDs. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::fmt::Write as _; use serde::{Deserialize, Serialize}; @@ -622,6 +623,143 @@ 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) + } +} + +/// Explicit minimum space for a stable publisher-owned element. +/// +/// Heights are CSS pixels, independent of the auction formats and selected creative. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CreativeOpportunityReservation { + /// Exact ID of a stable element present in publisher HTML before hydration. + pub element_id: String, + /// Minimum height below the first viewport breakpoint, in CSS pixels. + pub min_height: u32, + /// Mobile-first overrides, ordered by strictly increasing positive viewport width. + #[serde(default)] + pub breakpoints: Vec, +} + +/// A viewport-width override for an explicit slot-space reservation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CreativeOpportunityReservationBreakpoint { + /// Inclusive minimum viewport width, in CSS pixels. Zero is redundant and rejected. + pub min_width: u32, + /// Minimum element height at this viewport width, in CSS pixels. + pub min_height: u32, +} + +impl CreativeOpportunityReservation { + fn validate_runtime(&self, slot_id: &str) -> Result<(), String> { + if self.element_id.trim().is_empty() || self.element_id.contains('\0') { + return Err(format!( + "slot `{slot_id}` reservation element_id must be nonempty and contain no NUL" + )); + } + let mut previous_width = 0; + for breakpoint in &self.breakpoints { + if breakpoint.min_width <= previous_width { + return Err(format!( + "slot `{slot_id}` reservation breakpoints must have strictly increasing positive min_width" + )); + } + previous_width = breakpoint.min_width; + } + Ok(()) + } +} + +/// Path-matched layout rule prepared from a configured slot. +/// +/// The HTML processor retains only reservation policy and compiled page patterns, +/// never targeting, provider identifiers, or request-specific auction state. +#[derive(Debug, Clone)] +pub struct SlotReservationRule { + patterns: Vec, + reservation: CreativeOpportunityReservation, +} + +impl SlotReservationRule { + pub(crate) fn from_slot(slot: &CreativeOpportunitySlot) -> Option { + let reservation = slot.reservation.clone()?; + let patterns = if slot.compiled_patterns.is_empty() { + slot.page_patterns + .iter() + .filter_map(|pattern| compile_page_pattern(pattern).ok()) + .collect() + } else { + slot.compiled_patterns.clone() + }; + Some(Self { + patterns, + reservation, + }) + } +} + +/// Render visitor-neutral reservation CSS for one initial-document pathname. +/// +/// The first path-matching slot in configuration order owns the complete policy +/// for its exact element ID. Later same-ID policies emit no rules. +/// CSS strings hex-escape every character other than ASCII alphanumerics, `-` and +/// `_`, including `<`, so configured IDs cannot terminate the HTML style element. +pub(crate) fn render_slot_reservations( + rules: &[SlotReservationRule], + request_path: &str, +) -> Option { + let mut css = String::new(); + let mut seen_elements = HashSet::new(); + for rule in rules { + if !rule + .patterns + .iter() + .any(|pattern| pattern.matches(request_path)) + || !seen_elements.insert(&rule.reservation.element_id) + { + continue; + } + let reservation = &rule.reservation; + let mut selector = String::from("[id=\""); + for character in reservation.element_id.chars() { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + selector.push(character); + } else { + write!(selector, "\\{:x} ", u32::from(character)) + .expect("should write escaped CSS into a string"); + } + } + selector.push_str("\"]"); + write!(css, "{selector}{{min-height:{}px}}", reservation.min_height) + .expect("should write reservation CSS into a string"); + for breakpoint in &reservation.breakpoints { + write!( + css, + "@media(min-width:{}px){{{selector}{{min-height:{}px}}}}", + breakpoint.min_width, breakpoint.min_height + ) + .expect("should write reservation breakpoint CSS into a string"); + } + } + (!css.is_empty()).then(|| format!("")) +} + /// A single ad placement opportunity on the publisher's site. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -636,10 +774,19 @@ pub struct CreativeOpportunitySlot { /// /// Defaults to [`id`](Self::id) when absent. pub div_id: Option, + /// Optional initial-document space reservation for a stable publisher element. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reservation: Option, /// Glob patterns for page paths this slot should appear on. 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. @@ -685,6 +832,9 @@ impl CreativeOpportunitySlot { /// or semantically unusable at runtime. pub fn validate_runtime(&self) -> Result<(), String> { validate_slot_id(&self.id)?; + if let Some(reservation) = &self.reservation { + reservation.validate_runtime(&self.id)?; + } if self.page_patterns.is_empty() { return Err(format!( @@ -1448,17 +1598,195 @@ mod tests { ); } + fn reservation_slot( + id: &str, + patterns: Vec<&str>, + element_id: &str, + min_height: u32, + breakpoints: Vec<(u32, u32)>, + ) -> CreativeOpportunitySlot { + let mut slot = make_slot(id, patterns); + slot.reservation = Some(CreativeOpportunityReservation { + element_id: element_id.to_owned(), + min_height, + breakpoints: breakpoints + .into_iter() + .map( + |(min_width, min_height)| CreativeOpportunityReservationBreakpoint { + min_width, + min_height, + }, + ) + .collect(), + }); + slot.compile_patterns(); + slot + } + + #[test] + fn reservation_omission_preserves_slot_serialization() { + let value = serde_json::to_value(make_slot("header", vec!["/news/*"])) + .expect("should serialize slot without reservation"); + assert!(value.get("reservation").is_none()); + let slot: CreativeOpportunitySlot = + serde_json::from_value(value).expect("should parse legacy slot shape"); + assert!(slot.reservation.is_none()); + } + + #[test] + fn reservation_config_parses_explicit_responsive_policy() { + let source = r#" + id = "header" + page_patterns = ["/news/*"] + formats = [{ width = 300, height = 250 }, { width = 300, height = 600 }] + [reservation] + element_id = "header-ad-space" + min_height = 0 + [[reservation.breakpoints]] + min_width = 1024 + min_height = 600 + "#; + let mut slot: CreativeOpportunitySlot = + toml::from_str(source).expect("should parse explicit responsive reservation"); + slot.compile_patterns(); + slot.validate_runtime() + .expect("should accept zero base height"); + let rule = SlotReservationRule::from_slot(&slot).expect("should prepare reservation rule"); + assert_eq!( + render_slot_reservations(&[rule], "/news/article").as_deref(), + Some( + "" + ), + "should emit only explicit heights, not infer from format order" + ); + } + + #[test] + fn reservation_validation_rejects_unusable_ids_and_breakpoints() { + for element_id in ["", " \t\n", "bad\0id"] { + let slot = reservation_slot("header", vec!["/*"], element_id, 250, vec![]); + assert!( + slot.validate_runtime().is_err(), + "should reject unusable element ID" + ); + } + for breakpoints in [ + vec![(0, 250)], + vec![(768, 250), (768, 600)], + vec![(1024, 600), (768, 250)], + ] { + let slot = reservation_slot("header", vec!["/*"], "space", 0, breakpoints); + assert!( + slot.validate_runtime().is_err(), + "should reject non-increasing widths" + ); + } + let slot = reservation_slot("header", vec!["/*"], "space", 0, vec![(1, 0), (1024, 600)]); + slot.validate_runtime() + .expect("should allow explicit zero heights at positive widths"); + } + + #[test] + fn reservation_schema_rejects_raw_css_and_non_integer_dimensions() { + for value in [ + serde_json::json!({"element_id":"space", "min_height":250, "selector":"*"}), + serde_json::json!({"element_id":"space", "min_height":-1}), + serde_json::json!({"element_id":"space", "min_height":0.5}), + serde_json::json!({"element_id":"space", "min_height":250, "breakpoints":[{"min_width":768,"min_height":600,"css":"display:none"}]}), + ] { + assert!( + serde_json::from_value::(value).is_err(), + "should reject unsupported CSS inputs and invalid pixel types" + ); + } + } + + #[test] + fn reservation_css_escapes_selector_and_style_termination() { + let slot = reservation_slot( + "header", + vec!["/*"], + "1\"\\\né]", + 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, @@ -148,6 +158,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, @@ -159,6 +186,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(), @@ -1936,6 +2286,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(), @@ -1977,6 +2330,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(), @@ -2032,6 +2388,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(), @@ -2065,6 +2424,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(), @@ -2091,6 +2453,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(), @@ -2353,6 +2718,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/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 3292cc5b3a..fe5d3fdee7 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -490,20 +490,33 @@ 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 { + // Direct callers retain the legacy initializer as a compatibility reference. + // Production composition supplies the actual loading policy through the registry. + self.head_inserts_with_provisional_bootstrap(ctx, true) + } + + fn head_inserts_with_provisional_bootstrap( + &self, + _ctx: &IntegrationHtmlContext<'_>, + include_provisional: 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 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)); + } if let Some(ref url) = self.config.slim_prebid_url { // JSON-encode the URL, then escape ` 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, StreamProcessor as _, StreamingPipeline, + }; use crate::test_support::tests::create_test_settings; use http::Method; + use std::io::Cursor; fn test_config() -> GptConfig { GptConfig { @@ -1224,6 +1242,201 @@ mod tests { ); } + #[test] + fn legacy_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 retained file. + // This is legacy reference coverage, not proof of emitted candidate HTML. + } + + #[test] + fn publisher_registration_runtime_preserves_document_injection_order() { + const PUBLISHER_HEAD: &str = ""; + const SLOT_SCRIPT: &str = ""; + const SHARED_SEAM: &str = ""; + 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, chunk_size) in [(false, 1), (true, 1), (false, 8192), (true, 8192)] { + 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, + }, + 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 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"); + let body_close = output + .rfind("") + .expect("should retain the body close"); + + assert!( + 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!( + !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), + "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 response_csp_uses_the_synchronous_runtime_without_a_new_bootstrap_hash() { + const PUBLISHER_HEAD: &str = ""; + 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" + ); + } + } + + // 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/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index ef4893a41b..a1b0b6974b 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -1,990 +1,1089 @@ -// 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; - } - ts.gptInitialLoadDisabled = config.disableInitialLoad === true; + 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); + } 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; + 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 + ); + 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 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: {}, - fallbackSlots: {}, - }; } - var state = ts.firstImpression; - state.slots = state.slots || {}; - state.fallbackSlots = 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; + 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 : []]); } - 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 (snapshot.size > 0 && !clearPreviousTargetingSnapshot(g, snapshot)) { + (_j = g.cmd) == null ? void 0 : _j.push(() => { + clearPreviousTargetingSnapshot(g, snapshot); }); - 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]; + } + 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 : {})) { + 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 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" }; } - }); - return state; + } } - - function firstImpressionClaim(element) { - return firstImpressionState(Date.now()).slots[element.id]; + function finiteSize(size) { + return Array.isArray(size) && size.length === 2 && size.every( + (value) => typeof value === "number" && Number.isInteger(value) && value > 0 + ); } - - 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 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 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" - ) + 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; - 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, - }); + 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; } - } 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; + function installPublisherSlotRegistration(ts, claim, getTraceBridge) { + registrationState(ts); + ts.publisherSlotRegistrationVersion = 1; + ts.registerPublisherSlot = (options) => { + var _a, _b, _c, _d, _e; + const state = registrationState(ts); + const rejected = (status) => ({ + getStatus: () => ({ ...status }), + cancel: () => { + } }); - 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: {}, + 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); }; - 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]; + 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, _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); + 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([...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]); + } + 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 { + 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" }; + } 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); + } + }; } - - 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; - 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: {}, - }); - return; + (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" } }); } - if (claim.phase !== "rendered") claim.phase = phase; - if (claim.owner === "publisher") { - claim.expiresAt = Number.POSITIVE_INFINITY; - } else if (phase === "rendered") { - claim.publisherRegistrationClosed = 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.addEventListener("slotRequested", observe("requested")); - pubads.addEventListener("slotRenderEnded", observe("rendered")); - ts.firstImpressionListenersInstalled = true; + pubads.__tsInitialLoadHooked = true; }); - } - - installFirstImpressionListeners(); - - // 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; - var fire = function () { - if ((ts.navGeneration || 0) !== 0) return; + 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; + } + 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) { + } + }, 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; + }); + schedulePendingRenderDiagnostic(existing); + return existing; + } + 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; + } + 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, + 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; + }); + } + 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); + }; + ["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(); + 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 === undefined ? ts.adSlots : initialSlots, + initialSlots === void 0 ? ts.adSlots : initialSlots, traceAuctionTransport, - "initial_navigation_ssat", + "initial_navigation_ssat" ); } } catch (_) { - // Trace failure cannot interrupt the initial advertising pass. } - 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; + 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(); }; - // 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. + 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]); + } + ); + 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 (_) { + } } - } - - function findSlotByElementId(pubads, elementId) { - var slots = pubads.getSlots ? pubads.getSlots() : []; - return ( - slots.find(function (slot) { + 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) { + }) || 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(); + }) ? [formats] : formats; } - return target && target.id ? target.id : null; - } - - function isElementVisible(element) { - if (typeof element.checkVisibility === "function") { - return element.checkVisibility({ - checkVisibilityCSS: true, - visibilityProperty: true, - }); + function handoffFormatsMatch(handoff, formats) { + return JSON.stringify(handoff.formats) === JSON.stringify(normalizedGptFormats(formats)); } - - 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 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; } - 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); + 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; } - // 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 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 || []; - var bids = ts.bids || {}; - var divToSlotId = {}; - var nextSlotTargetingKeys = {}; - // 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; - 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/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index bb1a875e8f..d3767e688e 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/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 15a6486fd8..588fca71c0 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 with the provisional initializer loading decision. + /// + /// Integrations without a provisional initializer retain their existing snippets. + fn head_inserts_with_provisional_bootstrap( + &self, + ctx: &IntegrationHtmlContext<'_>, + _include_provisional: 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,25 @@ 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 { + // 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(ctx); + let mut next = + injector.head_inserts_with_provisional_bootstrap(ctx, include_provisional); if !next.is_empty() { inserts.append(&mut next); } @@ -2360,6 +2387,71 @@ 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(include_str!("gpt_bootstrap.js")), + 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 a670d31416..1a1aaf7e09 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -567,14 +567,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)) = ( @@ -621,7 +622,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 @@ -639,6 +640,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, @@ -683,6 +686,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, @@ -771,6 +776,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, @@ -1351,6 +1358,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, @@ -1550,7 +1559,9 @@ fn create_html_stream_processor_with_document_state( 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); // A deliverable skipped trace auction needs the request-only body seam even @@ -1764,6 +1775,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, @@ -2236,6 +2251,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(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -3104,6 +3125,8 @@ pub fn stream_publisher_body( })); } 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, @@ -3344,6 +3367,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, @@ -5750,6 +5775,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"); // Map origin URLs in navigation, preload, and CSP headers to the serving host, // matching the body rewrite, on every route. Runs before the template-cache gate // so a stored template replays the rewritten policy headers; the cache key is @@ -6073,6 +6102,8 @@ pub async fn handle_publisher_request( response, body, params: Box::new(OwnedProcessResponseParams { + enforced_csp, + request_path: request_path.clone(), response_status: status, range_associated: false, // The transform writes here; the post-transform gate reads it. Always @@ -6769,6 +6800,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(); @@ -10762,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", @@ -10915,6 +11062,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(), @@ -11957,6 +12107,8 @@ 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, div_id: Some("test-slot".to_string()), @@ -17950,6 +18102,181 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { assert_eq!(values("cross-origin-embedder-policy"), ["require-corp"]); } + #[tokio::test] + 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'", + ), + (Some("content-security-policy"), ""), + (Some("content-security-policy"), "default-src *"), + ( + Some("Content-Security-Policy"), + "script-src 'self' 'sha256-old-policy'", + ), + ] { + 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!( + document.contains("tsjs-unified.min.js?v="), + "should retain cacheable unified loading, admitted={admitted} header={policy_header:?}" + ); + assert!( + !document.contains("id=\"trustedserver-bootstrap\""), + "should not embed an uncacheable early bundle" + ); + if !admitted { + assert!( + !document.contains(".adSlots=JSON.parse"), + "should not use a policy fallback to authorize ad payloads" + ); + } + } + } + } + + #[tokio::test] + async fn unified_loading_preserves_policy_on_shared_template_cold_and_warm_paths() { + for finalizer in [Finalizer::Streaming, Finalizer::Buffered] { + 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"); + 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!(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!( + 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()); @@ -19169,6 +19496,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { pub(super) fn slot() -> CreativeOpportunitySlot { CreativeOpportunitySlot { + reservation: None, + initialization: Default::default(), id: "atf".to_string(), gam_unit_path: Some("/99999/example/home".to_string()), div_id: Some("ad-atf".to_string()), @@ -19211,6 +19540,170 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { 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, + trace_bootstrap: 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(); + 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(); @@ -19889,6 +20382,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { fn article_slot() -> CreativeOpportunitySlot { CreativeOpportunitySlot { + reservation: None, + initialization: Default::default(), id: "article-slot".to_string(), gam_unit_path: None, div_id: None, @@ -22639,6 +23134,77 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { 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(); @@ -23048,6 +23614,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let body = EdgeBody::from(compressed); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23110,6 +23678,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { .expect("should create integration registry"); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23161,6 +23731,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { ) .expect("should create integration registry"); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23290,6 +23862,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23357,6 +23931,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23427,6 +24003,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23497,6 +24075,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23567,6 +24147,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23619,6 +24201,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { fn non_html_stream_params(content_encoding: &str) -> OwnedProcessResponseParams { OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23839,6 +24423,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let services = noop_services(); let state = AdBidsState::default(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23917,6 +24503,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let services = noop_services(); let state = AdBidsState::default(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23997,6 +24585,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -24071,6 +24661,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { .body(EdgeBody::empty()) .expect("should build response"); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -25390,6 +25982,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { .expect("should replay overflow output"); let control_registry = IntegrationRegistry::from_html_stream_processors(Vec::new()); let mut processor = create_html_stream_processor(HtmlStreamProcessorParams { + request_path: "/", + enforced_csp: false, origin_host: "origin.example.com", request_host: "proxy.example.com", request_scheme: "https", @@ -26035,6 +26629,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { dispatched_auction: Option, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -26751,6 +27347,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let ec_context = EcContext::new_for_test(None, crate::consent::types::ConsentContext::default()); OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -26949,6 +27547,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { .map(bytes::Bytes::copy_from_slice) .collect(); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -27033,6 +27633,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { r#""#; let state = AdBidsState::with_script(bids_script); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -27100,6 +27702,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { // 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(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -27221,6 +27825,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let body = EdgeBody::from(html.to_vec()); let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -27291,6 +27897,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { // Small, single-fragment RSC script — placeholder path (not fallback). let html = br#""#; let params = OwnedProcessResponseParams { + enforced_csp: false, + request_path: "/".to_owned(), response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -27384,6 +27992,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { 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()), div_id: Some("div-atf-sidebar".to_string()), @@ -27434,6 +28044,26 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { } } + #[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(); @@ -29165,6 +29795,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { fn article_slot() -> Vec { vec![CreativeOpportunitySlot { + reservation: None, + initialization: Default::default(), id: "atf".to_string(), gam_unit_path: None, div_id: None, @@ -29186,6 +29818,85 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { 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(); @@ -30434,6 +31145,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { fn article_slot() -> Vec { vec![CreativeOpportunitySlot { + reservation: None, + initialization: Default::default(), id: "atf".to_string(), gam_unit_path: None, div_id: None, diff --git a/crates/trusted-server-core/src/publisher/html_finalization.rs b/crates/trusted-server-core/src/publisher/html_finalization.rs index 49ad33a5f8..f5a2435191 100644 --- a/crates/trusted-server-core/src/publisher/html_finalization.rs +++ b/crates/trusted-server-core/src/publisher/html_finalization.rs @@ -97,6 +97,8 @@ impl HtmlEofDriver { ); let mut processor = create_html_stream_processor_with_document_state( HtmlStreamProcessorParams { + request_path: ¶ms.request_path, + enforced_csp: params.enforced_csp, origin_host: ¶ms.origin_host, request_host: ¶ms.request_host, request_scheme: ¶ms.request_scheme, diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index e8275b8144..e8c4e02c60 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -1,26 +1,31 @@ use trusted_server_js::{concatenated_hash, 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. +/// +/// 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 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) -> String { + tsjs_script_tag_with_attributes( + &integrations.js_module_ids_immediate(), + &integrations.tsjs_script_tag_attributes(), + ) } -/// `", @@ -106,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=") @@ -121,6 +150,142 @@ mod tests { ); } + #[test] + 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", + "lockr", + "permutive", + "didomi", + "sourcepoint", + "osano", + "testlight", + "future_integration", + ] { + assert!( + !supports_unified_gpt(&["creative", "gpt", blocked]), + "should retain provisional initialization for {blocked}", + ); + } + } + + #[test] + fn publisher_runtime_preserves_the_existing_synchronous_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), + 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", + ); + } + + #[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({ + articlePresent = false, + runtimeId = "trustedserver-js", +} = {}) { + const listeners = {}; + const context = vm.createContext({ + window: { cwv: {} }, + performance: { now: () => 1 }, + document: { + getElementById: () => (articlePresent ? {} : 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: runtimeId } }); + 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("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) => { + 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, true); + 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/ + : /Full acceptance remains unexecuted/, + ); + } + 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/evidence.cjs b/crates/trusted-server-integration-tests/browser/cwv/evidence.cjs new file mode 100644 index 0000000000..9b4eecb134 --- /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, + "Available candidate unified runtime must install the real renderer", + ); +} + +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..39c792ee90 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/evidence.test.cjs @@ -0,0 +1,235 @@ +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)); + } + } +}); + +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-")); + 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["runtime-deferred"].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 new file mode 100644 index 0000000000..809b8e933e --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/html.cjs @@ -0,0 +1,83 @@ +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) { + return ( + html.match( + /]*\bdata-ts-slot-reservations\b[^>]*>[\s\S]*?<\/style>/gi, + ) ?? [] + ); +} + +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 withoutScripts( + html, + (script) => script.id === "trustedserver-bootstrap", + ); +} + +function withRuntimeDefer(html) { + const dom = new JSDOM(html, { includeNodeLocations: true }); + try { + const script = dom.window.document.querySelector("script#trustedserver-js"); + if (!script || script.hasAttribute("defer")) return html; + const offset = dom.nodeLocation(script).startOffset + " { + const raw = + ""; + const runtime = + ''; + const early = + ''; + assert.equal(withoutEarlyRenderer(raw + early + runtime), raw + runtime); + assert.equal( + withoutEarlyRenderer( + raw + + "" + + 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("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 = + ''; + const html = + "" + + css + + ''; + assert.deepEqual(reservationStyles(html), [css]); + assert.deepEqual(reservationStyles(""), []); +}); + +test("deferred regression control adds only the actual runtime defer attribute", () => { + const prefix = + '\r\n'; + const suffix = + '
Keep bytes
'; + for (const tag of [ + '', + '', + ]) { + assert.equal( + withRuntimeDefer(prefix + tag + suffix), + prefix + tag.slice(0, 7) + " defer" + tag.slice(7) + suffix, + ); + } + 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/metrics.cjs b/crates/trusted-server-integration-tests/browser/cwv/metrics.cjs new file mode 100644 index 0000000000..6cd6fd0647 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/metrics.cjs @@ -0,0 +1,182 @@ +// 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: [], + 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) => { + 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), + withinArticle: !!source.node?.closest?.("#article"), + 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, + 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, + 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..6695f2e724 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/metrics.test.cjs @@ -0,0 +1,84 @@ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { JSDOM } = require("jsdom"); +const { sessionCls, distribution, observePage } = 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, + }); +}); + +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..6ce5d1ab55 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/options.cjs @@ -0,0 +1,118 @@ +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" }, + "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" }, + "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", + ); + 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", + "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`); + } + 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", + ); + 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..8bb386706c --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/options.test.cjs @@ -0,0 +1,144 @@ +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("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/); + 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..3a702e14e3 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/proxy.cjs @@ -0,0 +1,78 @@ +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 Map(origins.map((origin) => [origin, new URL(origin)])); + 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; + } + const destination = allowed.get(target.origin); + if (!destination || 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: destination.host }; + delete headers["proxy-connection"]; + // Connection fields come only from the server-created origin map. The + // request contributes a path, never an upstream hostname or port. + const upstream = http.request( + { + hostname: destination.hostname, + port: destination.port, + path: target.pathname + target.search, + 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..ae0ec6884b --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/proxy.test.cjs @@ -0,0 +1,150 @@ +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 upstreamRequests = []; + const upstream = http.createServer((incoming, response) => { + let body = ""; + incoming.on("data", (chunk) => { + body += chunk; + }); + incoming.on("end", () => { + upstreamRequests.push({ + method: incoming.method, + path: incoming.url, + host: incoming.headers.host, + body, + }); + response.writeHead(200, { + "Cache-Control": "public, max-age=3600", + "Content-Type": "text/plain", + }); + response.end("fixture-response"); + }); + }); + 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, "fixture-response"); + assert.deepEqual(upstreamRequests, [ + { + method: "POST", + path: "/runtime.js?q=1", + host: new URL(origin).host, + body: "fixture-body", + }, + ]); + assert.equal(response.headers["content-type"], "text/plain"); + 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); + const suffixHost = new URL(origin); + suffixHost.hostname = "127.0.0.1.example.com"; + assert.equal((await request(proxyOrigin, suffixHost.href)).status, 403); + const wrongProtocol = new URL(origin); + wrongProtocol.protocol = "https:"; + assert.equal((await request(proxyOrigin, wrongProtocol.href)).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, + 5, + ); + } 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 new file mode 100644 index 0000000000..3b632cb07a --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/cwv/run.cjs @@ -0,0 +1,1293 @@ +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 { 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 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); +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: 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, + profileSamples: {}, + negativeControls: Object.fromEntries( + ["runtime-deferred", "reservation-removed"].map((id) => [ + id, + { + status: + unifiedDiagnostic || options["regressions-only"] + ? "not-requested" + : "pending", + ...(unifiedDiagnostic || options["regressions-only"] + ? { + reason: diagnosticScreen + ? "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; deferred-runtime and reservation-removal controls are not requested. Full acceptance remains unexecuted." + : "--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 { + baselineRevision, + expectedArchitecture, + assertScreenDocument, + installEarlyProbe, + assertEarlyProbe, + screenCases, + assertScreenSamples, + outageCases, + assertOutageSamples, +} = require("./diagnostic.cjs"); +const { + correctnessCases, + assertCorrectnessSamples, + unavailableErrors, + assertCspDocument, + assertUnavailableRuntime, + assertDeferredRegression, +} = require("./contract.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: true, + excluded: + "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, + withRuntimeDefer, + withoutScripts, + rendererAsset, +} = require("./html.cjs"); +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"); +if (baseline) + assert.equal( + baseline.manifest.sourceRevision, + baselineRevision, + "Baseline must remain pinned original-main, not the failing optimized PR snapshot", + ); +const profiles = { + loading, + combined, + ...(baseline ? { baseline, reservation: baseline } : {}), +}; +evidence = { + node: process.version, + platform: process.platform, + architecture: process.arch, + command: process.argv, + startedAt: new Date().toISOString(), + settings: { + ...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, + 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"))), + 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")), + ), + 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.", +}; +saveOutcome(); + +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, `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": + options.cache === "warm" && cacheable + ? "public, max-age=3600" + : "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 && settings.runtimeDelayMs > 0) + await wait(settings.runtimeDelayMs); + 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"] || + diagnosticOutages + ? "correctness" + : "measurement"; +async function sample( + browser, + profile, + name, + viewport, + mode = "slow", + noFill = false, + control = null, +) { + if (control) { + acceptance.negativeControls[control].status = "running"; + saveOutcome(); + } + const input = documentInput(profile, name); + 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 = { + input, + mode, + runtimeAttempts: 0, + unexpected: [], + blockedRenderer: [], + responseHeaders: {}, + responses: [], + phase: options.cache === "warm" ? "warmup" : "measurement", + html: input.html, + }; + if (control === "runtime-deferred") { + active.html = withRuntimeDefer(input.html); + assert.notEqual( + active.html, + input.html, + "Deferred regression control must add defer to the actual synchronous runtime", + ); + } + 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( + (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 the synchronous production runtime without inline fallback", + ); + assert( + !input.html.includes("// Edge-injected GPT auction bootstrap."), + "Enforced CSP must not depend on the provisional raw bootstrap's inline hash", + ); + if (runtimeUnavailable) + evidence.interventions.push({ + index, + profile, + name, + type: "enforced-CSP critical runtime failure; no inline fallback", + expectedErrors, + }); + } + if (mode === "renderer-absent") { + assert( + rendererAsset( + new URL(input.runtimeSrc, "http://publisher.example.com").pathname, + ), + ); + evidence.interventions.push({ + profile, + name, + type: "true renderer absence; sole unified runtime blocked, no inline renderer or publisher-code removal", + htmlSha256: sha256(active.html), + }); + } + if (mode === "absent") { + active.html = withoutScripts( + input.html, + (script) => script.id === "trustedserver-js", + ); + 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; + let warmup = null; + const recovery = []; + const requests = new Map(); + const errors = []; + const network = []; + 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()})(); + (${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 })}); + }`, + }); + page = await context.newPage(); + 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)); + page.on("request", (request) => { + const url = request.url(); + network.push({ url, method: request.method() }); + if ( + !url.startsWith(publisherOrigin + "/") && + !url.startsWith(creativeOrigin + "/") + ) + errors.push("Unexpected external request: " + url); + }); + const navigate = () => + page.goto(publisherOrigin + input.requestPath, { + waitUntil: "load", + timeout: 15000, + }); + if (options.cache === "warm") { + await navigate(); + if (deliveryExpected && !initiallyUnavailable) + 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 (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; + document.head.append(script); + }), + 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( + () => + 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.deepEqual( + errors, + expectedErrors, + "Only declared fixture page errors are allowed", + ); + if (["loading", "combined"].includes(profile)) + assertCspRuntimeApis(raw, !runtimeUnavailable); + if (runtimeUnavailable) { + assertUnavailableRuntime( + raw, + active.responses, + new URL(input.runtimeSrc, publisherOrigin).href, + mode, + ); + assert.equal( + active.runtimeAttempts, + 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 the synchronous runtime 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.deepEqual( + active.blockedRenderer, + [new URL(input.runtimeSrc, publisherOrigin).pathname], + "Actual sole unified renderer must be blocked", + ); + } else if (!runtimeUnavailable) { + 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); + assertEarlyProbe( + raw, + profile, + control === "runtime-deferred" || mode === "late-runtime", + ); + } + assert.equal( + raw.fixture.requests.length, + deliveryExpected ? 1 : 0, + "Exactly one native initial request requires a matched slot and available critical runtime", + ); + if (deliveryExpected) { + 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], + "fixture-server-ad", + "Candidate targeting requires an actual renderer", + ); + if (!noFill) { + const text = await Promise.all( + page + .frames() + .map((frame) => frame.locator(".marker").allTextContents()), + ); + assertVisibleCreative( + text, + raw.firstVisibleEstimateMs, + "Server result", + ); + } + } + 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", + ); + assertRuntimeOrdering( + raw, + new URL(input.runtimeSrc, publisherOrigin).href, + control === "runtime-deferred", + ); + 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", + ); + } + 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) { + assert.equal( + Number.parseFloat(after.minHeight), + expectedHeight, + "Responsive reservation must match viewport", + ); + if (!deliveryExpected) { + assert.equal( + cls, + 0, + "Unavailable runtime or deferred-startup regression must not produce ad-induced layout shift", + ); + } else 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", + ); + } + if (control === "reservation-removed") assertReservationControl(raw, cls); + result = { + index, + control, + runtimeUnavailableBoundary: runtimeUnavailable, + ...(recovery.length ? { recovery } : {}), + ...(diagnosticOutages + ? { + diagnosticOutage: { + policy: input.enforcedCsp + ? "enforced-old-hash-self-csp" + : "normal", + expectedRuntimeAvailable: !runtimeUnavailable, + changedOutageBehaviorApproved: true, + interpretation: + "Approved no-inline-fallback critical-script boundary; limited diagnostic, not full acceptance or recovery proof", + }, + } + : {}), + conditions: settings, + cache: { + mode: options.cache, + hits: cacheHits, + requests: cacheRequests, + warmup, + }, + responses: active.responses, + 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); + 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"), + 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), + conditions: settings, + cache: { mode: options.cache, requests: [...requests.values()], warmup }, + errors, + network, + server: { + responses: active.responses, + 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 { + 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 }; + const desktop = { width: 1280, height: 1000 }; + 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 { 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), + ); + assert( + shiftControls.every((entry) => entry.metrics.cls > 0), + "Unreserved controls must visibly reproduce ad-induced layout shift", + ); + } + 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", + ); + } + } + 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"], + ); + } + const groups = {}; + for (const entry of samples) { + 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( + 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( + diagnosticScreen + ? "Diagnostic screen completed; raw timings retained. Full correctness, negative controls, framework/live gates and combined acceptance remain unexecuted." + : diagnosticOutages + ? "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 { + evidence.finishedAt = new Date().toISOString(); + 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/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/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..2d1517a1b5 100644 --- a/crates/trusted-server-integration-tests/browser/package.json +++ b/crates/trusted-server-integration-tests/browser/package.json @@ -5,7 +5,14 @@ "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/*.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" }, "devDependencies": { "@playwright/test": "^1.49.0", 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.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/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/README.md b/crates/trusted-server-integration-tests/browser/publisher-registration/README.md new file mode 100644 index 0000000000..49512fada7 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/README.md @@ -0,0 +1,117 @@ +# 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. +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, +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..dfc706a3ee --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/gpt-fixture.js @@ -0,0 +1,323 @@ +// 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"; + // 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=" + + 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/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/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/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..3dd8f2fa3b --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs @@ -0,0 +1,224 @@ +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, + 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))); + for (const id of [ + input.id, + '', + ]) { + const html = documentPage({ id }); + 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-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"])), + "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": 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"), + "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/examples/publisher_registration_backends.rs b/crates/trusted-server-integration-tests/examples/publisher_registration_backends.rs new file mode 100644 index 0000000000..bbc9df24a7 --- /dev/null +++ b/crates/trusted-server-integration-tests/examples/publisher_registration_backends.rs @@ -0,0 +1,228 @@ +//! Generate test-only Viceroy mappings using the production backend naming policy. + +use core::error::Error; +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::time::Duration; + +use reqwest::Url; +use serde_json::{Value, json}; +use trusted_server_core::auction::compile_auction_plan; +use trusted_server_core::auction::plan::ProviderPlan; +use trusted_server_core::config::TrustedServerAppConfig; +use trusted_server_core::platform::{AuctionTargetId, BackendNamingPolicy, PlatformBackendSpec}; + +const POLICY: BackendNamingPolicy = BackendNamingPolicy::Fastly; +type TestError = Box; + +fn main() -> Result<(), TestError> { + let args: Vec = env::args().skip(1).collect(); + if args.len() != 4 { + return Err("usage: publisher_registration_backends APP_CONFIG LOOPBACK_ORIGIN MAPPINGS_TOML EVIDENCE_JSON".into()); + } + let (mappings, evidence) = generate(&fs::read_to_string(&args[0])?, &args[1])?; + fs::write(&args[2], mappings)?; + fs::write(&args[3], serde_json::to_string_pretty(&evidence)?)?; + Ok(()) +} + +// Mirror ProviderPlan::backend_spec_with_transport_timeout using public fields. +// In particular, retain endpoint.port() rather than inventing an explicit port, +// leave the Host override absent, and use the exact provider ID discriminator. +fn provider_spec( + provider: &ProviderPlan, + timeout_ms: u32, +) -> Result { + let endpoint = Url::parse(provider.endpoint.as_str())?; + let timeout = Duration::from_millis(u64::from(timeout_ms)); + Ok(PlatformBackendSpec { + scheme: endpoint.scheme().to_owned(), + host: endpoint + .host_str() + .ok_or("provider host missing")? + .to_owned(), + port: endpoint.port(), + host_header_override: None, + certificate_check: true, + first_byte_timeout: timeout, + between_bytes_timeout: timeout, + discriminator: Some(provider.id.as_str().to_owned()), + }) +} + +fn reachable_timers(budget_ms: u32, configured_ms: u32) -> BTreeSet { + // Enumeration deliberately delegates bucketing to the shared policy. This + // small fixture budget needs no copied knowledge of Fastly's timer ladder. + let mut timers = BTreeSet::from([configured_ms]); + timers.extend((1..=budget_ms).filter_map(|remaining_ms| { + let timeout = POLICY.canonicalize_transport_timeout_ms(remaining_ms, configured_ms); + (timeout > 0).then_some(timeout) + })); + timers +} + +fn generate(app_config: &str, loopback_origin: &str) -> Result<(String, Value), TestError> { + let origin = Url::parse(loopback_origin)?; + if origin.scheme() != "http" + || origin.host_str() != Some("127.0.0.1") + || origin.port().is_none() + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + || !origin.username().is_empty() + || origin.password().is_some() + { + return Err( + "fixture origin must be a loopback HTTP URL with an explicit port and no path prefix" + .into(), + ); + } + let app: TrustedServerAppConfig = toml::from_str(app_config)?; + let app = TrustedServerAppConfig::new(app.into_settings())?; + let settings = app.settings(); + let plan = compile_auction_plan(settings)?; + plan.validate_for_target(AuctionTargetId::Fastly)?; + if !plan.enabled() || plan.providers().len() != 1 || !settings.proxy.certificate_check { + return Err( + "fixture must enable exactly one provider and retain certificate checks".into(), + ); + } + let provider = &plan.providers()[0]; + let endpoint = Url::parse(provider.endpoint.as_str())?; + if endpoint.scheme() != "https" || provider.profile.id() != "prebid-server" { + return Err("fixture provider must use HTTPS and the prebid-server profile".into()); + } + let fixture_budget = settings + .creative_opportunities + .as_ref() + .and_then(|config| config.auction_timeout_ms) + .unwrap_or(settings.auction.timeout_ms); + let budget = settings.auction.timeout_ms.max(fixture_budget); + let mut names = BTreeSet::new(); + let mut backends = Vec::new(); + let mut mappings = String::from("\n# Test-only static provider transport overrides.\n"); + for timeout_ms in reachable_timers(budget, provider.timeout_ms) { + let spec = provider_spec(provider, timeout_ms)?; + let predicted = POLICY.predict(&spec)?; + if names.insert(predicted.name.clone()) { + mappings.push_str(&format!( + "[local_server.backends.{:?}]\nurl = {:?}\n", + predicted.name, + origin.as_str() + )); + backends.push(json!({ + "name": predicted.name, + "transport_timeout_ms": timeout_ms, + "resolved_port": predicted.port, + })); + } + } + let evidence = json!({ + "provider_id": provider.id.as_str(), + "endpoint": provider.endpoint.as_str(), + "provider_path": endpoint.path(), + "profile": provider.profile.id(), + "configured_timeout_ms": provider.timeout_ms, + "auction_budget_ms": budget, + "fixture_budget_ms": fixture_budget, + "certificate_check": true, + "host_header_override": null, + "discriminator": provider.id.as_str(), + "loopback_origin": origin.as_str(), + "backends": backends, + }); + Ok((mappings, evidence)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const APP: &str = + include_str!("../fixtures/configs/trusted-server.publisher-registration.toml"); + + #[test] + fn covers_full_reduced_and_minimum_positive_budgets() { + let timers = reachable_timers(1500, 1000); + assert!(timers.contains(&1000), "should cover the configured timer"); + assert!(timers.contains(&250), "should cover reduced budget"); + assert!(timers.contains(&50), "should cover minimum positive timer"); + assert!(!timers.contains(&0), "should omit expired budgets"); + for remaining in 1..=1500 { + let timer = POLICY.canonicalize_transport_timeout_ms(remaining, 1000); + assert!(timer == 0 || timers.contains(&timer)); + } + } + + #[test] + fn preserves_provider_discriminator_flags_and_path() { + let app: TrustedServerAppConfig = toml::from_str(APP).expect("should parse fixture"); + let plan = compile_auction_plan(app.settings()).expect("should compile fixture provider"); + let provider = &plan.providers()[0]; + let spec = provider_spec(provider, 250).expect("should build provider spec"); + assert_eq!(spec.scheme, "https"); + assert_eq!(spec.host, "auction.example.com"); + assert_eq!( + spec.port, None, + "should preserve default HTTPS port semantics" + ); + assert_eq!(spec.discriminator.as_deref(), Some("fixture-pbs")); + assert!(spec.certificate_check); + assert!(spec.host_header_override.is_none()); + assert_eq!(spec.first_byte_timeout, Duration::from_millis(250)); + assert_eq!(spec.between_bytes_timeout, Duration::from_millis(250)); + let (mappings, evidence) = generate(APP, "http://127.0.0.1:8888").expect("should generate"); + let parsed: toml::Value = toml::from_str(&mappings).expect("should parse rendered TOML"); + let predicted = POLICY + .predict(&spec) + .expect("should predict provider backend"); + assert_eq!(predicted.port, 443); + assert_eq!( + parsed["local_server"]["backends"][&predicted.name]["url"].as_str(), + Some("http://127.0.0.1:8888/") + ); + assert_eq!(evidence["provider_path"], "/api/fixture-auction"); + assert_eq!( + Url::parse("http://127.0.0.1:8888") + .expect("should parse origin") + .join( + evidence["provider_path"] + .as_str() + .expect("should retain path") + ) + .expect("should join path") + .path(), + "/api/fixture-auction" + ); + assert_eq!( + parsed["local_server"]["backends"] + .as_table() + .expect("should contain backends") + .len(), + evidence["backends"] + .as_array() + .expect("should list backends") + .len() + ); + } + + #[test] + fn rejects_path_prefixes_and_https_validation_regressions() { + assert!(generate(APP, "http://127.0.0.1:8888/api/fixture-auction").is_err()); + assert!( + generate( + &APP.replace("https://auction.example.com", "http://auction.example.com"), + "http://127.0.0.1:8888" + ) + .is_err() + ); + assert!( + generate( + &APP.replace("certificate_check = true", "certificate_check = false"), + "http://127.0.0.1:8888" + ) + .is_err() + ); + } +} 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 new file mode 100644 index 0000000000..cbfcabbe7d --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.publisher-registration.toml @@ -0,0 +1,97 @@ +# Isolated fictional framework application: registered header and automatic control. +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "integration_admin_password" + +[publisher] +domain = "localhost" +cookie_domain = "localhost" +origin_url = "http://127.0.0.1:8888" +proxy_secret = "integration_proxy_secret" + +[ec] +passphrase = "integration_ec_passphrase" +ec_store = "ec_identity_store" + +[request_signing] +enabled = false +config_store_id = "app_config" +secret_store_id = "secrets" + +[proxy] +certificate_check = true +allowed_domains = ["localhost", "127.0.0.1", "auction.example.com", "assets.example.com", "ads.example.com"] + +[integrations.nextjs] +enabled = true +rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] +max_combined_payload_bytes = 10485760 + +[integrations.gpt] +enabled = true +gam_attribution_enabled = false +script_url = "https://ads.example.com/gpt.js" +rewrite_script = false + +[integrations.prebid] +enabled = true +external_bundle_url = "https://assets.example.com/fixture-prebid.js" +timeout_ms = 1500 +client_side_bidders = [] + +[auction] +enabled = true +timeout_ms = 1500 +allowed_context_keys = [] + +[auction.providers.fixture-pbs] +protocol = "openrtb-2.6" +profile = "prebid-server" +endpoint = "https://auction.example.com/api/fixture-auction" +timeout_ms = 1000 +routing = "explicit" + +[auction.providers.fixture-pbs.profile_config] +debug = false +test_mode = true +consent_forwarding = "both" + +[auction.bidders.fixture-bidder] +provider = "fixture-pbs" + +[creative_opportunities] +enabled = true +gam_network_id = "123456789" +auction_timeout_ms = 1500 +assembly_mode = "inline" + +[[creative_opportunities.slot]] +id = "fixture-header" +initialization = "publisher_registered" +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" + +[[creative_opportunities.slot]] +id = "fixture-auto" +div_id = "fixture-auto-" +page_patterns = ["/publisher-registration"] +formats = [{ width = 300, height = 250 }] + +[creative_opportunities.slot.providers.prebid.bidders.fixture-bidder] +placement = "fixture-auto" + +[debug] +inject_adm_for_testing = false 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 new file mode 100644 index 0000000000..75584d9e2a --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/fixture-auction/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { FixtureAuctionControl } from "../../publisher-registration-real-gpt/provider-control"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +type Impression = { + id: string; + banner?: { format?: Array<{ w: number; h: number }> }; + ext?: { prebid?: { bidder?: Record } }; +}; +type AuctionRequest = { + id: string; + imp: Impression[]; + tmax?: number; + site?: { page?: string }; +}; +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, 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; + control.reset(); + return NextResponse.json({ exchanges, control: control.snapshot() }); +} + +export async function POST(request: NextRequest) { + const auction = (await request.json()) as AuctionRequest; + if ( + typeof auction.id !== "string" || + !Array.isArray(auction.imp) || + auction.imp.length === 0 || + auction.imp.some( + (imp) => + typeof imp.id !== "string" || + !imp.ext?.prebid?.bidder?.["fixture-bidder"] || + !imp.banner?.format?.some(({ w, h }) => w === 300 && h === 250), + ) + ) { + return NextResponse.json( + { error: "invalid fixture OpenRTB request" }, + { status: 400 }, + ); + } + + const pagePath = new URL( + auction.site?.page || "https://publisher.example.com/", + ).pathname; + const mode = control.forPath(pagePath); + const noBid = mode.noBid; + const height = pagePath.endsWith("/tall") ? 600 : 250; + const response = { + id: auction.id, + cur: "USD", + seatbid: noBid + ? [] + : [ + { + seat: "fixture-bidder", + bid: auction.imp.map((imp) => ({ + id: `fixture-win-${imp.id}`, + impid: imp.id, + price: 1.25, + adm: '
Fixture publisher registration winner
', + adomain: ["advertiser.example.com"], + crid: `fixture-creative-300x${height}`, + w: 300, + h: height, + ext: { prebid: { type: "banner", bidder: "fixture-bidder" } }, + })), + }, + ], + }; + exchanges.push({ + path: request.nextUrl.pathname, + request: auction, + response, + mode, + }); + console.log( + JSON.stringify({ fixtureAuction: exchanges[exchanges.length - 1] }), + ); + return NextResponse.json(response); +} 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 ( + <> + and 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] +``` + +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. + +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. + +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. + +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 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. + +These integrations retain their early initialization dependencies: + +| Integration | Early dependency | +| ---------------------- | ----------------------------------------------------------------------- | +| 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 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 @@ -118,15 +160,17 @@ Takes over `googletag.cmd` so every queued callback is wrapped before GPT execut ### Server slot handoff -For 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. @@ -138,6 +182,265 @@ auctions. SPA navigation obtains page bids through `GET /_ts/page-bids` (the legacy `GET /__ts/page-bids` alias remains available); it does not use `POST /auction` for that navigation flow. +### Publisher-registered initial delivery + +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, 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]] +id = "example-header" +div_id = "example-header-" +page_patterns = ["/example/*"] +formats = [{ width = 300, height = 250 }, { width = 300, height = 600 }] +initialization = "publisher_registered" +``` + +TS does not define a fallback for this placement. The publisher mounts the final +element, defines its native GPT slot, adds PubAdsService and delegates the first +delivery to TS. The final element ID need not match the configured prefix. Its +native slot must have the configured rendered GAM path. It must remain connected +with that ID through delivery and rendering. + +```mermaid +flowchart TD + P[Current admitted server payload] --> J[Join for logical slot and navigation] + R[Publisher registers exact live native slot] --> J + J --> V[Validate identity, eligibility and disabled initial load] + V --> D[Claim first delivery once] + D --> G[Display exact ID and refresh only that slot] + G --> C[Render in the same live element] +``` + +#### Page setup and ownership + +The publisher must disable initial loading before enabling services. This is +required even with SRA, because an enabled-load `display` can fetch unrelated +unfetched slots. TS does not change the page's load or SRA policy for registered +slots. Configure them in the publisher's existing GPT setup, not a second +component-level setup: + +```js +googletag.cmd.push(() => { + googletag.setConfig({ disableInitialLoad: true, singleRequest: true }) + // The publisher owns service setup and enables it once for the page. + googletag.enableServices() +}) +``` + +Before queuing a slot callback, capture `window.tsjs.navGeneration ?? 0`. Pass +that captured value, not a fresh generation read inside a callback from an older +route. Inside the callback, after native definition: + +```js +const registration = window.tsjs.registerPublisherSlot({ + slotId: 'example-header', + gptSlot: nativeSlot, + navigationGeneration: capturedGeneration, + eligibleFormats: [[300, 250]], +}) +``` + +Check `publisherSlotRegistrationVersion === 1` as part of the matched publisher +integration. The publisher must not call the placement's initial `display`, +`refresh` or Prebid auction after handing it over. TS displays and explicitly +refreshes only that offered native slot. Genuinely later publisher refreshes keep +their ordinary Prebid behavior and refresh options. + +The slot waits for an accepted current-navigation payload and foreground +scheduling. Head configuration, `load`, DOM stability, default bids and a failed +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. + +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. +A pathname round trip is a new generation, not permission to reuse an earlier +payload. Ordinary startup without a prior navigation does not start another auction. + +#### Responsive eligibility and React teardown + +`eligibleFormats` contains finite positive integer size pairs from the same +publisher values used for the native definition or size mapping. It does not +change native GPT sizes or run another TS responsive engine. While pending, the +publisher synchronously cancels and re-registers when that effective set changes, +including while hidden and before foreground delivery resumes. Do not update it +in a later passive React effect. Set-equivalent reordering needs no update. + +The following example uses public GPT types and assumes +`gam_network_id = "123456789"`. Its page-level setup above must already be +installed. The parent explicitly cancels and destroys before changing mounted +state. Route adapters must call the same teardown before navigation or removal; +layout cleanup is a backstop, not a replacement for that orchestration. Teardown +never waits behind a future GPT callback, and queued setup checks its own +cancellation flag and captured generation. + +```tsx +import { useLayoutEffect, useRef, useState } from 'react' + +type TeardownRef = { current: () => void } + +export function ExampleRegisteredHeader({ + teardown, +}: { + teardown: TeardownRef +}) { + useLayoutEffect(() => { + const ts = window.tsjs + if (ts?.publisherSlotRegistrationVersion !== 1 || !ts.registerPublisherSlot) + return + const register = ts.registerPublisherSlot.bind(ts) + const generation = ts.navGeneration ?? 0 + const compact: Array<[number, number]> = [[300, 250]] + const expanded: Array<[number, number]> = [ + [300, 250], + [300, 600], + ] + const wide = window.matchMedia('(min-width: 1024px)') + let disposed = false + let slot: googletag.Slot | null = null + let registration: ReturnType | undefined + let eligibility: Array<[number, number]> | undefined + + function updateEligibility() { + if (disposed || !slot || (ts.navGeneration ?? 0) !== generation) return + const next = wide.matches ? expanded : compact + if (next === eligibility) return + const status = registration?.getStatus().status + if ( + status && + status !== 'waiting_payload' && + status !== 'waiting_delivery' + ) + return + registration?.cancel() + eligibility = next + registration = register({ + slotId: 'example-header', + gptSlot: slot, + navigationGeneration: generation, + eligibleFormats: next, + }) + } + + googletag.cmd.push(() => { + if (disposed || (ts.navGeneration ?? 0) !== generation) return + const mapping = googletag + .sizeMapping() + .addSize([1024, 0], expanded) + .addSize([0, 0], compact) + .build() + if (!mapping) return + // Use the rendered path for this configured placement and route. + slot = googletag.defineSlot( + '/123456789/example-header', + expanded, + 'example-header-final' + ) + if (!slot) return + slot.defineSizeMapping(mapping).addService(googletag.pubads()) + wide.addEventListener('change', updateEligibility) + // Capture updates eligibility before foreground delivery listeners run. + document.addEventListener('visibilitychange', updateEligibility, true) + updateEligibility() + }) + + function dispose() { + if (disposed) return + disposed = true + registration?.cancel() + wide.removeEventListener('change', updateEligibility) + document.removeEventListener('visibilitychange', updateEligibility, true) + if (slot) googletag.destroySlots([slot]) + } + teardown.current = dispose + return dispose + }, [teardown]) + + return
+} + +export function ExamplePlacement() { + const [mounted, setMounted] = useState(true) + const teardown = useRef(() => {}) + return ( + <> + {mounted && } + + + ) +} +``` + +Use your publisher's existing TSJS and public GPT type declarations. The complete +framework fixture additionally checks native object identity, element identity, +request-time targeting, visible creative content and teardown ordering. A request +count alone does not establish visible delivery. + +#### Status, cancellation and demand eligibility + +`registration.getStatus()` returns a result discriminated by `status`: + +- `waiting_payload` or `waiting_delivery`: pending; cancel before replacing the + slot or changing its effective eligibility. +- `dispatching`, `requested` or `rendered`: the logical initial delivery is + consumed for this navigation. +- `inactive`: the admitted payload omitted the placement or did not select this mode. +- `publisher_owned`: an earlier observed publisher claim won; TS does not overwrite it. +- `cancelled`: pending work was cancelled or invalidated. +- `rejected`: invalid input, stale/conflicting identity or enabled/unknown initial-load policy. +- `failed_after_dispatch`: a native invocation threw after delivery began. A + request may already have occurred, so TS never automatically retries it. + +Cancellation affects only that handle's record. An old handle cannot cancel a +replacement or a newer navigation. Cancelling after dispatch cannot undo a +request or reopen initial delivery. Destroy the native slot before removing its +div. Intentional replacement after a request belongs to the publisher's later +lifecycle; TS does not move the creative or rebind the old native object. + +A TS bid participates only when a real TS renderer is installed and the winner's +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. + +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, +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 +refresh. Local stubs do not prove real-GAM behavior or publisher adoption. + +A rejected or inactive handle is not automatically permission to start a competing +publisher request. Rollback is a separate compatible server/adapter/configuration +release decision, not an asynchronous fallback callback. Do not change ownership +or initialization mode inside an already-open document. This opt-in API does not +repair unmodified publishers or close all performance work in issue #854. + ### Shared script-guard dispatcher GPT's six interception layers register with the shared DOM insertion diff --git a/docs/superpowers/plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md b/docs/superpowers/plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md new file mode 100644 index 0000000000..4414e339e3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md @@ -0,0 +1,823 @@ +# Publisher-registered initial GPT delivery implementation plan + +Status: [Independently re-reviewed](../reviews/2026-10-27-854-publisher-registered-initial-delivery-rereview.md), with no remaining design findings. +User authorized implementation and a draft PR on 2026-10-27. Implementation, +final V4 local checks, normal browser matrix and independent source review are +complete. Real-GPT staging and deployment remain unapproved. See the +[execution evidence](../reviews/2026-10-27-854-publisher-registered-initial-delivery-validation.md). + +Spec: [Publisher-registered initial GPT delivery](../specs/2026-10-27-854-publisher-registered-initial-delivery-design.md). + +Worktree: `issue-854-publisher-slot-registration`, based on +`5bc38862512c5c71085b5052bc3e528ee9b9987d`. + +## Outcome + +Land one opt-in correctness change for #854: a publisher registers its final live +GPT slot and explicitly delegates initial delivery to TS. TS waits for the +current admitted server payload, applies eligible targeting, then displays and +explicitly refreshes that slot once. V1 requires effective initial load already +disabled. Automatic placements retain existing behavior for enabled and disabled +initial loading. No bundle deferral, reservation injection, format-alias shortcut, +or production deployment belongs in this implementation. + +This branch starts without the earlier uncommitted CWV tooling. Implement only +the minimal durable regression coverage needed here. Do not copy customer data, +raw captures, operator configuration, temporary artifacts, `node_modules`, or +changes from another worktree. The plan's commands run from this worktree root +unless a command explicitly changes directory. + +## Working rules and dependencies + +- Tasks 0 through 7 are dependency-ordered. Task 0 proves the test-only provider + transport before delivery implementation begins. Do not activate the new + configuration between partial tasks. Task 8 validates the combined code, Task 9 wires CI and + documentation, and Task 10 governs release evidence. +- Write the relevant failing test before each behavior change. A new API missing + from old code is one failure signal; also preserve a native-request reproduction + of the old automatic changed-ID/size sequence so the causal behavior is visible. +- Use focused tests at each completed behavioral unit, broader suites at stable + integration checkpoints, and the complete project gates before PR handoff. +- One command owner per Cargo target directory, generated browser artifact set, + fixture server/port and mutable integration state. Reuse current passing evidence + until relevant inputs change. Do not run timing measurements during builds. +- Stop for design review instead of adding sleeps, new grace periods, polling, + global GPT policy changes, unknown-size guesses, or request suppression for + arbitrary publisher calls. +- Read this worktree's `AGENTS.md` and `AGENTS.override.md` first. Scope commits + to intended files when requested. Do not push, open a PR, deploy or enable a + production setting without the corresponding approval. + +### Shared setup before Task 0 + +Install this worktree's JS/browser dependencies and build its baseline assets +before the transport preflight. Reuse these outputs in Task 1 while inputs remain +unchanged: + +```sh +npm ci --prefix crates/trusted-server-js/lib +npm ci --prefix crates/trusted-server-integration-tests/browser +(cd crates/trusted-server-integration-tests/browser && npx playwright install chromium) +(cd crates/trusted-server-js/lib && node build-all.mjs) +``` + +These are future implementation commands, not checks run during this documentation +revision. The launcher must build or accept a source-fingerprinted baseline WASM +and the Next.js fixture image through the existing integration build paths. + +## Task 0. Prove the test-only provider transport before delivery work + +**Create as test infrastructure, then extend in Task 8:** + +- `crates/trusted-server-integration-tests/examples/publisher_registration_backends.rs` +- `crates/trusted-server-integration-tests/fixtures/configs/trusted-server.publisher-registration.toml` +- `crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/fixture-auction/route.ts` +- `scripts/test-publisher-registration.sh`, initially supporting `--transport-smoke`. + +The chosen arrangement uses Viceroy's static backend mapping, not a production +HTTP-provider exception or a new TLS trust configuration. The application provider +keeps `https://auction.example.com/api/fixture-auction` and certificate checking. +Only the isolated generated Viceroy configuration maps its predicted backend names +to `http://127.0.0.1:`. The backend base URL has no API path prefix, +so the provider's original `/api/fixture-auction` path is preserved once. + +Source supports the mechanism on the pinned Viceroy 0.17.0: + +- `src/session.rs::add_backend` rejects names already present in static or dynamic + backends, and `backend` resolves the static entry first. +- `src/wiggle_abi/req_impl.rs::register_dynamic_backend` turns that rejection into + `BackendNameRegistryError`; `src/error.rs::to_fastly_status` maps it to ERROR. +- Fastly SDK 0.12 maps ERROR to `BackendCreationError::NameInUse`. This checkout's + `crates/trusted-server-adapter-fastly/src/backend.rs::BackendConfig::ensure` + reuses that existing name, including the matching "already in use" message. +- Viceroy `src/upstream.rs::canonical_uri` and `BackendConnector::call` select the + configured backend URI and scheme, not the canonical provider URL's transport. +- Viceroy `src/config/backends.rs` parses named static backend `url` entries. + +Pinned upstream source is under +[`fastly/Viceroy` v0.17.0](https://github.com/fastly/Viceroy/tree/v0.17.0/src). +This is source evidence, not an executed transport pass. The smoke below is a +hard preflight gate; do not leave the first attempt until Task 8. + +- [x] Read the current fixture app config, compile one prebid-server provider and + its explicit bidder routes, and keep unrelated integrations/providers off. + Before Task 2, use the existing default automatic slot schema; add the new + initialization field only after it is supported. +- [x] Implement the native test example using already available integration-test + dev dependencies. Parse the compiled fixture provider's public endpoint, + provider ID and timeout fields. Construct its `PlatformBackendSpec` with the + same endpoint host/443 port semantics, certificate flag, no Host override, + provider discriminator and first/between-byte timers used by `ProviderPlan`. + Do not export a production-only private method merely for this helper. +- [x] Use `BackendNamingPolicy::Fastly.canonicalize_transport_timeout_ms` and + `predict`, not a copied hash/bucketing algorithm. Enumerate and deduplicate + every reachable positive timer for the fixture auction budget, including the + configured provider timer and reduced-budget buckets. Map every resulting + name to the same loopback origin. One hardcoded full-budget name is unsafe + because provider requests hash their request-local transport budget. +- [x] Add native helper tests for full/reduced/minimum budgets, matching discriminator, + endpoint path preservation and rendered TOML parsing. Compare predicted names + to the same shared policy; do not weaken HTTPS validation for those tests. +- [x] Generate the ordinary app-config envelope with `generate-viceroy-config`, + then append only these `[local_server.backends.]` entries to + a dedicated generated output. Leave the normal template, operator config, + production provider validation and `certificate_check: true` unchanged. +- [x] The loopback Next.js API route records the incoming OpenRTB request and returns + deterministic legal win/no-bid JSON through the existing prebid-server + profile. Supply matching imp/seat/bidder identity and dimensions. Do not + assign `tsjs.bids` in the browser to stand in for provider admission. +- [x] Run `--transport-smoke` against the baseline WASM with this generated config. + Assert the HTTPS application endpoint compiles, dynamic registration reuses + the generated static backend, the loopback route receives the actual Rust + request at the intended path, and its known winner is admitted into the + actual initial/seam or page-bids response. Assert unexpected backend names + or external provider attempts fail the smoke. +- [x] Persist fixture/runtime versions, predicted and observed backend names, + provider request/response facts, winner admission and logs. Prove a reduced + transport-budget name is covered as well as the full-budget case. +- [x] If the pinned runtime cannot execute this source-supported arrangement, + stop before Tasks 2 through 7 and return to transport design review. Do not + change canonical endpoints to HTTP, disable certificate checks, silently + use handwritten browser bids or mark the later framework gate passed. + +**Commands to implement and execute:** + +```sh +cargo test -p trusted-server-integration-tests --example publisher_registration_backends +./scripts/test-publisher-registration.sh --transport-smoke +``` + +**Check:** actual compiled Rust provider routing and admission work against the +local fixture without production validation changes. Full feature/browser proof +remains Tasks 7 and 8. No smoke was run while writing this plan. + +## Task 1. Establish a fresh baseline and executable reproductions + +**Read:** + +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`, especially + `installScheduleInitialAdInit`, `matchingHandoff`, `installTsAdInit`, + `waitForSlotElements` and `installSpaAuctionHook`. +- `crates/trusted-server-core/src/integrations/gpt_bootstrap.js`. +- `crates/trusted-server-js/lib/src/core/first_impression.ts` and `core/types.ts`. +- `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` refresh and + publisher first-impression registration paths. +- Existing `browser/initial-render/` runner, fixtures and evidence boundaries. + +**Create:** + +- `crates/trusted-server-integration-tests/browser/publisher-registration/gpt-fixture.js` +- `crates/trusted-server-integration-tests/browser/publisher-registration/pages.cjs` +- `crates/trusted-server-integration-tests/browser/publisher-registration/pages.test.cjs` +- `crates/trusted-server-integration-tests/browser/publisher-registration/run.cjs` + +- [x] Confirm the branch has only the approved planning documents before editing. +- [x] Reuse the shared setup and passing transport preflight. Rebuild only when + the corresponding source, dependency, configuration or toolchain input changed. + +- [x] Run and retain clean-base focused results: + + ```sh + (cd crates/trusted-server-js/lib && npx vitest run \ + test/integrations/gpt/ad_init.test.ts \ + test/integrations/gpt/gpt_bootstrap.test.ts \ + test/integrations/gpt/schedule_initial_ad_init.test.ts \ + test/integrations/gpt/spa_hook.test.ts) + node --test crates/trusted-server-integration-tests/browser/initial-render/pages.test.cjs + node crates/trusted-server-integration-tests/browser/initial-render/run.cjs + ``` + +- [x] Capture any baseline failure with command, revision, input hashes, environment, + failure signature and log. Prior checkout failures and timeouts are leads, not + proof that this clean branch fails or passes. +- [x] Implement a transport-only GPT fixture, separate from the diagnostics stub. + Model public definitions, service setup, effective initial-load state, displayed + registrations, selected refreshes, slots/destruction, SRA behavior, targeting at + native invocation, and controlled documented events. Never consult TS ownership + to suppress requests in the stub. +- [x] Include an unrelated defined but unfetched slot. With enabled initial load + and SRA, the fixture must expose that `display` can fetch it. With disabled + initial load and an explicit refresh list, it must remain untouched. +- [x] Create two old-mode diagnostic controls: replace/rename the server-rendered + ID after TS delivery, then define identical, reordered or narrowed formats. + Record native slots, requests, element objects/IDs and visible creatives. + Do not mark an old-ID alias as a visible-delivery pass. +- [x] Add escaping tests for all inline fixture data and a pure native-count test + proving repeated native calls are counted even if TS claims ownership. +- [x] Default the new runner to fix expectations. An explicit `--baseline` mode + may record the old automatic behavior but must never make failing new-mode + assertions pass. Use `--implementation runtime|bootstrap|handoff|all` and + `--out DIR`; output defaults to a temporary directory outside Git. + +**Check:** page-construction/native-fixture tests pass; existing ten initial-render +scenarios retain their outcomes. New registration expectations fail because the +API/mode does not exist, not because the fixture secretly deduplicates requests. + +## Task 2. Add the per-placement configuration and wire field + +**Modify:** + +- `crates/trusted-server-core/src/creative_opportunities.rs` +- `crates/trusted-server-core/src/publisher.rs` +- `crates/trusted-server-js/lib/src/core/types.ts` +- Existing Rust slot literals/helpers affected by the added field, located with + `git grep -n -F 'CreativeOpportunitySlot {' -- crates`. + +- [x] Add failing Rust tests for absent/default `auto`, explicit + `publisher_registered`, invalid enum values, and serde round trips. +- [x] Add a typed enum with `auto` default and skip default serialization. Do not + represent initialization with multiple independent booleans. +- [x] Add `initialization?: 'auto' | 'publisher_registered'` to the client slot + contract. Preserve automatic wire output byte-for-byte. +- [x] Update `build_slot_json` once, emitting only a non-default mode. Assert the + same value in initial-document slots, shared-template seam slots and successful + `/_ts/page-bids` output. Assert gating/empty responses still omit placements. +- [x] Update every affected Rust struct literal with the default. Keep those + changes mechanical and do not refactor unrelated auction/provider code. +- [x] Verify the mode does not change auction requests, bid selection, page + matching, consent or template gating. + +**Focused commands:** + +```sh +cargo test -p trusted-server-core --lib creative_opportunities +cargo test -p trusted-server-core --lib build_slot_json +cargo test -p trusted-server-core --lib page_bids +cargo fmt --all -- --check +``` + +Use a repository target-matched alias if a native-only test boundary requires it; +record the exact selected target and result. Run the whole touched publisher test +module if a filter would omit the new initial/shared-template assertions. + +**Check:** automatic serialization is unchanged; only explicitly opted-in admitted +placements carry the mode. New Rust tests are executed, not merely compiled. + +## Task 3. Implement registration records and exclude automatic delivery + +**Create:** + +- `crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/publisher_registration.test.ts` +- `crates/trusted-server-js/lib/test/fixtures/publisher_registration_contract.ts` + +**Modify:** + +- `crates/trusted-server-js/lib/src/core/types.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` + +- [x] Define the public options, handle/status discriminated types, protocol + version, and browser-local shared record shape from the spec. Native slot and + element references must not enter a JSON export. +- [x] Implement registration/cancellation/status and a shared current dispatcher. + Derive the element from the native slot's exact ID. Capture the exact object, + element, generation and eligible-format snapshot at the boundary. +- [x] Reject invalid arguments, stale generations, duplicate IDs, conflicting live + registrations and enabled/unknown initial-load state without native delivery. + Use supported GPT getters and existing legacy disable observation. Do not infer + disabled mode from a missing getter, configure it, or add another global wrapper. +- [x] Make set-equivalent repeated eligibility registration return the same logical + record without rewriting GPT's size order. Test repeated handle cancellation + and ensure stale handles cannot cancel a replacement record or newer navigation. + A changed eligible set requires cancel before re-registering, even when the + native object and element stay the same. +- [x] Add a consumed placement marker that survives physical-element disappearance + until navigation invalidation. Do not rely only on the existing physical-element + claim registry, which can legitimately prune replaced elements. +- [x] Skip registered slots in `adInit` before any first-impression claim, fallback + definition, fallback retry, renderable-work test or refresh. Do not put them in + `prevGptSlots` or a late-definition handoff. Add zero-call assertions on pages + containing only registered placeholders and on mixed automatic/registered pages. +- [x] Keep ordinary automatic initialization and enabled-load behavior unchanged. + Do not alter the old changed-ID matcher as part of this task. + +**Focused command:** + +```sh +(cd crates/trusted-server-js/lib && npx vitest run \ + test/integrations/gpt/publisher_registration.test.ts \ + test/integrations/gpt/ad_init.test.ts) +``` + +**Check:** waiting/invalid registrations and registered SSR placeholders produce +zero requests and definitions. Automatic controls continue to request normally. +Delivery itself is added only after admission joins are established next. + +## Task 4. Join authoritative initial and SPA payload admission + +**Modify:** + +- `crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/publisher_registration.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts` + +- [x] Write both arrival-order tests: publisher registration before accepted + payload, and accepted payload before registration. No request is authorized by + head slot configuration, default empty `tsjs.bids`, or document `load` alone. +- [x] Publish generation-0 settlement only after the existing scheduler accepts + its first payload. Preserve omitted initialSlots versus explicit `[]`, duplicate + scheduling latches and stale-SSR rejection. +- [x] On accepted SPA navigation, invalidate old registrations/admission before + new route work is queued. Capture generation before async work; recheck it after + fetch and in every queued drain. +- [x] Publish accepted SPA payload before registered slots would wait on automatic + DOM resolution. Restrict `waitForSlotElements` to automatic placements while + preserving its behavior for those placements. Test registered-only and mixed + routes with a never-mounted registered element. +- [x] Cover empty accepted payload, no payload seam, aborted/failed page-bids, + late old responses, repeated pathnames after another navigation, stale GPT + command callbacks and cancellation while waiting. None may authorize stale work. +- [x] Coalesce drains through the shared dispatcher and existing foreground/rAF + behavior. Test a hidden document held until visible. Never use frame count or + temporary DOM stability as the readiness condition. +- [x] Add waiting-payload and hidden-document breakpoint-change tests. The adapter + synchronously cancels the old handle, recomputes its own effective eligibility + and re-registers before delivery resumes. Force the old frame/payload callback + and old-handle cancellation to run afterward; neither may request, clear nor + cancel the replacement. Verify one eventual request with the new eligibility. + Also test a resize after dispatch does not reopen the consumed initial request. +- [x] Recheck element identity, native-slot liveness, path, mode, generation and + effective disabled-load policy immediately before dispatch. Policy changes while + waiting cause a no-request rejection, not an implicit setting change. + +**Focused command:** + +```sh +(cd crates/trusted-server-js/lib && npx vitest run \ + test/integrations/gpt/publisher_registration.test.ts \ + test/integrations/gpt/schedule_initial_ad_init.test.ts \ + test/integrations/gpt/spa_hook.test.ts) +``` + +**Check:** the join has exactly two owners, accepted payload application and +publisher registration, using the existing navigation generation. No parallel +hydration timer or independent page-bids request mechanism was introduced. + +## Task 5. Deliver once with eligible targeting and existing renderer behavior + +**Modify:** + +- `crates/trusted-server-js/lib/src/core/types.ts` for the internal renderer capability. +- `crates/trusted-server-js/lib/src/integrations/gpt/publisher_registration.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/publisher_registration.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` for + integration assertions, not a refresh-policy rewrite. + +- [x] Claim the untouched first impression only at final admission. If an observed + publisher auction/request won first, return `publisher_owned` without targeting, + a TS retry lease, a new fallback, or another request. +- [x] Commit dispatch once before native delivery. Reentrant registration cannot + dispatch again. After any native delivery exception, retain consumed ownership + and return `failed_after_dispatch`, since an exception does not prove no request. +- [x] Give the shared maps an explicit per-generation writer rule in bootstrap + and runtime. Automatic initialization replaces only its automatic entries and + derives/retains registered entries from current registration records. Registered + dispatch updates only its entries. Include consumed/cancelled-after-dispatch + records whose slot still needs cleanup/correlation; do not add a second + independently mutable registered-mapping registry. +- [x] Test registered delivery before automatic frames, the reverse order, a + registered-only empty automatic pass, and a mixed SPA route. Use a valid final + native element ID outside the configured prefix to prove exact render lookup + does not rely on prefix fallback. On the next navigation, assert both owners' + old targeting keys are cleared, old mappings retired and new mappings retained. + Include cleanup queued until GPT is ready, using the captured old snapshot. +- [x] Reuse targeting cleanup, diagnostics opportunity and render-bridge + bookkeeping under that writer rule. Keep publisher-native slots out of + `prevGptSlots` and TS destruction ownership. +- [x] Publish `tsRenderBridgeVersion === 1` only after the real TS message handler + is installed. Make repeated installation idempotent without changing legacy + automatic delivery policy. No bootstrap code may advertise an absent handler. +- [x] Filter only the offered registered-slot candidate using the installed renderer + capability, winning `w`/`h`, configured formats and the publisher eligibility + snapshot kept current by cancel/re-register. Preserve reordered equivalent + sets for comparison without mutating GPT size order. +- [x] Cover valid fitting bid, narrowed incompatible bid, unknown dimensions, + zero/no-fill bid map, fluid-only/empty finite eligibility and a missing renderer. + All admitted cases still request GAM once; excluded candidates must have no + managed TS candidate targeting/correlation. Ordinary GAM fill uses actual + publisher creative content in the fixture; explicit no-fill is allowed as + empty and must not be misreported as a failed TS renderer. +- [x] Preserve unrelated publisher targeting and clear stale TS candidate keys for + this owned delivery only. Do not mutate the server's bid map or affect another + automatic placement's candidate. +- [x] Ensure the render bridge cannot recover an excluded registered candidate + merely from the globally retained bid map. Use existing first-impression targeting + and exact-slot correlation where possible, rather than another eligibility + registry. Test no response/beacon for a requesting slot that did not receive that + candidate. Keep existing automatic and APS renderer behavior unchanged. +- [x] Call `display(actualId)` once followed by `refresh([offeredSlot])` once under + the existing scoped internal/Prebid bypasses, restored with `try/finally`. + Make no definitions, service-policy changes, global refreshes or one-shot + publisher-refresh suppressions. +- [x] Assert an unrelated SRA slot remains unfetched. Assert a genuinely later + publisher refresh still traverses Prebid and requests once with original options. + +**Focused command:** + +```sh +(cd crates/trusted-server-js/lib && npx vitest run \ + test/integrations/gpt/publisher_registration.test.ts \ + test/integrations/gpt/ad_init.test.ts \ + test/integrations/prebid/index.test.ts) +``` + +**Check:** native request snapshots show targeting before the single scoped request; +no candidate is fabricated for an incompatible size. A count-only/blank-creative +result is explicitly not sufficient acceptance. + +## Task 6. Mirror and prove the bootstrap protocol + +**Modify:** + +- `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- `crates/trusted-server-core/src/integrations/gpt.rs` +- `crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts` +- Shared `publisher_registration_contract.ts` and registration tests. + +- [x] Implement the same API version, statuses, payload settlement, mode skip, + exact-object identity, mapping writer rule, size-snapshot cancellation and + scoped request semantics in the inline implementation. Candidate eligibility + uses the same capability check, but bootstrap-only execution has no TS bridge. + Install the API before publisher head scripts can call it. +- [x] Keep its state on the same `window.tsjs` fields. Queued callbacks must resolve + the current shared dispatcher after the bundle takes over. +- [x] Parameterize the common executable contract for runtime-only, bootstrap-only, + bootstrap waiting then runtime, and bootstrap already requested then runtime. +- [x] Include runtime network failure, registration before GPT queue drain, + duplicate runtime installation, payload arriving across implementation handoff, + cancellation across handoff, old-navigation callbacks and post-dispatch throw. +- [x] Choose ordinary-GAM degradation when the TS renderer capability is absent: + omit managed TS candidate targeting and render correlation, display/register + once and explicitly refresh the registered slot once. Do not add a renderer + to the bootstrap. Record the demand-participation tradeoff in docs and tests. +- [x] With a fitting TS bid present but no TS bridge, prove the fixture's ordinary + publisher creative is actually rendered. After late runtime installation, + assert no retroactive candidate targeting, automatic replacement refresh or + response to the excluded candidate from the retained global bid map. A still + pending registration may use the renderer only after the actual handler + installs. Test both sides of that dispatch boundary. +- [x] Add Rust include/head-order checks and execute the actual included JS in + Vitest. A source substring assertion alone cannot satisfy parity. +- [x] Do not migrate bootstrap bundling just to remove duplication. Share a small + pure helper only if the current build can already emit it without an added + blocking dependency; otherwise use the existing deliberate duplication plus + the exact same black-box contract cases. + +**Focused commands:** + +```sh +(cd crates/trusted-server-js/lib && npx vitest run \ + test/integrations/gpt/publisher_registration.test.ts \ + test/integrations/gpt/gpt_bootstrap.test.ts \ + test/integrations/gpt/schedule_initial_ad_init.test.ts) +cargo test -p trusted-server-core --lib integrations::gpt +``` + +**Check:** a pending or consumed bootstrap registration is adopted, never restarted. +Failure to load the normal bundle still leaves one supported GAM request path. + +## Task 7. Complete offline browser contracts with real Prebid and creatives + +**Modify/create:** the `browser/publisher-registration/` files from Task 1. + +**Reuse:** existing actual bundles, external Prebid builder and Universal Creative +handling from `browser/initial-render/`. Avoid importing that runner as a module +with startup side effects; share only small pure page/creative helpers when useful. + +- [x] Extend the runner with fixed modes for publisher API registration, both + payload arrival orders, initial inline/shared-seam representations, bootstrap + variants, SRA isolation, old-mode controls and explicit later refresh. +- [x] Use real built core/GPT/Prebid, a manifest-verified external Prebid artifact + and real Universal Creative. Intercept provider/GAM transport for fictional + origins, with deterministic completion controls and no real bidder contacts. +- [x] Delay publisher mount/registration explicitly, rather than sleeping to pick + the winner. Assert no TS requests or fallback definitions while only SSR + placeholders are present, including while an unrelated automatic slot runs. +- [x] Assert one native GPT object and request for each final registered placement, + request-time targeting, exact returned slot/element identity and actual creative + content in the final separate-origin frame. Do not accept marker-only delivery + as proof of the render bridge or an alias to a missing ID as proof of visibility. +- [x] Cover two placements sharing a GAM path independently. Reordered/narrowed + publisher formats are used before registration; they do not trigger aliasing. +- [x] Model unmount before admission with cancel/destroy/remove ordering. For + deliberate unmount after a request, assert consumed registration is not reopened + and record the supported teardown boundary instead of promising preservation. +- [x] Verify real Prebid does not auction the TS initial refresh, does auction a + subsequent publisher refresh, preserves unrelated targeting/options and leaves + publisher-first claims alone. Also cover empty/incompatible TS bid delivery. +- [x] Test renderer-unavailable delivery separately from the TS Universal Creative + path: no TS candidate keys, one ordinary request and actual publisher creative + content when the fixture supplies a fill. Late bridge installation must not + request again or answer for the excluded candidate. Explicit no-fill remains + a valid empty result, not a guaranteed-fill assertion. +- [x] Record commit, dirty inputs, bootstrap and bundle hashes, external Prebid + manifest, browser version, case/profile, all per-slot native calls, controlled + network counts, render facts and console errors. Keep native requests distinct + from network batches. Save traces/screenshots/results even on failed cases; + startup failures remain fatal, never an allowed diagnostic variant. +- [x] Default fixed assertions are mandatory CI behavior. Do not provide a general + allow-failures flag for the fix contract. Document missing real-GAM coverage. + +**Commands:** + +```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 +node crates/trusted-server-integration-tests/browser/initial-render/run.cjs +``` + +**Check:** old automatic-mode reproduction remains accurately labeled; supported +registered-mode tests pass one-request and visible-delivery assertions. All older +initial-render contracts retain their results. + +## Task 8. Verify real framework lifecycle through actual Rust HTML injection + +**Create or extend the transport infrastructure already proved in Task 0:** + +- `crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration/page.tsx` +- `crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/publisher-registration/RegisteredSlot.tsx` +- `crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/app/api/fixture-auction/route.ts` +- `crates/trusted-server-integration-tests/fixtures/configs/trusted-server.publisher-registration.toml` +- `crates/trusted-server-integration-tests/browser/tests/nextjs/publisher-registration.spec.ts` +- `crates/trusted-server-integration-tests/browser/playwright.publisher-registration.config.ts` +- `scripts/test-publisher-registration.sh` + +**Modify only if needed:** Next.js test-fixture configuration, the browser package +scripts, and small reusable test helpers. Do not enable GPT/auctions globally in +`trusted-server.integration.toml` or change diagnostics stub assumptions. + +- [x] Add a fictional React component that owns its element and native GPT slot. + It performs GPT setup after actual component mount/commit, captures generation + before queued work, registers before display and does no competing initial + publisher auction. Make cancellation and destroy-before-removal explicit in + its parent-controlled teardown path and verify call/DOM ordering. +- [x] Offer deterministic test controls for delayed mount, pre-registration ID + change, responsive narrowing, pending cancellation and navigation. These are + fixture controls, not production readiness heuristics. Include a failed/delayed + hydration case in which no registration occurs and TS must not guess readiness. +- [x] Implement the publisher's own synchronous effective-mapping/visibility + subscriptions. If eligibility changes while waiting for payload or hidden, + cancel before replacing the registration snapshot, without waiting for a + passive React effect. Resize in both pending states and force old callbacks + to run after the new token exists. Assert one request with current eligible + targeting, no stale winner, and no post-dispatch resize reopening. +- [x] Exercise registered delivery before and after automatic initialization and + a registered-only page. Confirm exact render correlation and next-navigation + targeting cleanup survive all orders, including an ID outside the config prefix. +- [x] Isolate the runtime settings in the new app-config fixture. Select one + registered slot and one automatic/unrelated control on only the fictional + fixture route. Enable GPT and the required existing auction profile there. +- [x] Reuse Task 0's deterministic Next.js provider route and its passed transport + preflight. Keep the application endpoint canonical HTTPS and use the generated + static-name mappings for every reachable transport budget. Extend legal + win/no-bid responses for this task's cases without changing production profile + validation or allowing external provider traffic. +- [x] Generate an isolated Viceroy config by invoking the existing + `generate-viceroy-config` binary with `--app-config` pointing to the new fixture, + `--template` the existing Viceroy template, `--origin-url` the fixture origin, + and a separate output under `target/integration-test-artifacts/publisher-registration/`. + Append the verified static backend mappings from Task 0's native helper and + assert the configured fixture port and runtime pin match the passed preflight. + Do not overwrite the normal integration config or an operator config. +- [x] Use the existing global setup/teardown with `TEST_FRAMEWORK=nextjs` and + `VICEROY_CONFIG_PATH` for this isolated runtime. Restrict the new Playwright + configuration to the new spec and guard that spec outside its dedicated run, + so normal Next.js/WordPress jobs keep their existing settings and expectations. +- [x] The launcher builds the required artifacts/images through existing build + paths, prepares the custom config and passes its location to Playwright. Reuse + dependencies where valid; each launched container/process must be cleaned by + its own recorded ID, not a global browser/container kill. +- [x] Inspect the real transformed response and body seams. Assert the wire mode, + unchanged script ordering, current admitted slots/bids, gated empty responses, + successful winner targeting and SPA settlement under actual React transitions. +- [x] Install only the transport GPT stub for this local framework suite. Serve + the actual configured external Prebid artifact through Playwright routing; + do not use an inert stub to claim Prebid initialization or consent passed. +- [x] Assert final native slot/element objects, visible iframe creative text, no + early fallback requests, one intended request after registration, later refresh, + and correct teardown. Test inline and shared-template admission with explicitly + selected fixture assembly settings, not just handwritten JS payloads. +- [x] If the framework/runtime cannot supply deterministic local admitted payloads + without unrelated infrastructure changes, stop and review the test approach. + Do not replace this gate with manual `tsjs.bids` assignment and call it Rust + end-to-end coverage. + +**Command to implement and document:** + +```sh +./scripts/test-publisher-registration.sh +``` + +The script must expose reuse inputs for prebuilt WASM/config/artifacts so CI can +reuse its prepared outputs instead of rebuilding the workspace twice. Browser +runs using the shared fixture port/state must be serialized. + +**Check:** both the real framework lifecycle and actual server-generated injection +are exercised. Offline direct-payload tests alone cannot clear this gate. + +## Task 9. Wire CI, document the publisher adapter and run complete checks + +**Modify/create:** + +- `.github/workflows/integration-tests.yml` +- `crates/trusted-server-integration-tests/browser/package.json` +- `crates/trusted-server-integration-tests/browser/publisher-registration/README.md` +- `docs/guide/integrations/gpt.md` +- `docs/guide/configuration.md`, in its `[creative_opportunities]` slot reference. +- `trusted-server.example.toml` only if a commented fictional opt-in example fits + the existing structure; never enable the mode by default. + +- [x] Add standalone fixture tests and the fixed offline runner after the existing + build/install steps. Reuse the manifest-verified Prebid artifact when possible. +- [x] Add the dedicated framework step after existing normal browser runs, with + its own isolated app config and recorded process ownership. Keep the normal + framework matrix in place. +- [x] Upload new traces, screenshots, request/render records and artifact hashes + with `if: always()`. A failed assertion or browser startup must fail the job; + uploaded artifacts are evidence, not an alternative pass condition. +- [x] Document configuration, required disabled-load setup before enableServices, + native-slot registration, status/cancellation, generation capture, finite-size + eligibility and pending breakpoint updates, single initial owner, + service/teardown responsibilities and ordinary-GAM bootstrap degradation. + State explicitly that the bootstrap gives up TS bid participation when no + renderer is installed and that late runtime arrival never replaces that ad. Show a complete fictional React integration with explicit + destroy-before-removal orchestration and no extra initial publisher display. +- [x] Document that missing payload/consent admission is not a permission to start, + unknown dimensions can omit a TS offer while GAM still fills, and every rejected + status is not a safe automatic publisher retry signal. +- [x] Document protocol-version and artifact pairing before activation, no mid-page + ownership switch, rollback of a compatible server/adapter/config pair, and the + remaining real-GPT gate. Do not promise that this opt-in fixes unmodified sites. + +**Focused integration checkpoint:** + +```sh +(cd crates/trusted-server-js/lib && npx vitest run \ + test/integrations/gpt/publisher_registration.test.ts \ + test/integrations/gpt/ad_init.test.ts \ + test/integrations/gpt/gpt_bootstrap.test.ts \ + test/integrations/gpt/schedule_initial_ad_init.test.ts \ + test/integrations/gpt/spa_hook.test.ts \ + test/integrations/prebid/index.test.ts) +(cd crates/trusted-server-js/lib && npm run lint && npm run format && node build-all.mjs) +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-final +./scripts/test-publisher-registration.sh +``` + +**Before PR handoff, run the complete current gates from `AGENTS.md`:** + +```sh +cargo fmt --all -- --check +cargo test-fastly +cargo test-fastly-reuse +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +cargo test -p trusted-server-core --test template_build_digest +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo clippy-cli +cargo clippy-codegen +cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings +(cd crates/trusted-server-js/lib && npx vitest run && npm run format && node build-all.mjs) +(cd docs && npm ci && npm run format) +docs/node_modules/.bin/prettier --config docs/.prettierrc --check \ + '*.md' '.claude/**/*.md' '.github/**/*.md' 'crates/**/*.md' \ + 'scripts/**/*.md' 'tinybird/**/*.md' +scripts/lint-wasm-clock.sh +git diff --check +``` + +Also run the old initial-render browser runner and existing normal browser matrix +as required by the changed integration workflow. Keep the validation record compact: +command, tested commit/dirty-file fingerprints, environment, elapsed time, outcome +and log. Do not rerun unchanged passing units merely because a different worker +ran them. Reproduce failures on the clean base before classifying them as unrelated; +exclusions remain disclosed coverage gaps, not a green full suite. + +**Check:** CI and local evidence refer to the exact combined implementation and +built assets, not an earlier protocol or a different worktree's distribution. + +## Task 10. Review and real-GPT staging gate + +No deployment is authorized by this plan. Obtain separate staging approval and +publisher-adapter access before this task's external actions. + +- [x] Review source, fixtures and documentation together for shared-state drift, + post-cancel retries, stale generation adoption, hidden broad refresh, accidental + service-policy changes, candidate resurrection in the bridge, and TS destruction + of publisher-native slots. +- [ ] Confirm the intended live publisher adapter can actually call the API after + its final native definition and before its first display/auction. The repository + example alone does not deploy that integration. +- [ ] Stage the exact reviewed release with its matched HTML bootstrap, selected + module hashes, publisher adapter and per-placement setting. Retain deployment + mapping to the source commit. Do not enable the new field on an old artifact. +- [ ] Capture one affected placement plus an unrelated control using real GPT/GAM. + With the new mode, require zero requests from the SSR placeholder, one initial + request for the final slot, eligible targeting on that request and a visible + creative in that same live element when GAM supplies a fill. Include a + no-TS-bid fill, renderer-unavailable ordinary-GAM fill and pending/hidden + responsive narrowing case; late renderer installation must not replace the + initial ordinary fill. Distinguish per-slot request events from SRA HTTP batches. +- [ ] Issue a separately controlled legitimate refresh and verify one additional + request without affecting the unrelated slot. Verify teardown before removal. +- [ ] Record exact observation windows and errors. If trace tools fail, disclose + missing performance proof; do not convert a failed trace into CWV measurements. +- [ ] Verify rollback by stopping new opt-in assignments and restoring a compatible + release pair. Do not rewrite ownership inside already-open documents. +- [x] If real-GPT validation cannot run, keep the live-site fix claim blocked. + The user may explicitly choose to merge a default-off API/tooling feature first, + but that is not a production-verified fix or grounds to close all of #854. + +## Completion record + +Before proposing a PR, append a concise execution record to this plan or a linked +review note with: + +- Exact changed behavior and supported publisher lifecycle. +- Config/API/protocol compatibility decisions and any reviewed deviations. +- Old failure evidence and new one-request plus visible-creative evidence. +- Focused, full-suite, framework and real-GPT checks with tested state and logs. +- Any unverified adoption, real-GAM, CI or performance claims. + +Tasks remain unchecked until their implementation and verification are complete. +The execution record below follows the original documentation approval. + +### 2026-10-27: Authorized; blocked at transport preflight + +- The user authorized implementation and opening a draft PR when done. Production + deployment, activation and real-GPT staging remain outside that authorization. +- Shared setup attempted `npm ci` against this worktree's copied package manifests + in `/tmp/ts-854-implementation/js-deps`. It failed with `EDQUOT`. Browser dependency + installation, asset builds and baseline tests were not reached. The incomplete + installation was removed; the failure log remains at + `/tmp/ts-854-implementation/evidence/npm-js.log`. +- Task 0's native backend-mapping example, isolated fixture TOML, Next.js auction + route and transport-smoke launcher are drafted. Scoped Rust formatting, route + formatting, shell syntax and `git diff --check` pass. They have not been compiled + or exercised through Viceroy; no transport or provider-admission pass is claimed. +- `/tmp` has filesystem free space but the user's tmpfs quota was exhausted. + Removing only the failed installation recovered about 26 MB, insufficient for + builds. The root filesystem also has only about 1.4 GB free. Other projects' + caches have not been removed. The user has been asked to free roughly 8 GB of + `/tmp` quota or increase it. +- No runtime feature files changed. No commits, pushes or PR creation occurred. + Resume shared setup and the actual full/reduced-budget transport preflight + before starting Tasks 2 through 7. + +### 2026-10-27: Quota resolved; baseline passed + +With explicit user permission, two inactive Cargo caches were removed, recovering +about 8.2 GB. Shared setup now passes. The clean-base full JS suite passed all +1,211 tests, the focused GPT suites passed 215 tests, and all ten existing +initial-render browser scenarios passed. Task 0 compilation and runtime smoke +are in progress; feature delivery work remains gated on that smoke. Detailed +commands, source fingerprint and evidence paths are in the linked validation +record above. + +### 2026-10-27: Implementation and final V4 local verification complete + +- The opt-in schema, publisher registration API, authoritative payload join, + renderer eligibility, shared mapping ownership and bootstrap/runtime protocol + are implemented. Automatic configuration remains the default. Publishers own + final native definition, disabled-load setup, eligibility updates and teardown. +- Real-history regressions found and fixed a bootstrap-only stale-admission gap + and a catch-up retry generation bump. The final common contract preserves the + original waiting handle/token/native through same-generation retries. The + bootstrap has no second auction client. +- Final V4 passed 1,490 full JS tests, 794 focused instances, 11 pure fixture tests, + 109 offline browser cases, ten legacy scenarios, 26 actual Rust/Next.js cases + and all 19 Rust commands. Normal Next.js passed 21 tests and WordPress passed + 11, with only the documented dedicated/other-framework skips. +- Actual build.rs WASM embedding matched all 13 independently built module byte + sequences. Preparation ran without Viceroy installed in PATH; strict reuse + captured zero Cargo/Docker builds. Normal CI's distinct nine-user-ID Prebid + profile was built and verified separately from the feature's pinned profile. +- Four independent review lanes covered the exact 32-file target. There are no + blocking findings. R1, a repeated test-artifact verification helper, remains + unchanged as nonblocking maintenance. Source fingerprints stayed unchanged + throughout final artifact checks and review. +- Two nonfatal pinned-Viceroy downstream receiver task panics appeared in the + otherwise passing normal Next.js run. They remain unclassified because a + clean-base reproduction was not performed. The validation record preserves + the logs and exact expected skips rather than claiming a warning-free run. +- Hosted CI, real GPT/GAM, publisher adoption, production activation and CWV + validation remain unverified. A draft PR is authorized; deployment is not. + This is not grounds to close the whole of #854. + +The linked validation and combined implementation review contain exact hashes, +commands, durations, retained failures and remaining limits. 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..037cb8d4c0 --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-854-client-cwv-completion-plan.md @@ -0,0 +1,92 @@ +# Issue 854 measurement, implementation and validation plan + +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 + +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. + +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 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 + +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-27-854-publisher-registered-initial-delivery-implementation-review.md b/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-implementation-review.md new file mode 100644 index 0000000000..d1fd4aef07 --- /dev/null +++ b/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-implementation-review.md @@ -0,0 +1,80 @@ +# Publisher-registered initial delivery implementation review + +## Target and coverage + +Reviewed the implementation, fixtures, CI and operator guides against +`5bc38862512c5c71085b5052bc3e528ee9b9987d`. All four independent read-only lanes +inspected the same 32 paths, including the complete contents of 17 untracked +files. Each reviewer verified this fingerprint before and after inspection: + +`669bfc753a5b4360f856adfa0a38dbecb7c097c49846048f13c3dee58916ae42` + +The coordinator recomputed it after collecting the reports. Historical planning +and review records were excluded from this implementation fingerprint. The +approved spec and plan supplied intent. No reviewer changed source or ran Cargo, +builds or browsers. + +Correctness, architecture and style reported no findings. The coordinator +verified the one code-smell candidate against all three cited implementations. + +## R1. Repeated Prebid artifact byte verification + +- Kind: maintenance, code-smells lane. Low priority and nonblocking. +- Issue: the offline runner, framework spec and launcher separately read the + manifest and verify the selected bundle's SHA-256 and SHA-384 SRI. A future + verification-rule change needs three updates. +- Evidence: `browser/publisher-registration/run.cjs:57-100`, + `browser/tests/nextjs/publisher-registration.spec.ts:148-156`, and + `scripts/test-publisher-registration.sh:165-181`. Browser paths are relative to + `crates/trusted-server-integration-tests/`. +- Recommendation: a small pure CommonJS helper could share byte verification, + while callers retain their different schema, filename and module policies. +- Disposition: leave unchanged in this PR. There is no current verification or + delivery defect. A shared helper adds a dependency across three test entrypoints + and is not required for the requested ownership fix. This remains a follow-up, + not a reason to weaken or skip any check. + +## Safety evidence and limits + +The final shared contract passed 794 tests with zero skips on matching source and +test hashes. It exercises exact native/element ownership, consumption before +mutation, cancelled/stale callbacks, candidate exclusion, real history changes, +one navigation handler, current-generation catch-up and same-generation retry. +The coordinator's original raw-browser probe now leaves B at generation one, +waiting for payload with zero native requests. A late renderer cannot restore an +excluded consumed TS candidate through the retained bid map. + +The architecture review confirmed the bootstrap has no second page-bids client. +Runtime takes over the existing history bridge and fetch/apply operation without +another generation increment. Record-derived mappings retain their ownership +rules; provider loopback overrides remain isolated to test configuration. The +public contract requires known disabled initial loading and publisher-owned +native destruction. + +Pre-runtime navigation follows this sequence: + +```mermaid +flowchart TD + B[Bootstrap observes changed pathname] --> G[Advance generation and retire old admission] + G --> W[Current registration waits without fetching] + W --> H[Runtime takes over the same history bridge] + H --> F[Existing page-bids fetch at captured generation] + F --> R{Current response accepted?} + R -->|No| U[Keep waiting without native delivery] + U -->|Later same-path retry| F + R -->|Yes| A{Payload admits this placement?} + A -->|No| I[Inactive without native delivery] + A -->|Yes| D[Validate identities and consume once] +``` + +Style checks passed for the selected docs, workflow, fixture code, shell, Rust +example and JavaScript syntax. Current scoped lint/format evidence was reused +only after checking its input hashes. Source inspection and local contract tests +do not prove real GPT/GAM behavior or production adoption. + +Full command results, built-artifact browser/framework checks, Rust gates and +remaining external limits belong in the linked +[validation record](2026-10-27-854-publisher-registered-initial-delivery-validation.md). +The external detailed lane reports are under +`/tmp/ts-854-implementation/implementation-review-*.md`. No hosted CI, +production deployment, real-GPT staging or CWV result is implied by this review. diff --git a/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-rereview.md b/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-rereview.md new file mode 100644 index 0000000000..6b0ae41b01 --- /dev/null +++ b/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-rereview.md @@ -0,0 +1,129 @@ +# Publisher registration specification and plan re-review + +Reviewer: `openai-codex/gpt-6.1-sol`, independent read-only worker. + +Coordinator disposition: accepted. A1 through A4 are resolved in the design and +implementation instructions. This approves the documents only, not runtime +implementation, deployment or production activation. + +Documents: [specification](../specs/2026-10-27-854-publisher-registered-initial-delivery-design.md) +and [implementation plan](../plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md). + +Status: complete. Verdict: approve the revised specification and implementation plan. This is documentation approval, not authorization to implement, deploy or activate runtime behavior. + +No new architecture or execution-plan findings. No B IDs assigned. A1 through A4 are resolved at the specification/plan level; none is claimed implemented or runtime-proven. + +## Target and authority + +Worktree branch: `issue-854-publisher-slot-registration`. +HEAD: `5bc38862512c5c71085b5052bc3e528ee9b9987d`. +Both complete untracked documents were read, not just their revisions: + +- `docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md`, 542 lines, called `spec` below. +- `docs/superpowers/plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md`, 756 lines, called `plan` below. + +Before/after fingerprint: `12d996037dce8479aaa2e7b13e259b4460c7f2b6a6b95480bb36459e9d5747fe`. Both checks matched the assignment and `/tmp/ts-854-planning-review-v2-target.txt`. + +Review executed directly as the assigned worker, without delegation. Read the historical R1 review completely, current `AGENTS.md` and `AGENTS.override.md`, required worker/target/reviewer instructions, engineering references and the earlier slot-handoff specification. Only this report was deliberately written. No repository edits, installs, builds, tests, formatting, browser actions, real provider requests or commits occurred. + +## Resolution of the first review + +### A1. Resolved: registration records own registered mapping entries + +`spec:383-402` makes current-generation registration records the source of registered `divToSlotId` and `prevSlotTargetingKeys` entries. Consumed and cancelled-after-dispatch records retain necessary cleanup/correlation. Automatic passes replace only automatic entries; registered dispatch changes only its entries. No second mutable registered-mapping registry is introduced, and native destruction remains outside TS ownership. + +`spec:285-296` and `395-402` require old-generation snapshots before record retirement, deferred cleanup ordered before new delivery, and no merge from old records. `plan:375-389` explicitly carries this into both implementations and requires registered-before-auto, auto-before-registered, registered-only, mixed-SPA, outside-prefix-ID and deferred-cleanup regressions. The framework repetition is at `plan:568-570`. + +This addresses the actual source boundary. `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:1277-1279,1412-1415` currently constructs and replaces whole maps; `crates/trusted-server-core/src/integrations/gpt_bootstrap.js:884-885` also replaces them. Runtime cleanup at `index.ts:1074-1108` uses those maps, while exact renderer lookup at `index.ts:167-224` depends on their retained identity. The revised ownership rule removes ordering dependence without delaying registered delivery behind automatic DOM work. + +### A2. Resolved: ordinary GAM without TS demand when the real renderer is absent + +`spec:360-379` now chooses the approved policy. Runtime publishes `tsRenderBridgeVersion === 1` only after installing the real handler. Bootstrap never advertises or implements that renderer. An absent capability suppresses TS candidate targeting and candidate/render correlation, then allows one ordinary scoped GAM request. GAM fill or explicit no-fill is honest acceptance; guaranteed fill is not promised. Late runtime arrival cannot retarget, refresh or recover the excluded candidate from retained bids. A registration still pending at handler installation can use the candidate at its eventual dispatch. + +`plan:390-411` requires actual-handler installation, idempotence, owned candidate-key cleanup and a renderer guard against the retained bid map. `plan:451-464` requires ordinary publisher creative content without a bridge, no late resurrection, and both sides of the pending/consumed boundary. `plan:513-519` keeps this separate from Universal Creative proof. + +The policy matches source responsibilities. `crates/trusted-server-core/src/integrations/gpt.rs:479-484` puts creative delivery in the normal JS bridge. Bootstrap `gpt_bootstrap.js:863-866` records correlation for that future bundle, not a renderer. Runtime `index.ts:1954-1970` installs the message listener; `2079-2090` resolves a candidate directly from global bids, and `2157-2210` can return inline ADM and fire beacons. The plan explicitly requires changing that recovery path for excluded registered candidates rather than treating a synthetic marker as a responder. Existing automatic/APS behavior is deliberately preserved. + +### A3. Resolved: source-supported static-name transport, with mandatory future preflight + +`spec:509-524` and `plan:76-99` choose isolated Viceroy 0.17.0 static backend configuration. The application keeps `https://auction.example.com/api/fixture-auction` and certificate checking. Only generated test configuration maps its predicted backend names to a plaintext loopback origin with no duplicated path prefix. + +`plan:105-125` requires reconstruction of the production provider spec from public compiled fields and use of the shared `BackendNamingPolicy::Fastly` prediction/canonicalization policy. It covers every reachable positive reduced-budget bucket as well as the configured timeout, not one copied hash or assumed budget. `plan:127-140` requires an actual baseline-WASM Rust provider request, intended local route, winner admission, observed backend evidence and reduced-budget coverage before delivery implementation. Failure stops dependent work rather than weakening validation. `plan:574-585` reuses this arrangement at the framework gate. + +I independently inspected this source chain: + +- `crates/trusted-server-core/src/auction/plan.rs:565-582` enforces HTTPS. `376-400` builds the backend specification with endpoint host/port, certificate checking, provider discriminator and both request-local timers. Public compiled provider fields are at `243-258`; the public endpoint string accessor is at `208-215`. A test helper need not export a private production backend constructor. +- `crates/trusted-server-core/src/platform/backend_naming.rs:79-103,352-420,482-501` provides shared prediction and bucket policy. `crates/trusted-server-core/src/auction/orchestrator.rs:454-477` shows why reduced request-local timers must be mapped. +- `crates/trusted-server-adapter-fastly/src/backend.rs:192-197,222-275` predicts the same name, attempts registration and reuses the existing name on the SDK's NameInUse message. +- Installed Fastly SDK 0.12.1, `fastly-0.12.1/src/backend/builder.rs:81-96`, maps ERROR to NameInUse with the matching "already in use" display text. Its `361-364` documents using the pre-existing backend after that result. +- Pinned upstream `https://github.com/fastly/Viceroy/blob/v0.17.0/src/session.rs#L625-L655` looks up static entries and rejects static/dynamic name collisions. `src/wiggle_abi/req_impl.rs:555-574` converts that collision to BackendNameRegistryError. `src/error.rs:216-247` maps it to ERROR. +- Pinned `src/upstream.rs:129-169` connects using the backend URI and its scheme; `275-314` builds the request URI using that scheme and combines the backend base path with the original request path. `src/config/backends.rs:101-110` parses the static backend URL. Thus a root loopback HTTP backend can retain the provider request path without requiring a production HTTP exception. +- The existing plaintext fixture is confirmed by `crates/trusted-server-integration-tests/browser/helpers/infra.ts:64` and `fixtures/frameworks/nextjs/Dockerfile:42`. The generator accepts isolated app-config/template/origin/output inputs at `src/bin/generate-viceroy-config.rs:60-100,124-143`; appending named local backends does not require changing its production validation. + +These are Source/Path evidence, not executed runtime success. Task 0 honestly says so and makes the missing proof a preflight stop condition. Missing execution today is residual risk, not an unresolved A3 or new finding. + +### A4. Resolved: synchronous publisher-owned eligibility replacement before dispatch + +`spec:161-167,185-188` requires the adapter to keep pending eligibility current, synchronously cancel/re-register from its own mapping/visibility handlers, and not rely on a later passive React effect. Set-equivalent reorderings remain idempotent. `spec:404-425` requires per-record token checks, stale callback/handle rejection, and a consumed boundary after dispatch. TS gains neither viewport polling nor another responsive size engine. + +`plan:279-284,334-339` covers unchanged native/element identity with changed eligibility, waiting-payload and hidden-document replacement, forced old callbacks and old-handle cancellation, one eventual current-eligibility request and no reopening after dispatch. `plan:562-567` requires the actual framework adapter and corresponding lifecycle tests. + +This is consistent with the source timing problem. Runtime scheduler `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:802-824` admits then defers work through load/frames; SPA `1699-1734` joins asynchronous route data. Existing physical-element claims can be pruned after identity loss at `crates/trusted-server-js/lib/src/core/first_impression.ts:53-69`, so the planned logical consumed marker and separate record tokens remain necessary. The revisions assign responsiveness to the publisher that owns the mapping, not to TS. + +## Whole-plan assessment and coverage + +The opt-in API remains a narrow solution to a publisher-readiness race. It does not claim that load, frame count, temporary DOM stability or size-array aliasing proves readiness. Accepted payload and registration join through existing navigation identity; no second fetch scheduler is added. Native object/element identity, logical consumed identity, cancellation and renderer selection have distinct responsibilities. + +Inspected configuration/wire construction, runtime and inline automatic delivery, navigation cleanup, first-impression claims/pruning, creative source-frame lookup and bridge delivery, initial-load detection/scheduling, and Prebid initial-refresh bypass. Source `crates/trusted-server-js/lib/src/integrations/prebid/index.ts:2660-2676` supports the proposed scoped initial bypass while preserving later auctions. Read existing bootstrap execution and scheduler test mechanisms and the offline runner's real Prebid/PUC setup. Inspected framework infra/setup/teardown, isolated config generation/template, fixture dependencies, Cargo aliases, JS scripts and browser CI wiring for plan feasibility. Proposed new files/commands are future implementation artifacts, not checks available or passed now. + +The larger fixture workload is justified by the actual risk: native request counts alone cannot prove a compatible visible creative or framework element lifetime. The plan keeps transport, deterministic delivery, actual Rust/framework injection and real-GPT evidence separate. Task 0 is correctly ahead of dependent delivery implementation. No new provider implementation, production certificate bypass, bootstrap renderer or build-system migration is required. + +External publisher adoption is explicitly outside this repository at `spec:213-216`. Rollout pairs protocol/artifacts/configuration and forbids mid-document ownership switching at `spec:447-462`. `plan:712-743` requires separate staging approval, verifies actual adapter adoption before the first display/auction, and blocks a live-site fix claim when real-GPT validation is unavailable. A default-off merge is not misrepresented as a deployed fix or closure of all #854 work. Runtime implementation remains unapproved under `plan:3-5,755-756`. + +## Checks and outcomes + +Only read-only inspection and integrity checks ran. No implementation tests or runtime checks were run or reused as proof. + +Exact integrity command, run before and after inspection: + +```sh +{ git rev-parse HEAD; git status --short -- docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md docs/superpowers/plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md; sha256sum docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md docs/superpowers/plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md; } | sha256sum +``` + +Result both times: expected fingerprint. The final check completed at `2026-10-27T15:56:24-05:00`, elapsed 0.1 seconds for the combined fingerprint/time/status command. Whole review reading time was not separately timed. + +Other inspection used `read`, `fffind`, `ffgrep` and narrow `rg` queries. `wc -l` confirmed 542/756 lines. `git diff --stat` and `git diff --cached --stat` showed no tracked changes. Final status retained the two untracked target files and historical untracked reviews directory. + +Pinned public upstream retrieval, all successful, used this command separately for each of `src/session.rs`, `src/wiggle_abi/req_impl.rs`, `src/error.rs`, `src/upstream.rs`, `src/config/backends.rs`: + +```sh +curl -fsSL --max-time 30 https://raw.githubusercontent.com/fastly/Viceroy/v0.17.0/ # allow-domain: raw.githubusercontent.com +``` + +No requests went to an ad provider. Initial source discovery tried a nonexistent `platform/backend.rs` and a nonexistent local Viceroy registry source directory; corrected to `platform/backend_naming.rs` and the public pinned upstream. Those were lookup misses, not behavioral check failures. + +## Residual risk and next checks + +1. Static-name reuse must actually carry a real Rust provider win to the local Next.js route across full/reduced transport budgets. Highest evidence here: Source/Path, unproven by execution. Next check: Task 0's native helper tests and `./scripts/test-publisher-registration.sh --transport-smoke`. Do not begin dependent delivery implementation if that gate fails. +2. One admitted initial delivery must preserve identity, eligible targeting and actual creative content across mixed mapping writers, cancellation/hidden responsiveness and bootstrap/runtime handoff, with no excluded-candidate resurrection. Highest evidence here: Source/Path, unproven by execution. Next checks: the planned parity/unit/browser/framework gates, followed by separately approved real-GPT staging and external adapter adoption. + +These are explicit implementation/deployment gates, not reasons to reject a source-supported plan before it is authorized for execution. No blocking questions remain for this review. Parent retains edits, checks and authorization decisions. + +## Coordinator closeout + +After the full review, the coordinator changed only the two Status paragraphs to +link this persisted review, mark the documents re-reviewed and retain the explicit +prohibition on runtime implementation. The reviewer acknowledged that follow-up, +read both paragraphs and independently confirmed unchanged document bodies after +omitting the Status paragraph with the recorded normalization. + +Final two-file fingerprint: `a71d53e7e5f0ba71960eee504095c542492e1b3909598ae16b9f744c6d38bfbf`. +The full reviewed-design fingerprint above remains the record of the original +re-review target; the final fingerprint includes only this status metadata change. +Documentation approval stands, with no new findings or unresolved A1-A4 decisions. + +Prettier, local Markdown links, untracked-document whitespace and `git diff --check` +passed. Only the specification, plan and two review records are present as worktree +changes. No runtime files, production configuration or original-checkout changes +were made. No implementation tests or provider smoke ran during this revision. +The future transport, parity/framework and real-GPT/adoption gates remain required. diff --git a/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-review.md b/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-review.md new file mode 100644 index 0000000000..7eec45e983 --- /dev/null +++ b/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-review.md @@ -0,0 +1,113 @@ +# Publisher registration specification and plan review + +Reviewer: `openai-codex/gpt-6.1-sol`, one independent read-only worker. + +Historical first-review snapshot. Its line references and fingerprint describe +the original draft, not the revised documents. The coordinator verified all four +findings, and the user approved addressing A1 through A4, including ordinary-GAM +fallback for A2. The specification and plan have now been revised for re-review. +This record preserves the original findings and does not approve runtime +implementation; resolution requires the separate second-review result. + +Documents: [specification](../specs/2026-10-27-854-publisher-registered-initial-delivery-design.md) +and [implementation plan](../plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md). + +Status: complete. Four plan/specification findings. Request changes before runtime implementation. + +Target: the two complete untracked files below, at HEAD `5bc38862512c5c71085b5052bc3e528ee9b9987d` in this worktree. + +- `docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md`, abbreviated `spec` below. +- `docs/superpowers/plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md`, abbreviated `plan` below. + +Before/after fingerprint: `8a445ee3a3281b61388d6cb2a0107331304c909008f60a5b611c1ece08e44bce`. Matches the assignment and `/tmp/ts-854-planning-review-target.txt`. No repository files changed. Review executed locally as the assigned architecture worker, without delegation. + +## Verdict on the overall shape + +Explicit registration is a reasonable smallest readiness contract for the stated problem. A slot configuration flag is necessary to stop automatic delivery before the publisher component exists. An exact native object and element avoid pretending an old GPT object can bind to a replacement div. Joining registration with the existing admitted payload and navigation generation is preferable to another fetch or hydration scheduler. + +Disabled-load-only v1 is a defensible restriction, given the stated SRA constraint and the assignment's evidence that the affected publisher already disables initial loading. It deliberately gives up enabled-load adoption rather than changing global publisher policy. I would not expand v1 to enabled load. The effective getter/legacy observation and real scoped-SRA behavior still need execution and real-GPT proof; inspection here did not independently validate Google's documentation or the prior live observations. + +This is an opt-in API and publisher-adapter project, not a transparent fix for unmodified sites. The specification correctly says so. Confirming that the actual publisher can register before its first display/auction is a deployment blocker. Its current disabled-load setting alone does not establish adoption feasibility. Keeping the real-GPT gate and separate approvals is appropriate. Nothing reviewed supports closing all CWV work in #854. + +## Findings + +### A1. Assign ownership of shared mapping writes before allowing registered delivery to precede automatic initialization + +- Issue: `plan:267-269` tells registered delivery to reuse `divToSlotId` and targeting cleanup. `spec:259-261` allows payload admission as soon as the scheduler accepts it, while automatic initialization retains its later load/frame schedule. Thus a registered delivery can populate the shared maps before that automatic pass. The plan requires skipping registered slots in the automatic loop, but never changes that loop's whole-map replacement contract. Registered-only pages have the same problem when the scheduled automatic pass produces empty maps. +- Evidence: runtime `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:1277-1279` creates fresh maps, and `:1412-1415` replaces the shared maps. Bootstrap does the same at `crates/trusted-server-core/src/integrations/gpt_bootstrap.js:884-885`. Navigation cleanup derives every touched element and configured targeting key from those maps at runtime `index.ts:1091-1108`. Renderer lookup uses the exact mapping plus a configured-prefix fallback at `index.ts:167-224`. +- Consequence: the later automatic pass can erase a registered slot's exact render correlation and targeting-cleanup record. A final publisher ID outside the configured prefix is accepted by the specified API but then has no renderer lookup fallback. Even when the prefix still resolves, navigation can leave stale TS targeting on a publisher-owned slot because its cleanup entry was erased. Correctness would depend on which drain ran last. +- Scope: both initializers, the registration dispatcher, render correlation and navigation cleanup. +- Intent and contracts: preserve independent automatic delivery and exact registered identity without assigning publisher slots to `prevGptSlots` destruction ownership. +- Proposed change: state that an automatic pass replaces only automatic entries and retains current-generation registered entries. Use the registration records to derive those entries or preserve the explicitly tagged subset. Navigation still clears both. Require tests for registered dispatch before automatic frames, the reverse order, a registered-only page, a mixed SPA route and subsequent targeting cleanup. Do not fix this by requiring registered delivery to wait for unrelated automatic DOM resolution. +- Tradeoff: automatic initialization no longer owns whole-map replacement on opted-in pages. No new public API or second registry is required. +- Severity: P2, a concrete shared-state ordering gap to resolve in the plan before coding. + +### A2. Choose a bootstrap-only creative policy instead of leaving a known unrenderable candidate as a labeled limitation + +- Issue: `plan:313-325` requires matching candidate filtering/scoped delivery in bootstrap-only mode and functional GAM delivery when runtime fails, but treats the missing TS creative bridge as a limitation to label. `spec:310-324` otherwise applies eligible TS bid targeting unconditionally. The documents do not decide what bootstrap does with a qualifying TS candidate whose inline creative can only be served by that missing bridge. +- Evidence: `crates/trusted-server-core/src/integrations/gpt.rs:479-484` explicitly locates the bridge in the JS bundle. Bootstrap `gpt_bootstrap.js:863-866` likewise records maps for a future bundle rather than serving creatives. Runtime `index.ts:1954-1970`, `:2079-2087`, `:2157-2192` and `:2338` installs the message handler and serves the inline `adm` held in `tsjs.bids`. A PUC request for that server bid has no such responder when the GPT runtime never loads. +- Consequence: in this supported failure case GAM can select the targeted TS line item, consume the one initial delivery, and leave an empty creative. Merely documenting the absent bridge does not reconcile the promised functional fallback with the visible-delivery acceptance at `spec:402-405`. This concerns the newly promised registered path, not a request to repair all existing automatic degradation behavior. +- Scope: bootstrap-only registration delivery, bootstrap-to-runtime adoption and browser acceptance expectations. +- Intent and contracts: keep one initial delivery and meaningful GAM fallback without presenting a count-only blank as success. +- Proposed change: choose the contract explicitly. The smallest implementation option is to omit TS candidate targeting in registered bootstrap-only delivery when its required renderer is unavailable, perform one ordinary scoped GAM request, and preserve consumed ownership when runtime later arrives. Test real ordinary-GAM creative content for this case. If preserving TS demand is required, specify the actual available renderer and prove PUC completion without loading the normal runtime. Do not use a synthetic marker as the responder. +- Tradeoff: the smaller option gives up TS bid participation for deliveries made before a compatible renderer is available. The alternative adds bootstrap rendering responsibility and code. Either choice must be reflected in the parity and acceptance rules. +- Severity: P2, an unresolved supported-path contract rather than missing evidence alone. + +### A3. Task 8's provider endpoint cannot directly use the existing plaintext Next.js fixture + +- Issue: `plan:424-432` requires the existing prebid-server profile and validation, a loopback Next.js API auction route, and the existing Viceroy template. No TLS or test backend mapping is planned. Those pieces do not currently connect as specified. +- Evidence: `crates/trusted-server-core/src/auction/plan.rs:565-582` rejects every provider endpoint whose scheme is not HTTPS. Its backend specification at `:388-397` derives that scheme from the endpoint and sets `certificate_check: true`. Existing framework infra waits on `http://127.0.0.1:${ORIGIN_PORT}` at `crates/trusted-server-integration-tests/browser/helpers/infra.ts:64`. The Next.js Dockerfile runs plain standalone `node server.js` at `fixtures/frameworks/nextjs/Dockerfile:42`, with no TLS setup. The existing Viceroy template has an empty backend table; the generator only injects config stores and optionally overrides publisher origin, not provider transport. +- Consequence: an HTTP loopback endpoint fails configuration compilation; changing only its URL to HTTPS makes the provider attempt TLS against a plaintext server. The required real-Rust winner and targeting gate cannot run through this proposed connection. `[proxy].certificate_check = false` in the existing app fixture does not change the provider's explicit certificate-check field. +- Scope: Task 8 fixture transport, launcher and isolated Viceroy config. +- Intent and contracts: retain the real provider profile and actual Rust admission path without weakening production endpoint validation or assigning bids by hand. +- Proposed change: resolve and source-check a test-only transport arrangement before starting the dependent runtime tasks. For example, investigate whether the Fastly/Viceroy adapter can use a test static backend mapping for a canonical HTTPS provider endpoint to the plaintext loopback fixture. If not, specify a local TLS endpoint and how provider certificate validation will succeed, or review a different real-provider test arrangement. Add the required files/process ownership and a config-plus-provider smoke check to Task 8. Do not leave the first discovery of this incompatibility to the final framework gate. +- Tradeoff: adds a small explicit fixture transport setup. Preserves production validation. Feasibility of the static-backend option is not proven by this review. +- Severity: P2, verified source-level incompatibility in the execution plan. + +### A4. Define how pending registrations handle a breakpoint change + +- Issue: `spec:154-159` defines `eligibleFormats` as the sizes for the selected breakpoint, but registration stores a snapshot and may wait indefinitely for payload or foreground delivery. The publisher preconditions at `spec:165-176` keep the element and ID stable, not the size eligibility. Dispatch checks at `spec:286-297` never revalidate that snapshot against the publisher's current breakpoint. `plan:270-275` filters against the original snapshot; browser narrowing is explicitly before registration at `plan:367-368`. +- Evidence: the documents support both publisher size mapping and registration-before-payload. They also deliberately hold hidden documents until visible at `spec:299-300`. A publisher can therefore register while a tall format is eligible, cross to a narrower mapping while waiting, and dispatch with the old eligibility without violating any stated precondition. +- Consequence: the new compatibility filter can accept the broad-config winner that the publisher's current mapping excludes. That defeats the reason for adding finite publisher eligibility without requiring any element replacement or duplicate registration. +- Scope: the publisher adapter lifecycle, pending cancellation/replacement and responsive verification. +- Intent and contracts: preserve publisher-owned size mapping and avoid turning TS into another responsive-auction engine. +- Proposed change: document that the adapter must cancel and explicitly re-register pending work when its effective eligibility changes, including a viewport change while payload/foreground delivery is pending. Define the boundary after dispatch, where the initial request is consumed and later lifecycle is publisher-owned. Add a deterministic waiting/hidden resize case with cancellation, replacement and old-token rejection. An alternative is a publisher-owned current-eligibility callback, but that expands the API and is not needed if the cancellation contract is workable. +- Tradeoff: the smaller contract requires a responsive publisher adapter to observe its own mapping changes. TS retains only a snapshot and no new size-mapping engine. +- Severity: P2, a missing lifecycle contract exposed by the asynchronous join. + +## Reviewed coverage + +Read both target documents completely, including every task, command block, rollout rule and acceptance criterion. Read repository `AGENTS.md` and `AGENTS.override.md`, required worker/reviewer instructions, engineering references, and the linked earlier slot-handoff design. + +Inspected the relevant source boundaries and existing tests: + +- Configuration and wire construction: `creative_opportunities.rs`, `publisher.rs` initial/seam/`build_slot_json` code, JS `core/types.ts`. +- Delivery: GPT initial-load detector, initial scheduler, changed-ID matcher/wrappers, automatic initializer/fallback, targeting snapshots, source-frame lookup and TS render bridge; bootstrap equivalents. +- Navigation and ownership: SPA accepted-navigation generation, endpoint fallback, automatic DOM wait, shared payload application, `core/first_impression.ts` claim pruning/leases and physical-element identity. +- Prebid: native initial-refresh bypass, publisher claims, scoped subsequent refresh and targeting restoration; existing regression cases for bypass, later auctions, stale navigation and element replacement. +- Existing GPT tests: scheduler acceptance/handoff, SPA generation/empty-response handling, actual bootstrap execution, request/render/bridge tests. These are existing proof locations, not passing results from this review. +- Browser/CI feasibility: initial-render runner, transport fixture, real Prebid/PUC setup and evidence boundaries; Playwright configuration/global setup/teardown, infra helpers, Next.js Dockerfile/config/layout, Viceroy generator/template, integration app configuration and CI workflow. +- Command definitions: JS lint/format/build scripts and Cargo target aliases exist. Proposed new test files and launcher are future artifacts, not claimed available now. The plan already permits locating the authoritative slot configuration guide instead of assuming the suggested path exists. + +No architecture finding against the explicit admission gate, generation identity, consumed logical marker, exact native liveness checks, publisher teardown ownership, default-off rollout, excluded-candidate bridge guard or scoped Prebid bypass as specified. These are requirements to prove, not already-verified implementation behavior. + +## Checks actually run + +Only read-only inspection and target-integrity checks ran. No installs, builds, tests, browser actions, formatting, commits or repository edits. + +The exact integrity check ran before and after inspection: + +```sh +{ git rev-parse HEAD; git status --short -- docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md docs/superpowers/plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md; sha256sum docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md docs/superpowers/plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md; } | sha256sum +``` + +Result both times: expected fingerprint. Final `git status --short` lists only the two approved untracked documents. Inspection used `read`, `fffind`, `ffgrep` and narrow `rg` commands. Final inspection timestamp was `2026-10-27T15:36:59-05:00`; fingerprint command elapsed time reported zero seconds. Overall reading time was not separately timed. + +## Proof level, blockers and next actions + +- Effective disabled loading plus explicit refresh isolation: Source/contract evidence only, unproven by execution here. Next checks are the planned strict SRA fixture and separately approved real-GPT canary. +- One request plus a visible compatible creative through bootstrap/runtime/real Prebid: Source/path inspection only, unproven. A1, A2 and A4 need contract decisions, then executable parity and browser checks on the implemented artifacts. +- Real Rust/framework winner path: Source establishes the HTTPS mismatch in A3. Resolve transport first, then execute the dedicated launcher against actual inline and shared-template injection. +- Publisher adoption, production causality, real GAM selection and performance: deferred external gates. No production publisher source or private captures were inspected, and none were assumed to be present in this checkout. + +Actionable verdict: keep runtime implementation unapproved until the coordinator resolves A1-A4 in the frozen specification/plan through the user's review process. Then implement the opt-in contract with the listed gates. Real-GPT/publisher adoption can remain a deployment blocker for an explicitly approved default-off feature merge, but cannot become a live-site fix claim. diff --git a/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-validation.md b/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-validation.md new file mode 100644 index 0000000000..736e3b49e1 --- /dev/null +++ b/docs/superpowers/reviews/2026-10-27-854-publisher-registered-initial-delivery-validation.md @@ -0,0 +1,263 @@ +# Publisher registration implementation validation + +Status: Implementation in progress. Task 0 transport preflight passed; opt-in delivery implementation and combined verification are underway. +No production deployment, activation or real-GPT staging is authorized. + +[Plan](../plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md) · +[Design](../specs/2026-10-27-854-publisher-registered-initial-delivery-design.md) + +## Environment and evidence + +Base: `5bc38862512c5c71085b5052bc3e528ee9b9987d`. +Worktree: `issue-854-publisher-slot-registration`. +Local logs and generated artifacts: `/tmp/ts-854-implementation/`. + +The user authorized implementation and a draft PR on 2026-10-27. Initial dependency +installation failed with `EDQUOT` because the tmpfs user quota was exhausted. +With subsequent user permission, the coordinator removed two inactive Cargo +caches, recovering about 8.2 GB. Their source snapshots and saved evidence were +retained. Process arguments, environments and memory maps had no references to +the selected caches. + +Dependencies were then installed from this worktree's package manifests into +isolated temporary directories. Ignored local `node_modules` symlinks point there. +No dependencies or experimental source were copied from another checkout. + +Cargo has one command owner and uses `/tmp/ts-854-implementation/target`, +`CARGO_PROFILE_DEV_DEBUG=0`, `CARGO_PROFILE_TEST_DEBUG=0`, `CARGO_INCREMENTAL=0`, +and `CARGO_BUILD_JOBS=2`. Frozen baseline JS is at +`/tmp/ts-854-implementation/baseline-dist`. Artifact reuse must match the source +and bundle fingerprints, not merely an existing filename. + +## Clean-base checks + +All runtime source remained unchanged during these checks. The complete baseline +source listing is `evidence/baseline-source.sha256`; its SHA-256 is +`94c788d8d7604968a7e3e55907ebd1f4b4358408859618b6e9f6627d60499f19`. +Paths below are relative to the temporary evidence directory. JS commands run in +`crates/trusted-server-js/lib` unless stated otherwise. + +| Check | Result | Time | Log | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------- | +| `npm ci` for JS and browser dependencies; Playwright Chromium install | Passed after quota cleanup | Not separately timed | `npm-js.log`, `npm-browser.log`, `chromium-install.log` | +| `node build-all.mjs --out-dir /tmp/ts-854-implementation/baseline-dist` | Passed | Not separately timed | `baseline-build.log` | +| Focused GPT `ad_init`, `gpt_bootstrap`, `schedule_initial_ad_init`, `spa_hook` Vitest suites, `--maxWorkers=2` | 215 passed; no type errors | 5.09 s | `baseline-gpt.log` | +| `npx vitest run --maxWorkers=2` | 1,211 tests in 50 files passed; no type errors | 79.53 s | `baseline-js-full.log` | +| Repository-root `node --test crates/trusted-server-integration-tests/browser/initial-render/pages.test.cjs` | 2 passed | 0.99 s | `baseline-pages.log` | +| Repository-root `node crates/trusted-server-integration-tests/browser/initial-render/run.cjs --out /tmp/ts-854-implementation/evidence/baseline-initial-render` | All 10 runtime/bootstrap scenarios passed, with real Prebid v10.26.0 and visible server/client creative content | 8.8 s including external Prebid build | `baseline-initial-render.log`, `baseline-initial-render/` | + +The prior checkout's broad-suite failures were not reproduced on this clean base. +They are not exclusions for this implementation. + +## Task 0 and regression preparation + +The native mapping helper passed all three tests on the explicit +`x86_64-unknown-linux-gnu` target in 4m45.4s. Command, input hashes and log are under +`evidence/task0/native-helper*`. The source-labeled Next.js fixture image built +in 50.5s. These do not establish a provider-transport pass. + +The first transport launcher built the baseline WASM in 6m20s, then stopped during +application configuration validation because the isolated fixture lacked the +required admin-protection handler. The next attempt reached Viceroy, but the +fixture's epoch-zero TC timestamps correctly failed production consent expiry +checks. Both inputs were fixed only in the test fixture. Failed attempts remain +at `evidence/transport-config-failure/` and `evidence/transport-consent-failure/`. + +The final `--transport-smoke` passed in 15.010s with Viceroy 0.17.0. Each budget +case received two actual Rust OpenRTB POSTs at `/api/fixture-auction`, admitted the +known 300x250 winner and accepted legal no-bid with an empty bid map. Full budget +reused the mapped 1000 ms backend; reduced budget reused the mapped 250 ms backend. +All observed provider names matched the eight shared-policy-generated mappings. +The application endpoint stayed HTTPS and certificate checking stayed enabled. + +The coordinator inspected both `admission.json` records, actual route exchanges, +Viceroy logs, exit-zero `outcome.txt` and final owned-file hashes. Exact commands, +versions and timings are in `evidence/task0/handoff.md`; runtime evidence is under +`evidence/transport/{full,reduced}/`. + +- Baseline WASM SHA-256: `e8435647841fe54d0413ab41253249f182ee18f70f0a84ebc339ca29821833fa`. +- WASM source fingerprint: `e8a77df4c24c8835366cc125006b431aaa544d67354f3a25f798184956e51112`. +- Frozen bundle-set fingerprint: `056f0fd185cf5577a58583a12343e07f0ab3147e9e111057fe3c0208a1d284cc`. + +This is the Task 0 baseline transport pass, not verification of the feature or its +final combined artifacts. The framework run must use updated feature assets and +must disable debug ADM replacement to prove actual Universal Creative rendering. + +The new offline fixture passed eight pure tests, including repeated native call +counting, enabled-load SRA exposure, disabled-load isolation, targeting snapshots, +escaping and refresh registration. Its explicit baseline runner passed all 18 +runtime/bootstrap/handoff controls in 8.658s total case time, retaining traces and +screenshots under `evidence/offline/baseline-refresh-registration/`. Reordered and +narrowed sizes cause a second request. Identical-size aliasing after subtree +replacement has one request but no creative in the final element. That is asserted +as a visibility failure, not accepted as correct delivery. + +The default new-mode runner failed because protocol version 1 is absent, with +failure artifacts at `evidence/offline/pre-feature/`. It does not allow expected +failures to become a fixed-contract pass. The earlier disabled-load exploratory +control failure is retained too; it included a legitimate later Prebid refresh +and was replaced by the intended enabled-load automatic controls. + +## V3 implementation checks and additional history defect + +The coordinator verified the five frozen behavior-source hashes before and after +building all 13 feature bundles and the paired Fastly WASM. The full JS suite +passed 1,415 tests in 51 files with no type errors in 60.37s. Library lint and +formatting, full documentation formatting, outside-docs Markdown formatting and +Actionlint with ShellCheck passed. + +The offline suite passed all 105 fixed cases, with actual Universal Creative, +ordinary publisher creatives, real Prebid first-refresh bypass and later auctions, +identity, size changes, hidden/cancelled work, mapping ownership and stale callback +checks. The existing initial-render suite passed all ten scenarios on the same V3 +artifact hashes. The offline worker's exact commands, hashes and red fixture +correction are recorded under `evidence/offline/`; the final run is +`fixed-v3-adrequest/`. No runtime change was needed for that fixture correction. + +The real Rust/Next.js launcher passed 13 inline and 13 genuine shared-template +cases, with no skips or unexpected/flaky results. Current provider transport passed +again at both timeout budgets, with debug ADM injection disabled. Actual served +JS bytes matched the selected distribution. Evidence is `evidence/framework/` and +`evidence/task8/handoff.md`. The final combined run took 58.498s. The shared request +transport removes routing's synthetic no-cache semantics and fulfills the actual +Rust response unchanged; the strict shared seam and authorized template storage +were observed, not replaced with handwritten browser bids. + +A coordinator probe then found a missing case: with only the raw bootstrap loaded, +a real pathname change after A admission left generation zero unchanged. A fresh +B registration made one ordinary GAM request with A's targeting. The executable +probe and exact result are `bootstrap-history-probe.cjs` and +`evidence/bootstrap-history-probe.json`. The bounded independent architecture +review confirmed this violates current-navigation admission, rather than a +publisher precondition. Its report is `bootstrap-navigation-architecture.md`. + +V4 is authorized to add provisional bootstrap navigation observation, transfer +one counter owner to the runtime, and reuse the existing page-bids path for +late-runtime catch-up without retiring current-generation waiting registrations. +No fetch loop belongs in the bootstrap. V3 passing cases remain useful neighboring +proof but do not clear this newly reproduced defect. + +The full Rust gate matrix ran. Two initial failures were the same introduced test +locator error: `.find("")` matched a comment inside the included head script. +The cfg(test)-only correction uses the actual closing body location. Native GPT, +full Fastly tests, formatting and relevant lint were re-executed successfully; +other passing gates are recorded at `evidence/rust-final/REPORT.md`. Updated +combined-artifact verification remains pending V4. + +## Final V4 source and browser proof + +The final five-source build manifest is +`evidence/registration-source-stable-v4-final.sha256`. Earlier V4 manifests are +historical. Before rebuilding, a second coordinator check found that failed +catch-up followed by a same-path retry incremented the generation and cancelled +valid waiting registrations. Six strengthened assertions reproduced it. The +bounded fix reuses the captured catch-up path and generation through the existing +fetch/apply function, without another transition, cleanup or controller engine. +Eleven targeted checks then passed. Runtime-only legacy retry policy is unchanged. + +All seven focused suites passed 794 tests with zero skips in 34.89s. The common +contract has 68 scenarios across four implementations, plus three bootstrap-start +instances of the returned-A failure case. Nonapplicable runtime-only catch-up is +not declared as a skipped test. The complete current JS suite passed 1,490 tests +in 51 files, with no type errors, in 108.30s. Library lint and formatting passed. +Logs are `registration-v4-retry-focused.log` and `final-v4-js-all.log` under +`evidence/`. + +The original raw-bootstrap history probe now reports generation one, +`waiting_payload` and zero native requests on B. Its V3 source/result remain +preserved; the new result is `evidence/bootstrap-history-probe-v4.json`. +All 109 current offline browser cases passed in 130.301s. They include actual +push/replace/popstate and A-to-B-to-A history, same-token/runtime catch-up, +a forced late B response after C, and a controlled 500 followed by a same-path +retry preserving native, element, token and generation. No page errors occurred. +The one console error is the intentional 500 response. The previous ten-scenario +initial-render suite also passed on the same four loaded V4 artifact hashes, +with no page errors, in 4.928s. Evidence is `evidence/offline/final-v4/`, +`evidence/legacy-final-v4/` and their sibling logs. + +The coordinator checked all five source hashes before and after building 13 new +modules into `feature-dist-v4`. A real release build with `TSJS_PREBUILT_DIR` +unset rebuilt and embedded all 13 modules through `build.rs`; it passed in +5m19.009s. Its immutable WASM is +`final-v4/trusted-server-adapter-fastly.wasm`, SHA-256 +`9935a545f27a22cf63bf81108ed624f604b2ec0c1a0ee12d4e9012b68dae354f`. +Source checks and full bundle hashes are in `evidence/feature-v4-wasm-source-check.log` +and `evidence/final-v4-artifacts.sha256`. The coordinator independently confirmed +that the WASM contains all 13 exact independently built module byte sequences; +`evidence/final-v4-build-rs-byte-parity.json` records every comparison. + +Four independent read-only reviewers share the exact 32-file implementation, +fixture, CI and operator-guide target, including all untracked files. Its fingerprint +is `669bfc753a5b4360f856adfa0a38dbecb7c097c49846048f13c3dee58916ae42`. +Historical planning/review progress records are excluded so this ledger can be +updated. Correctness, architecture and style report no findings. The coordinator +verified the code-smell reviewer's only candidate, a nonblocking duplicated +Prebid byte-verification helper opportunity, and left it unchanged. The +[combined implementation review](2026-10-27-854-publisher-registered-initial-delivery-implementation-review.md) +records coverage, R1's disposition and proof limits. + +## Final V4 framework, CI preparation and Rust checks + +Actual preparation passed in 35.201s with Viceroy absent from the restricted PATH +and `TSJS_PREBUILT_DIR` unset. The log records both conditions and the actual +build.rs rebuild of 13 modules. The normal integration configuration was also +generated from the unchanged normal fixture/template with the current prepared +native generator. Evidence is `evidence/task8-final-v4-preparation-command.log`. + +The final strict artifact-reuse run passed all 26 real Rust/Next.js cases, +13 inline and 13 shared, with zero skipped, unexpected or flaky cases. It passed +fresh full/reduced provider transport and actual served-module equality checks. +The first run passed in 60.896s; an instrumentation-only argv separator correction +then captured every tool call in a second passing run of 67.033s. The final scoped +spy records exactly one `cargo --version`, ten Docker runtime/inspect calls, +zero Cargo build/run and zero Docker build calls. No source change or runtime +fix was needed. Evidence is `evidence/task8-final-v4-no-build-summary.json`, +`task8-final-v4-framework-v2-command.log` and the paired framework evidence. + +All 19 final Rust commands passed freshly on V4 in 491.948s total command wall +time. These include native GPT 49/49, full Fastly 3,220 passing with ten existing +ignored tests, Fastly reuse 221, Axum 48, Cloudflare 54, Spin 88, parity 17 and +template digest ten. All required Clippy gates and the additional native backend +example Clippy check passed. All 39 whole-target snapshots matched the reviewed +fingerprint, and the five behavior sources, immutable WASM and 13 selected bundle +hashes stayed unchanged. `evidence/rust-final-v4/REPORT.md` records exact commands, +per-gate environment, durations, exits and hashes. V3 evidence remains separate. + +The full docs format check, outside-docs Markdown formatting, WASM clock lint and +relative-link checks passed. Later progress-note edits receive focused formatting +checks. The nine changed Markdown files had 16 checked relative links and no +missing targets. Independent style review also passed Actionlint, ShellCheck, +fixture formatting and untracked whitespace checks. + +## Final normal browser matrix + +Both standard framework runs exited zero with their unchanged application config, +normal logging, timeout/retry policy and one worker. Next.js passed 21 tests with +14 expected skips in 28.787s command wall time. WordPress passed 11 tests with +24 expected skips in 29.571s. Those skips are the dedicated registration guard and +the other framework's tests, not excluded failures. The dedicated registration +cases already passed separately with zero skips. + +The ordinary CI Prebid command omits a user-ID override and therefore selects +nine default modules. Its exact separate artifact built in 3.196s, with SHA-256 +`166d18ff74d4e9d023a01468ee433d4f5b75b9fd7c7ab60639f0396a74d9072b`. +The dedicated registration profile remains Rubicon plus SharedId, matching its +own CI preparation command. No mismatched subset artifact was used for normal +matrix claims. Only ignored build outputs changed during this preparation. + +Normal Next.js printed two nonfatal pinned-Viceroy task panics at +`session/downstream.rs:121`, `response receiver is open: ()`. All tests passed and +the server continued. The exact stderr remains in its command log. No clean-base +reproduction was performed, so these warnings are unclassified, not declared +unrelated or silently omitted. Normal WordPress completed without a test failure. +Evidence is `evidence/task8-final-v4-normal-{nextjs,wordpress}/` and sibling +normal artifact/config logs. Managed teardown stopped the owned runtime and +containers. Root free space fell to about 235 MB; no further heavy work or root +cleanup was authorized. + +## Remaining external gates + +- Hosted GitHub CI has not yet run for this branch. Local preparation/reuse + execution does not claim a hosted workflow pass. +- Real publisher adoption and separately approved real-GPT staging remain external + deployment gates. Local fixtures do not prove a live-site fix or CWV improvement. 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..17bd744406 --- /dev/null +++ b/docs/superpowers/reviews/2026-10-29-854-client-cwv-validation.md @@ -0,0 +1,286 @@ +# Issue 854 client CWV validation + +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) + +## Validation after merging main + +The first implementation push was `0235533c7bac92716a05e6c6e7ec91a9048d9fe7`. PR1460 then conflicted with newer main. The merged source combines that commit with `21f43aa0138498023455789f152f5a864bbefe24`. The records below validate the combined checkout, not just the earlier branch. All later sections retain the earlier evidence separately. + +The merged checkout pins Node 24.20.0, Rust 1.98.1 and Viceroy 0.21.0. Browser tests still use Chromium 145.0.7632.6. Fresh JS dependencies came from the merged lockfile. One coordinator owns Cargo with `CARGO_TARGET_DIR=$HOME/.cache/trusted-server-854-merged-target`, four build jobs, zero dev/test debug info and incremental compilation disabled. The new evidence directory is `/tmp/ts-854-cwv/merge/`. + +Conflict resolution preserves upstream asynchronous HTML EOF processing, origin response-header rewriting, response status and range ownership. It retains this PR's actual request path, conservative CSP capture and reservation policy. The new EOF driver receives the actual path and CSP decision. The shared GPT render bridge includes upstream APS failure-reason validation, iframe-bound consumed-capability records and timeout-derived retention. Early/runtime installation and registration admission guards remain intact. + +### 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, 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`. + +The new Next.js image is `test-nextjs:publisher-registration-b78c3cc44c54194e`. It uses Node 24.20.0 and a new source label because `.tool-versions` contributes to the image fingerprint. The older image was not reused. The two actual framework runs passed in 13.4 s and 14.2 s. + +The lab, offline suite and framework cases use freshly generated Prebid 10.26.0 with Rubicon and SharedId, SHA-256 `32a7f07131047948134510d57c7f4eeb832e1ae0c75d8b61d6fd38aab2be780c`. The APS suite separately uses the exact ordinary CI build request, Rubicon with the builder's default user-ID modules. That artifact is 314,643 bytes, SHA-256 `166d18ff74d4e9d023a01468ee433d4f5b75b9fd7c7ab60639f0396a74d9072b`. These profiles are not interchangeable evidence. All 14 runtime hashes and tracked source files stayed unchanged during the APS run. + +### Updated controlled measurements + +The unchanged `c36c82df43c2f62a59444290f863f736b579ed33` archive remains the baseline. Current exports include upstream main, newer Node/Rust and build-dependency changes. This comparison does not isolate the effect of this PR from those changes. The 750 ms runtime hold, five alternating samples per group, viewport sizes, fictional transport and session-window CLS calculation are unchanged. The host is unthrottled and shared; these are not field CWV results. + +| Viewport | Arm | FCP/LCP median and range, ms | CLS median | Native request median, ms | Visible estimate median, ms | +| ----------- | ---------------- | ---------------------------- | ---------- | ------------------------- | --------------------------- | +| 390 × 844 | Baseline | 792, 784 to 792 | 0.207065 | 808 | 1006.2 | +| 390 × 844 | Loading only | 32, 28 to 36 | 0.207065 | 778 | 972.3 | +| 390 × 844 | Reservation only | 784, 784 to 792 | 0 | 804 | 1002.3 | +| 390 × 844 | Combined | 36, 32 to 40 | 0 | 780 | 978.1 | +| 1280 × 1000 | Baseline | 792, 788 to 800 | 0.111133 | 810 | 1008.4 | +| 1280 × 1000 | Loading only | 44, 40 to 52 | 0.111133 | 775 | 973.8 | +| 1280 × 1000 | Reservation only | 792, 792 to 800 | 0 | 800 | 993.0 | +| 1280 × 1000 | Combined | 44, 44 to 52 | 0 | 780 | 979.7 | + +The reservation-only arm still adds only the exact current Rust-generated CSS to baseline HTML. The request/visible differences are local fixture observations, not proof of production ad-startup improvements. Full ranges and observer data are in `evidence/measurement-summary.json` and `evidence/cwv/`. + +Transfer cost remains a tradeoff. Raw inline JS is 57,550 bytes in baseline and 114,153 in current output. The retained raw bootstrap is 56,429 bytes; the compiled early entry adds 56,602 bytes. Fetched runtime is 91,321 bytes in baseline and 58,251 in current. Estimated combined HTML/runtime gzip grows from 42,104 to 50,583 bytes, an increase of 8,479. Brotli grows from 35,452 to 44,459 bytes. The increase includes upstream and build-dependency changes; it is not a size-reduction claim. + +Frozen export manifest hashes are `209171eda4cedf472ef6b372bcc05bdf354cb90000970d61ea7c965a774a814f` for loading, `ccfc8084527da2e851f6dfea9272d5c44135a55448b0dd1d34159ba02e157006` for combined and the unchanged `e903286285cab8ca79d0b334e6012a136f7d76975a813dc00eeb76d54664b5fe` for baseline. Final samples hash is `31abb8fd74e77bd195c523385bf5a1c4ceac10685861005d1002fff348bf284b`. + +### Retained failures and limits in the merged checkout + +Initial browser attempts failed or stalled, including a Chromium SIGTRAP and later GPU-process/Mojo failures. Failed outputs and `evidence/chromium-crash-analysis.md` remain available. Serial retries passed the same 61 and 109 cases without source changes, weaker assertions or different browser flags. The crash cause is unproven; the evidence does not establish OOM, a process limit or an application defect. + +One WASM-copy attempt hit the temporary-file quota and produced an incomplete copy. It was not accepted as a validated artifact. Moving only this session's old 7.1 GB Cargo cache to `$HOME/.cache/trusted-server-854-premerge-target` restored room. A symlink preserves its old `/tmp/ts-854-cwv/target` paths. The copy retry matched the source binary, passed all 14 embedded-module checks and then passed the actual framework gate. + +Standalone `tsc --noEmit` still fails, with 393 output lines and 320 primary diagnostics. A same-lock main archive also fails, with 385 lines and 318 diagnostics. Every current file/code/message signature occurs in either that main archive or the earlier branch baseline; no new merge signature appears. There are no diagnostics in the extracted GPT helpers or early-bundle test. Comparison: `tsc-comparison.json`. The older Cargo-injected native Settings failure is historical; the ordinary merged Cargo invocation now passes. + +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. + +### 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 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 + +Everything below describes the earlier `0235533c7bac92716a05e6c6e7ec91a9048d9fe7` implementation, before merging main. Its older tool pins, measurements, cache path, failures and 21-gate results do not describe the merged checkout above. + +## 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/reviews/2026-11-02-854-closure-acceptance.md b/docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md new file mode 100644 index 0000000000..e3a8e95033 --- /dev/null +++ b/docs/superpowers/reviews/2026-11-02-854-closure-acceptance.md @@ -0,0 +1,216 @@ +# Issue 854 closure acceptance + +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 + +## 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. + +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. + +## Original closure contract (historical) + +The PR must demonstrate these outcomes through the production HTML transformation and compiled JavaScript: + +1. Supported integration profiles can continue parsing content without waiting for the external GPT runtime. Early APIs and interception remain functional. Other profiles retain tested synchronous delivery. +2. Explicit responsive reservation prevents the reproduced ad-induced layout shift. Unconfigured and unknown-height placements do not acquire an invented height policy. +3. A publisher-registered placement requests its final live native slot once initially, with eligible server targeting. An unrelated slot remains untouched, the creative is actually visible, and a legitimate later refresh still works. + +Registration requires a publisher adapter and initial loading disabled before services are enabled. Reservation requires an explicit configuration and stable wrapper. This is not transparent duplicate prevention for unchanged publisher pages. + +A successful offline run cannot stand in for real GPT. Full closure additionally requires the observed original-main failure and successful candidate delivery against dedicated real GPT/GAM test inventory. Missing prerequisites are blocked, not skipped or passing. + +## Matched baseline + +The baseline is original main `51730bdaf841e4d7bb96c2258e5e98169bdff9a7`. The candidate merges that revision in `2168a75fc` before adding the acceptance tests and their fixes. Cargo lockfile, JS lockfile, Rust pin and tool-version pins match. + +Prepare the original source without backporting the fix: + +```bash +node scripts/prepare-client-cwv-baseline.mjs \ + --revision 51730bdaf841e4d7bb96c2258e5e98169bdff9a7 \ + --out /absolute/new-baseline-directory +``` + +The helper archives the exact revision, adds only a `test-utils` publisher bridge and a compatible copy of the HTML exporter, and records the original archive hash, exact fixture adaptations and dependency fingerprints in `baseline-source.json`. It does not change baseline production initialization, script loading or reservation behavior. + +Build TSJS in the archived tree with its own lockfile: + +```bash +cd /absolute/new-baseline-directory/source/crates/trusted-server-js/lib +npm ci +npm run build +``` + +From the archived source root, export baseline HTML and build the original WASM: + +```bash +export TSJS_PREBUILT_DIR="$PWD/crates/trusted-server-js/dist" +HOST_TARGET="$(rustc -vV | awk '/^host:/ {print $2}')" +CWV_SOURCE_REVISION=51730bdaf841e4d7bb96c2258e5e98169bdff9a7 \ +CWV_SOURCE_STATUS='Archive plus recorded fixture-only adaptations' \ + cargo run --locked -p trusted-server-integration-tests \ + --example cwv_html --target "$HOST_TARGET" -- /absolute/baseline-export unreserved +cargo build --locked -p trusted-server-adapter-fastly --release --target wasm32-wasip1 +``` + +Copy the completed baseline WASM to a separate immutable path before building candidate artifacts in a shared Cargo target directory. Immediately create its baseline stamp using the command in the [real-GPT guide](../../../crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.README.md#artifacts-and-preparation). + +Build/export the candidate using the [CWV runner instructions](../../../crates/trusted-server-integration-tests/browser/cwv/README.md). Use a current stamped WASM, matching TSJS dist, and the existing external Prebid artifact with Rubicon and SharedId. Do not reuse a stale Next.js image after changing the fixture adapter. + +## Acceptance entry point + +After preparing the artifacts: + +```bash +node scripts/test-client-cwv-acceptance.mjs \ + --baseline /absolute/baseline-export \ + --loading /absolute/candidate-unreserved-export \ + --combined /absolute/candidate-reserved-export \ + --dist /absolute/candidate-dist \ + --wasm /absolute/candidate.wasm \ + --prebid /absolute/verified-prebid \ + --baseline-dist /absolute/original-main-dist \ + --baseline-wasm /absolute/original-main.wasm \ + --inventory /absolute/dedicated-inventory.json \ + --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 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: + +- Fixture, observer, option, launcher and acceptance unit tests. +- 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. +- 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. + +Omitting `--inventory` still runs the offline work but finishes nonzero with a blocked real-GPT row. The final controller reports `functionalGatesPassed` and `requiresPerformanceReview`; passing functional assertions is not an automatic issue-closing decision. Successful preparation, listing or structural preflight cannot set `liveAcceptancePassed=true`. + +## Failures exposed by the stronger tests + +The actual pre-PR baseline exposed an inline-bootstrap CSP hash incompatibility that the earlier registration-era baseline did not. Enforced-response-CSP pages now omit the changed provisional raw bootstrap and use the synchronous unified runtime before publisher scripts. The unchanged activation marker remains. No policy is weakened and no new inline permission is required. + +This deliberately has no provisional fallback if that synchronous runtime fails to load. The acceptance suite must verify both successful old-hash/self delivery and this unavailable-runtime boundary. Non-CSP deferred-runtime failure retains the early renderer and remains a separate contract. + +The merge also exposed two Rust test initializers missing the request-path/CSP or trace fields. These are corrected. The existing Prebid shim budget is adjusted only by its measured 135-character increase relative to matching main, preserving the existing 38-character headroom and the checks against embedding Prebid itself. + +## Evidence and disposition + +Local evidence root: `/home/pav/.cache/ts-854-acceptance/`. Initial failed attempts are not successful runs. + +| Check | Result | Evidence | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| Full JS | 2,126 tests across 77 files passed; lint and format passed | `full-js-final.log`, `js-{lint,format}.log` | +| Native core | 3,373 tests and 20 integrations passed; 30 doctests passed | `core-native.log` | +| Axum | 60 tests passed | `axum-native.log` | +| Cross-adapter parity | 28 tests passed | `parity.log` | +| Adapter/CLI/codegen Clippy | All eight target-matched aliases passed | `gates/clippy-*.log` | +| Fastly, reuse, Cloudflare and Spin test aliases | Passed | `gates/test-*.log` | +| Native digest lint, clock, Node pins, URL domains and documentation proposals | Passed | `gates/` | +| EOF | Native test/lint and actual Axum/Fastly auction controls passed | `gates/eof-*.log` | +| ShellCheck and changed-workflow actionlint | Passed using explicit installed binaries after shim-only failures | `gates/*-retry.log` | +| Docs | Format and build passed | `docs-{format,build}.log` | +| Next.js image | Actual production build/typecheck passed | `image-build.log`, `image-build-canonical.log` | +| Real-GPT preparation | Final async candidate and original-main config generation passed using fictional preparation-only inventory; no runtime or Google traffic | `real-gpt-preparation-final/` | + +Existing ignored Rust tests and doctests remain ignored; these counts do not claim their execution. Fastly absolute-deadline and Worker deadline-runtime fixture commands were not independently rerun in this acceptance session. + +The first Next.js build exposed unsupported direct Set iteration under the fixture's generated ES5 target. The adapter now iterates `Array.from(set)` without relaxing compiler settings. The first real-GPT preparation exposed locale-dependent shell sorting versus JS sorting of bundle names. Both launchers now use canonical `LC_ALL=C` sorting, with a regression covering `gpt.js` and `gpt_diagnostics.js`. Rebuilding and restamping preserved the exact production WASM bytes, SHA-256 `560ff7193bb00871ef97e607e046ae6aeb1a9672c02c488a202a76234b22309c`. + +The first combined run passed 77 units, 109 offline registration cases, 18 legacy controls and 40 actual framework cases. Its measurement phase correctly failed because it expected the PR's new renderer-version marker on the original-main baseline and CSS-only reservation arm. Those arms must instead prove the original renderer through unchanged targeting, request count and actual nested creative visibility. Candidate arms retain the new version assertion. The corrected frozen run at `final-acceptance-corrected/` passed 78 units, the same 109 offline/18 legacy/40 framework cases, and all five 64-sample profiles. Those profiles contain 200 repeated measurement samples and 120 functional/control samples. The overall command exited 1 with a blocked real-GPT row, not success. + +## Matched measurements and the unresolved regression + +[Committed evidence](2026-11-02-854-cwv-measurements.json) retains all 200 individual measurement rows, exact conditions, input/report hashes, bytes and post-measurement byte-equivalence proof. Full observations, screenshots and traces remain in the local evidence directory. Each cell below is baseline to combined median; FCP and LCP were equal in this fixed article fixture. + +| Condition | Viewport | FCP/LCP, ms | First visible ad estimate, ms | +| ---------------------- | --------- | ----------- | ----------------------------- | +| Normal cold | 390x844 | 44 to 32 | 249.7 to 238.5 | +| Normal cold | 1280x1000 | 36 to 36 | 243.8 to 237.6 | +| Normal warm | 390x844 | 28 to 28 | 222.1 to 221.7 | +| Normal warm | 1280x1000 | 32 to 32 | 222.9 to 222.4 | +| Throttled cold | 390x844 | 936 to 792 | 1,333.5 to 1,565.5 | +| Throttled cold | 1280x1000 | 944 to 812 | 1,305.4 to 1,571.0 | +| Throttled warm | 390x844 | 348 to 756 | 550.0 to 1,008.3 | +| Throttled warm | 1280x1000 | 356 to 764 | 563.7 to 1,018.1 | +| Controlled 750 ms hold | 390x844 | 780 to 32 | 995.8 to 974.9 | +| Controlled 750 ms hold | 1280x1000 | 784 to 36 | 990.6 to 966.0 | + +Matching reservation changes controlled CLS from 0.207065 to zero at mobile width and 0.111133 to zero at desktop width. This does not erase the warm-cache regression. At throttled mobile width, all baseline paint samples were 312 to 352 ms and all combined samples were 752 to 764 ms. The loading-only arm reproduces the slowdown; reservation-only retains baseline-like timing. + +Both baseline and candidate runtime requests were genuine HTTP cache hits with zero transferred runtime bytes in warm measurements. The document still grows from 38,728 to 128,941 raw bytes. The new compiled inline entry adds 69,512 bytes, and the provisional bootstrap grows from 36,756 to 57,260 bytes. That extra uncached document transfer explains the warm-cache result. This needs a loading/cacheability design change, not a weaker test or a favorable timing threshold. + +The loopback server serves uncompressed HTTP. Its exact latency delta is not a prediction of production gzip/Brotli delivery. Estimated HTML gzip still grows from 10,018 to 36,499 bytes; estimated cold HTML-plus-runtime gzip grows from 40,336 to 54,938 bytes. The regression's exact production magnitude remains unverified, but its extra-inline-transfer mechanism is directly observed. Slower cold delivery also delays the first visible ad despite improving page paint. + +## Final-state proof and retained failures + +After the frozen run, the controller gained exact original-main revision/blob validation and stopped conflating functional success with issue closure. The live launcher gained running-outcome invalidation and interruption cleanup. These are separately checked orchestration/provenance changes, not new measurements. + +A bounded TypeScript cleanup removes the PR's two source errors and new test diagnostics without relaxing compiler settings. Standalone `tsc --noEmit` still exits 2 with 315 existing diagnostics, compared with 321 on original main and 323 before cleanup. Exact file/code/message signature comparison finds no new or increased signatures. The focused affected Vitest run passes 289 tests, with 309 intentionally unselected; the earlier full JS result is reused for unchanged inputs. + +Fresh production compilation proves all 14 JS modules and both trace assets byte-identical to the measured distribution. Fresh Rust exports prove every servable file byte-identical for both loading and combined outputs, 12 files each; only manifest metadata changes. Rebuilding production WASM preserves SHA-256 `560ff7193bb00871ef97e607e046ae6aeb1a9672c02c488a202a76234b22309c`. The native digest suite passes all 10 tests. These proofs justify reusing the existing browser observations rather than relabeling them as new runs. + +The actual isolated Next.js provider HTTP interface also passes default win, forced no-bid transition, path-driven no-bid, reset, invalid PUT rejection and restored win checks, without opening a GPT page or contacting Google. Evidence: `provider-control-http.json` and its retained executable fixture command. + +Final focused Node validation passes all 82 tests with zero skips, including stale-success invalidation, actual CLI SIGINT/SIGTERM/SIGKILL handling, ordinary discovery exclusion, canonical locale fingerprints and a post-spawn journal-write failure. The live launcher atomically marks a new invocation running before mutations, owns only its child process groups and uniquely named container, and unwinds cleanup before publishing failure. Review caught and corrected the journal-write error path; no exception is ignored to make the gate pass. + +The final async launcher passes actual no-Google `--prepare` against the stamped original-main and candidate artifacts. Separate operational fault checks deliberately make the browser executable unavailable, so no page or Google request can start. A normal expected launch failure proves actual provider preflight, Viceroy exit and Docker removal. SIGINT and SIGTERM at the owned-runtime milestone prove actual Viceroy process exit, owned Docker removal and failed outcomes with exits 130 and 143. These are operational passes, not real-GPT delivery or original-main failure reproduction. SIGKILL remains uncatchable and can require manual resource cleanup; its persisted state cannot retain an old live success. + +Evidence: `final-all-node.log`, `acceptance-boundary-final.log`, `type-only-final-eslint.log`, `final-servable-byte-proof.json`, `real-gpt-preparation-final/`, `real-gpt-no-browser-cleanup-proof.json`, `runtime-signal-proof.json` and their executable scratch commands. Independent read-only correctness reviews checked the real-GPT observations, provenance controller, type-only boundary and live-runner cleanup changes, with no remaining findings after corrections. Actual Google/GAM delivery remains unverified. + +## Hosted fixture findings and focused follow-up + +The first hosted JavaScript scan flagged four new fixture operations. The proxy now constructs connection hostname/port only from the server-created exact-origin map, never the incoming URL. Its upstream path remains request-derived and its response/cache semantics remain unchanged. Tests verify exact request method/path/host/body at the upstream separately from a fixed plain-text response, rather than reflecting request data. Real-GPT configuration assertions now parse the generated scalar/host-list values and compare exact endpoints instead of substring/hostname-regex checks. No suppressions or global allowlist changes are added. + +All 82 Node checks pass after these changes. Four cold browser regressions and a 32-case warm run pass, including genuine cache reuse and the original-main comparison. Candidate and original-main emitted app configs are byte-identical before/after the assertion changes; production JS, HTML and WASM remain unchanged. Evidence: `codeql-fixture-units.log`, `codeql-cold-regressions/`, `codeql-warm-regressions/` and `codeql-fixture-proof.json`. The original five-condition timings are retained as historical executions, not relabeled as this follow-up. Hosted analysis is separate and must be checked on the pushed head. + +Dedicated inventory must provide two distinct test ad-unit paths, known TS and ordinary creative IDs, and their visible markers. No customer inventory or guessed public sample placement substitutes for this setup. See the [real-GPT guide](../../../crates/trusted-server-integration-tests/browser/publisher-registration/real-gpt.README.md). + +Do not add an automatic issue-closing keyword, mark the PR ready, or claim closure while a required functional gate is blocked/failed or the measured warm-cache regression remains unresolved. No deployment or publisher activation is part of this local test run. diff --git a/docs/superpowers/reviews/2026-11-02-854-cwv-measurements.json b/docs/superpowers/reviews/2026-11-02-854-cwv-measurements.json new file mode 100644 index 0000000000..83b71a37ed --- /dev/null +++ b/docs/superpowers/reviews/2026-11-02-854-cwv-measurements.json @@ -0,0 +1,7538 @@ +{ + "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.", + "Dedicated real GPT/GAM inventory absent; no live acceptance or original-main live reproduction." + ], + "baselineRevision": "51730bdaf841e4d7bb96c2258e5e98169bdff9a7", + "candidateInputRevision": "2168a75fcfd98662b3f468412cabc88fc2ff0726", + "candidateState": "Measured dirty working tree is identified by the retained source-file fingerprints; subsequent live-runner/provenance/type-only changes are separately validated.", + "node": "v24.20.0", + "browser": "145.0.7632.6", + "boundary": "Controlled fictional loopback fixture; no real GPT/GAM/bidder transport or field-CWV claim. HTTP documents/assets served uncompressed; gzip/Brotli values are estimates only. Shared host, five alternating samples per group.", + "inputs": { + "loading": { + "manifestSha256": "54623615f61fe2fb1cf31f2e4ad7b1a2f3642a14cccc6d64bcb4f08bb4dda069", + "sourceFingerprints": "550a4adb3d7199a73585886ce9fe01f1f66e3c1670e92deb7e78aa8a8420c00c Cargo.lock\n95961a65cd44ee0b7d97243bdc87d3b35d2b8d710d86e850bc8da64bcbd51491 rust-toolchain.toml\n273b277448d6ea76639e75ed0af0260f24c80b34b711970747e7e9b9524383c4 .tool-versions\n3e944d3b695f8b21d08032ff7bcc43cbe0f77d1ef5f4181d7ce97adc7ade5c67 crates/trusted-server-core/src/html_processor.rs\n94a3602f2d9f40f78cb72566f39f75e273bf0db6b123894f08da41e7e6c15667 crates/trusted-server-core/src/publisher.rs\nd081dbbf16f19174dc1dbe4a4cc429ba3f9c925765ddf502e13eaaa38d23e225 crates/trusted-server-core/src/integrations/registry.rs\n81c5a8833a0c820c7de7adbde530b1c2098c5f738bad551cf40fa93cb38e745a crates/trusted-server-core/src/creative_opportunities.rs\n25aa765897a66c9e75ef646ebf96fc1139f152915336e0a4b5fb5a438b6e55e6 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", + "files": { + "inline.html": { + "sha256": "384da77420d692858bf3f57d1736fe10729e117f5f9bf91f3f5825f0999ad58b", + "raw": 128792, + "gzip": 36419, + "brotli": 31772 + }, + "csp.html": { + "sha256": "2aaf9560bd27a6ec6c9329d1aed4c30c2b843149198254675837d16faa8fa4b8", + "raw": 1955, + "gzip": 1017, + "brotli": 771 + }, + "shared.html": { + "sha256": "5edcb1a6b3c6cef44366c86a83fca4b2036e616ad257eb71bd9bd797e8032cdb", + "raw": 128758, + "gzip": 36381, + "brotli": 31788 + }, + "registered.html": { + "sha256": "00af7c6c4d7be52e5ae8be9ce4db9bcf53b8b37ed07252fb773d8b460107b8a0", + "raw": 129280, + "gzip": 36615, + "brotli": 31970 + }, + "tall.html": { + "sha256": "5c5ab8f3653ea6809b80aaea015cdc56e728bf1b37b1c7fa1686493afd65488c", + "raw": 128792, + "gzip": 36418, + "brotli": 31803 + }, + "unknown-height.html": { + "sha256": "d979d51618de4b318136bfe2de82bfbe65e2d8ec1a618724c434e59d518a6eb2", + "raw": 128772, + "gzip": 36407, + "brotli": 31778 + }, + "absent-wrapper.html": { + "sha256": "0913aeb4ee9653af3c82ce11a73bfbc179ce53deaeb31bbe3ab2749d2f0b4c00", + "raw": 128759, + "gzip": 36416, + "brotli": 31789 + }, + "unmatched.html": { + "sha256": "e6fb5771a8beb3b310483140324978d4e165d54cd51344732bcfd794888e5f4b", + "raw": 127898, + "gzip": 36005, + "brotli": 31460 + }, + "child-replacement.html": { + "sha256": "895e5674d75d900e8f03fd9c957ed1db5332dc53bc7dbf82726d764d103f7977", + "raw": 128876, + "gzip": 36443, + "brotli": 31807 + }, + "assets/0.js": { + "sha256": "c6fd0895a70f5f23734245d7e54dcd966d4b9d94c78bbe158213f4fe8ea8f372", + "raw": 58738, + "gzip": 18439, + "brotli": 16421 + }, + "assets/1.js": { + "sha256": "79d7792bfbbcb918bfd49c9fca59f4b14f8cfb1723455c2d66c68d48eeebc0fa", + "raw": 106931, + "gzip": 33155, + "brotli": 27508 + }, + "app.toml": { + "sha256": "e57f0fa799b7c22eba359a12d842688a6355097dac566f31d06975e5892eb75c", + "raw": 794, + "gzip": 412, + "brotli": 330 + } + } + }, + "combined": { + "manifestSha256": "f200c599f700f0b7872d2cb3701f50df39188b78b855cdb1c70f3b3903e3c504", + "sourceFingerprints": "550a4adb3d7199a73585886ce9fe01f1f66e3c1670e92deb7e78aa8a8420c00c Cargo.lock\n95961a65cd44ee0b7d97243bdc87d3b35d2b8d710d86e850bc8da64bcbd51491 rust-toolchain.toml\n273b277448d6ea76639e75ed0af0260f24c80b34b711970747e7e9b9524383c4 .tool-versions\n3e944d3b695f8b21d08032ff7bcc43cbe0f77d1ef5f4181d7ce97adc7ade5c67 crates/trusted-server-core/src/html_processor.rs\n94a3602f2d9f40f78cb72566f39f75e273bf0db6b123894f08da41e7e6c15667 crates/trusted-server-core/src/publisher.rs\nd081dbbf16f19174dc1dbe4a4cc429ba3f9c925765ddf502e13eaaa38d23e225 crates/trusted-server-core/src/integrations/registry.rs\n81c5a8833a0c820c7de7adbde530b1c2098c5f738bad551cf40fa93cb38e745a crates/trusted-server-core/src/creative_opportunities.rs\n25aa765897a66c9e75ef646ebf96fc1139f152915336e0a4b5fb5a438b6e55e6 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", + "files": { + "inline.html": { + "sha256": "27bdaa1eef5378e6e9e40e535294982e11f7ad18283800580fab9a469294dc75", + "raw": 128941, + "gzip": 36499, + "brotli": 31897 + }, + "csp.html": { + "sha256": "58cadbc1116135811f64ed1593fbd130d44df39464bab0f753b606d2fa9a453f", + "raw": 2104, + "gzip": 1072, + "brotli": 823 + }, + "shared.html": { + "sha256": "7dff2a667f391019f838255120b3458018829271b2e7e07e70def091113c627c", + "raw": 128907, + "gzip": 36468, + "brotli": 31846 + }, + "registered.html": { + "sha256": "d05d02593888c3e2a6af8f9015d2bede253d0970169d434e2e6610e0982e889d", + "raw": 129429, + "gzip": 36694, + "brotli": 31969 + }, + "tall.html": { + "sha256": "a1c9a8070d7deb9776089f8983795f90ca411a27e2ec3acc7c7d01c5a8ff5e3d", + "raw": 128941, + "gzip": 36498, + "brotli": 31866 + }, + "unknown-height.html": { + "sha256": "0e24bcdb1f121d3212a8abcfd327be93251f566367ffe832578c73e5bee810ed", + "raw": 128921, + "gzip": 36487, + "brotli": 31832 + }, + "absent-wrapper.html": { + "sha256": "d00492acc6a8eafaf59ba5fe63ba4cb229d8c81848a629716a45e7c812040daa", + "raw": 128908, + "gzip": 36496, + "brotli": 31849 + }, + "unmatched.html": { + "sha256": "e6fb5771a8beb3b310483140324978d4e165d54cd51344732bcfd794888e5f4b", + "raw": 127898, + "gzip": 36005, + "brotli": 31460 + }, + "child-replacement.html": { + "sha256": "0026e165cfdfcf36c8de45d22c4762f945771715cc692c374342b0adbcebbd8f", + "raw": 129025, + "gzip": 36523, + "brotli": 31841 + }, + "assets/0.js": { + "sha256": "c6fd0895a70f5f23734245d7e54dcd966d4b9d94c78bbe158213f4fe8ea8f372", + "raw": 58738, + "gzip": 18439, + "brotli": 16421 + }, + "assets/1.js": { + "sha256": "79d7792bfbbcb918bfd49c9fca59f4b14f8cfb1723455c2d66c68d48eeebc0fa", + "raw": 106931, + "gzip": 33155, + "brotli": 27508 + }, + "app.toml": { + "sha256": "9f89c834c3dc56847a30f5d1c9b4094c65d8db860ce6992b41d0bacb4c3083d4", + "raw": 976, + "gzip": 468, + "brotli": 375 + } + } + }, + "baseline": { + "manifestSha256": "9902a61a39cbdbb9c9d8a66f247c16ac78cdfa73c4d1ad39be93a592e66212b2", + "sourceFingerprints": "550a4adb3d7199a73585886ce9fe01f1f66e3c1670e92deb7e78aa8a8420c00c Cargo.lock\n95961a65cd44ee0b7d97243bdc87d3b35d2b8d710d86e850bc8da64bcbd51491 rust-toolchain.toml\n273b277448d6ea76639e75ed0af0260f24c80b34b711970747e7e9b9524383c4 .tool-versions\nf0142d05d153603f3ac96a002fb625a73485ce9125194d5852f1628bd6941a2c crates/trusted-server-core/src/html_processor.rs\nd9ac0be479b4422aee40d6d5064c57d25678f61357da56d66fdf1ecd448be075 crates/trusted-server-core/src/publisher.rs\n89aeb37bc77ba597f0863efb6abfeb21bde02cb1d9493a328840cdf7ff61cacd crates/trusted-server-core/src/integrations/registry.rs\n307c87bdb8b09d5e92d789be1ddcba93b4449bf196254ac9278de54bd41debc0 crates/trusted-server-core/src/creative_opportunities.rs\nd431d59f523f2ac3d22f497a3e67a6bfb77fa69b8e645a057286233ebafc4a26 crates/trusted-server-core/src/tsjs.rs\n91447df59ba11ae40e918a461bfba6ea24784985f167bb43ee53525fa08a7111 crates/trusted-server-js/lib/build-module-options.mjs\n692f2f36201bff185ca8259e3c80ed1929690ee8af046b7c4839bc6476a38e27 crates/trusted-server-js/lib/build-all.mjs\nac5f89676ae9d6d7ee6a74feab2f70948e9ad59860192cc82a03a8d965580110 crates/trusted-server-core/src/integrations/gpt_bootstrap.js\n4d06cc79afe5d7f098e3359d7ce9a02f36b490f55c57973fb2f140088eaf9cd9 crates/trusted-server-integration-tests/examples/cwv_html.rs", + "files": { + "inline.html": { + "sha256": "f9b3213f0dfb0a437442f37a84317b2eb06381809cab95e22c274b9e9b797671", + "raw": 38728, + "gzip": 10018, + "brotli": 8511 + }, + "csp.html": { + "sha256": "f9b3213f0dfb0a437442f37a84317b2eb06381809cab95e22c274b9e9b797671", + "raw": 38728, + "gzip": 10018, + "brotli": 8511 + }, + "shared.html": { + "sha256": "6d3e09243b766c1b2d70951397f520d3854ec8163f259757eff4a891d598ee53", + "raw": 38694, + "gzip": 9980, + "brotli": 8505 + }, + "tall.html": { + "sha256": "d176c1854eda99b12860ce89af7dbb113056fa73b8b8f9325c57b8451d6d1953", + "raw": 38728, + "gzip": 10017, + "brotli": 8504 + }, + "unknown-height.html": { + "sha256": "93602b0f9fde618f9a9ce16ec817c63594424d8c4437c4c8288e1a79121ac594", + "raw": 38708, + "gzip": 10007, + "brotli": 8483 + }, + "absent-wrapper.html": { + "sha256": "aac49e6af2684970aee186745a48db7264ec96b76226f1f4805df125cca13ace", + "raw": 38695, + "gzip": 10015, + "brotli": 8500 + }, + "unmatched.html": { + "sha256": "2b12ea3fb35b80f85f501bd84c99498b25a8761b0c084acf56834c4efe0d8a13", + "raw": 37834, + "gzip": 9614, + "brotli": 8173 + }, + "child-replacement.html": { + "sha256": "9248b6b2c50a66cd099cb3839f105c0d2acc4605ac32675ca78ebb55a39e0e63", + "raw": 38812, + "gzip": 10040, + "brotli": 8506 + }, + "assets/0.js": { + "sha256": "af0daac10f0bde94fc7432655d2cf6cfc4ad18084e7af9fd467487ffa197c5f2", + "raw": 97116, + "gzip": 30318, + "brotli": 25253 + }, + "app.toml": { + "sha256": "e57f0fa799b7c22eba359a12d842688a6355097dac566f31d06975e5892eb75c", + "raw": 794, + "gzip": 412, + "brotli": 330 + } + } + } + }, + "bytes": { + "baseline": { + "html": { + "raw": 38728, + "gzip": 10018, + "brotli": 8511 + }, + "inlineScript": { + "raw": 37877, + "gzip": 9550, + "brotli": 8196 + }, + "inlineTags": [ + { + "id": "anonymous-0", + "raw": 207, + "gzip": 174, + "brotli": 135 + }, + { + "id": "anonymous-1", + "raw": 92, + "gzip": 75, + "brotli": 61 + }, + { + "id": "anonymous-2", + "raw": 36756, + "gzip": 9023, + "brotli": 7744 + }, + { + "id": "anonymous-3", + "raw": 164, + "gzip": 132, + "brotli": 93 + }, + { + "id": "anonymous-4", + "raw": 0, + "gzip": 20, + "brotli": 1 + }, + { + "id": "anonymous-5", + "raw": 653, + "gzip": 402, + "brotli": 312 + } + ], + "runtime": { + "raw": 97116, + "gzip": 30318, + "brotli": 25253 + } + }, + "loading": { + "html": { + "raw": 128792, + "gzip": 36419, + "brotli": 31772 + }, + "inlineScript": { + "raw": 127894, + "gzip": 35939, + "brotli": 31443 + }, + "inlineTags": [ + { + "id": "anonymous-0", + "raw": 207, + "gzip": 174, + "brotli": 135 + }, + { + "id": "anonymous-1", + "raw": 92, + "gzip": 75, + "brotli": 61 + }, + { + "id": "anonymous-2", + "raw": 57260, + "gzip": 13209, + "brotli": 11369 + }, + { + "id": "trustedserver-bootstrap", + "raw": 69512, + "gzip": 22459, + "brotli": 19878 + }, + { + "id": "anonymous-4", + "raw": 164, + "gzip": 132, + "brotli": 93 + }, + { + "id": "anonymous-5", + "raw": 0, + "gzip": 20, + "brotli": 1 + }, + { + "id": "anonymous-6", + "raw": 653, + "gzip": 402, + "brotli": 312 + } + ], + "runtime": { + "raw": 58738, + "gzip": 18439, + "brotli": 16421 + } + }, + "reservation": { + "html": { + "raw": 38877, + "gzip": 10096, + "brotli": 8561 + }, + "inlineScript": { + "raw": 37877, + "gzip": 9550, + "brotli": 8196 + }, + "inlineTags": [ + { + "id": "anonymous-0", + "raw": 207, + "gzip": 174, + "brotli": 135 + }, + { + "id": "anonymous-1", + "raw": 92, + "gzip": 75, + "brotli": 61 + }, + { + "id": "anonymous-2", + "raw": 36756, + "gzip": 9023, + "brotli": 7744 + }, + { + "id": "anonymous-3", + "raw": 164, + "gzip": 132, + "brotli": 93 + }, + { + "id": "anonymous-4", + "raw": 0, + "gzip": 20, + "brotli": 1 + }, + { + "id": "anonymous-5", + "raw": 653, + "gzip": 402, + "brotli": 312 + } + ], + "runtime": { + "raw": 97116, + "gzip": 30318, + "brotli": 25253 + } + }, + "combined": { + "html": { + "raw": 128941, + "gzip": 36499, + "brotli": 31897 + }, + "inlineScript": { + "raw": 127894, + "gzip": 35939, + "brotli": 31443 + }, + "inlineTags": [ + { + "id": "anonymous-0", + "raw": 207, + "gzip": 174, + "brotli": 135 + }, + { + "id": "anonymous-1", + "raw": 92, + "gzip": 75, + "brotli": 61 + }, + { + "id": "anonymous-2", + "raw": 57260, + "gzip": 13209, + "brotli": 11369 + }, + { + "id": "trustedserver-bootstrap", + "raw": 69512, + "gzip": 22459, + "brotli": 19878 + }, + { + "id": "anonymous-4", + "raw": 164, + "gzip": 132, + "brotli": 93 + }, + { + "id": "anonymous-5", + "raw": 0, + "gzip": 20, + "brotli": 1 + }, + { + "id": "anonymous-6", + "raw": 653, + "gzip": 402, + "brotli": 312 + } + ], + "runtime": { + "raw": 58738, + "gzip": 18439, + "brotli": 16421 + } + } + }, + "profiles": [ + { + "name": "normal-cold", + "conditions": { + "runtimeDelayMs": 0, + "cpuRate": 1, + "cache": "cold", + "network": { + "offline": false, + "latency": 0, + "downloadThroughput": -1, + "uploadThroughput": -1 + } + }, + "sourceEvidence": { + "samplesSha256": "a018321cdc06beee433631eaa414ab0ddb2de7942dc973635a192642acbabacf", + "artifactsSha256": "8514d9de0603a378d542894f75b4be9386311ddf9b76c14baaa06a47aab031a8", + "runnerSha256": "6cc475663feb947ee532b5bcb89734b08926210e65567af22cccc739ec1caaf5", + "metricsSha256": "886359ad58e86f8baca93b178898148992ca5d897906cc081c720a9d099e4118", + "htmlSha256": "6c842586f4060f871e74080243152707a3ea49c9f63ca4e695b316c92fc71500", + "optionsSha256": "69aa6cbaef2cf2dfa375baa1baa8e752dd023624d4ad84409714a24054d1be1c", + "evidenceSha256": "25d21e7e061e68281eaf41b5306f533915ef92257257439216050a0a2d1506e5" + }, + "functionalSamples": 64, + "negativeControls": { + "parser-blocking": { + "status": "passed", + "sampleIndex": 44, + "htmlFile": "44-combined-inline-390-slow-parser-blocking.html" + }, + "reservation-removed": { + "status": "passed", + "sampleIndex": 45, + "htmlFile": "45-combined-inline-390-slow-reservation-removed.html" + } + }, + "measurements": [ + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 60, + "lcp": 60, + "cls": 0.20706520518407043, + "domInteractive": 54.400000005960464, + "domContentLoaded": 54.900000005960464, + "requestMs": 70, + "firstVisibleEstimateMs": 268, + "runtimeResponseEnd": 25.5 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 44, + "lcp": 44, + "cls": 0.20706520518407043, + "domInteractive": 22.5, + "domContentLoaded": 26.799999997019768, + "requestMs": 45, + "firstVisibleEstimateMs": 243.70000000298023, + "runtimeResponseEnd": 10.200000002980232 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 44, + "lcp": 44, + "cls": 0, + "domInteractive": 31.30000001192093, + "domContentLoaded": 33.400000005960464, + "requestMs": 58, + "firstVisibleEstimateMs": 239.20000000298023, + "runtimeResponseEnd": 13.600000008940697 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 21.200000002980232, + "domContentLoaded": 24.299999997019768, + "requestMs": 52, + "firstVisibleEstimateMs": 233.79999999701977, + "runtimeResponseEnd": 10.400000005960464 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 25.399999991059303, + "domContentLoaded": 27, + "requestMs": 57, + "firstVisibleEstimateMs": 238.5, + "runtimeResponseEnd": 10.299999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 40, + "lcp": 40, + "cls": 0, + "domInteractive": 26, + "domContentLoaded": 28, + "requestMs": 50, + "firstVisibleEstimateMs": 231.5, + "runtimeResponseEnd": 9.599999994039536 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.20706520518407043, + "domInteractive": 24.700000002980232, + "domContentLoaded": 26.200000002980232, + "requestMs": 57, + "firstVisibleEstimateMs": 255.20000000298023, + "runtimeResponseEnd": 11.599999994039536 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 40, + "lcp": 40, + "cls": 0.20706520518407043, + "domInteractive": 33.8999999910593, + "domContentLoaded": 34.29999999701977, + "requestMs": 65, + "firstVisibleEstimateMs": 263.59999999403954, + "runtimeResponseEnd": 11.399999991059303 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 44, + "lcp": 44, + "cls": 0.20706520518407043, + "domInteractive": 39.5, + "domContentLoaded": 40, + "requestMs": 60, + "firstVisibleEstimateMs": 242.20000000298023, + "runtimeResponseEnd": 14.600000008940697 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 48, + "lcp": 48, + "cls": 0.20706520518407043, + "domInteractive": 26.899999991059303, + "domContentLoaded": 30.599999994039536, + "requestMs": 51, + "firstVisibleEstimateMs": 249.5, + "runtimeResponseEnd": 13.599999994039536 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 48, + "lcp": 48, + "cls": 0, + "domInteractive": 25.700000002980232, + "domContentLoaded": 27.299999997019768, + "requestMs": 56, + "firstVisibleEstimateMs": 254.20000000298023, + "runtimeResponseEnd": 10.900000005960464 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 28.400000005960464, + "domContentLoaded": 30.299999997019768, + "requestMs": 63, + "firstVisibleEstimateMs": 257.90000000596046, + "runtimeResponseEnd": 11.700000002980232 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 48, + "lcp": 48, + "cls": 0, + "domInteractive": 26, + "domContentLoaded": 29.5, + "requestMs": 59, + "firstVisibleEstimateMs": 252.90000000596046, + "runtimeResponseEnd": 12.799999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 48, + "lcp": 48, + "cls": 0, + "domInteractive": 21.799999997019768, + "domContentLoaded": 24.299999997019768, + "requestMs": 54, + "firstVisibleEstimateMs": 235.5, + "runtimeResponseEnd": 9.299999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0.20706520518407043, + "domInteractive": 18.900000005960464, + "domContentLoaded": 21.5, + "requestMs": 37, + "firstVisibleEstimateMs": 235.59999999403954, + "runtimeResponseEnd": 9 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 44, + "lcp": 44, + "cls": 0.20706520518407043, + "domInteractive": 21.599999994039536, + "domContentLoaded": 23.200000002980232, + "requestMs": 51, + "firstVisibleEstimateMs": 249.70000000298023, + "runtimeResponseEnd": 8.200000002980232 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 23.5, + "domContentLoaded": 23.80000001192093, + "requestMs": 49, + "firstVisibleEstimateMs": 247.20000000298023, + "runtimeResponseEnd": 8.600000008940697 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 56, + "lcp": 56, + "cls": 0.20706520518407043, + "domInteractive": 30.100000008940697, + "domContentLoaded": 34.5, + "requestMs": 55, + "firstVisibleEstimateMs": 251.6000000089407, + "runtimeResponseEnd": 13.600000008940697 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 44, + "lcp": 44, + "cls": 0, + "domInteractive": 27.600000008940697, + "domContentLoaded": 29.5, + "requestMs": 54, + "firstVisibleEstimateMs": 252.20000000298023, + "runtimeResponseEnd": 10.799999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0, + "domInteractive": 17.700000002980232, + "domContentLoaded": 20, + "requestMs": 40, + "firstVisibleEstimateMs": 221.90000000596046, + "runtimeResponseEnd": 10 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 44, + "lcp": 44, + "cls": 0.11113281249999998, + "domInteractive": 26.099999994039536, + "domContentLoaded": 28, + "requestMs": 50, + "firstVisibleEstimateMs": 248.29999999701977, + "runtimeResponseEnd": 11.299999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 60, + "lcp": 60, + "cls": 0.11113281249999998, + "domInteractive": 28.700000002980232, + "domContentLoaded": 32.400000005960464, + "requestMs": 49, + "firstVisibleEstimateMs": 244.70000000298023, + "runtimeResponseEnd": 13.900000005960464 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 40, + "lcp": 40, + "cls": 0, + "domInteractive": 17.5, + "domContentLoaded": 17.799999997019768, + "requestMs": 47, + "firstVisibleEstimateMs": 229.29999999701977, + "runtimeResponseEnd": 6.899999991059303 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0, + "domInteractive": 22.900000005960464, + "domContentLoaded": 24.400000005960464, + "requestMs": 53, + "firstVisibleEstimateMs": 244.90000000596046, + "runtimeResponseEnd": 10 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 40, + "lcp": 40, + "cls": 0, + "domInteractive": 21.30000001192093, + "domContentLoaded": 22.700000002980232, + "requestMs": 44, + "firstVisibleEstimateMs": 226.40000000596046, + "runtimeResponseEnd": 8.900000005960464 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 48, + "lcp": 48, + "cls": 0, + "domInteractive": 20.5, + "domContentLoaded": 21.899999991059303, + "requestMs": 52, + "firstVisibleEstimateMs": 234.3999999910593, + "runtimeResponseEnd": 7.5 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 40, + "lcp": 40, + "cls": 0.11113281249999998, + "domInteractive": 18.700000002980232, + "domContentLoaded": 21.299999997019768, + "requestMs": 41, + "firstVisibleEstimateMs": 236.90000000596046, + "runtimeResponseEnd": 8.5 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0.11113281249999998, + "domInteractive": 25.200000002980232, + "domContentLoaded": 25.400000005960464, + "requestMs": 45, + "firstVisibleEstimateMs": 243.79999999701977, + "runtimeResponseEnd": 8.299999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0.11113281249999998, + "domInteractive": 26.799999997019768, + "domContentLoaded": 27.099999994039536, + "requestMs": 45, + "firstVisibleEstimateMs": 226.90000000596046, + "runtimeResponseEnd": 9.099999994039536 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 40, + "lcp": 40, + "cls": 0.11113281249999998, + "domInteractive": 28.19999998807907, + "domContentLoaded": 30.099999994039536, + "requestMs": 56, + "firstVisibleEstimateMs": 237.79999999701977, + "runtimeResponseEnd": 13.399999991059303 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 52, + "lcp": 52, + "cls": 0, + "domInteractive": 24.299999997019768, + "domContentLoaded": 26.299999997019768, + "requestMs": 54, + "firstVisibleEstimateMs": 250.40000000596046, + "runtimeResponseEnd": 9.299999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 40, + "lcp": 40, + "cls": 0, + "domInteractive": 22.200000002980232, + "domContentLoaded": 25.200000002980232, + "requestMs": 54, + "firstVisibleEstimateMs": 235.5, + "runtimeResponseEnd": 11.200000002980232 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0, + "domInteractive": 21.400000005960464, + "domContentLoaded": 24.200000002980232, + "requestMs": 41, + "firstVisibleEstimateMs": 237.59999999403954, + "runtimeResponseEnd": 9.599999994039536 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 16.299999997019768, + "domContentLoaded": 17.399999991059303, + "requestMs": 35, + "firstVisibleEstimateMs": 217.29999999701977, + "runtimeResponseEnd": 7.5999999940395355 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.11113281249999998, + "domInteractive": 13.899999991059303, + "domContentLoaded": 15.399999991059303, + "requestMs": 47, + "firstVisibleEstimateMs": 230.20000000298023, + "runtimeResponseEnd": 9.299999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0.11113281249999998, + "domInteractive": 23.600000008940697, + "domContentLoaded": 23.900000005960464, + "requestMs": 41, + "firstVisibleEstimateMs": 223.30000001192093, + "runtimeResponseEnd": 8.5 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 48, + "lcp": 48, + "cls": 0.11113281249999998, + "domInteractive": 21.5, + "domContentLoaded": 22.900000005960464, + "requestMs": 51, + "firstVisibleEstimateMs": 249, + "runtimeResponseEnd": 8.100000008940697 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0.11113281249999998, + "domInteractive": 18.599999994039536, + "domContentLoaded": 20.700000002980232, + "requestMs": 52, + "firstVisibleEstimateMs": 233.70000000298023, + "runtimeResponseEnd": 11.400000005960464 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 40, + "lcp": 40, + "cls": 0, + "domInteractive": 19.399999991059303, + "domContentLoaded": 19.599999994039536, + "requestMs": 46, + "firstVisibleEstimateMs": 227.79999999701977, + "runtimeResponseEnd": 7.799999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0, + "domInteractive": 21.5, + "domContentLoaded": 23.599999994039536, + "requestMs": 45, + "firstVisibleEstimateMs": 238.59999999403954, + "runtimeResponseEnd": 8.700000002980232 + }, + "nativeRequests": 1 + } + ] + }, + { + "name": "normal-warm", + "conditions": { + "runtimeDelayMs": 0, + "cpuRate": 1, + "cache": "warm", + "network": { + "offline": false, + "latency": 0, + "downloadThroughput": -1, + "uploadThroughput": -1 + } + }, + "sourceEvidence": { + "samplesSha256": "31cee924374e0917b3301784d9c5f31cea304f213d2cfbb0d9fb54b5ee7d4736", + "artifactsSha256": "8ded96ebd07a82b7bae1cf03938be1ee2f7f8af6803029241d8be0601c3cd79b", + "runnerSha256": "6cc475663feb947ee532b5bcb89734b08926210e65567af22cccc739ec1caaf5", + "metricsSha256": "886359ad58e86f8baca93b178898148992ca5d897906cc081c720a9d099e4118", + "htmlSha256": "6c842586f4060f871e74080243152707a3ea49c9f63ca4e695b316c92fc71500", + "optionsSha256": "69aa6cbaef2cf2dfa375baa1baa8e752dd023624d4ad84409714a24054d1be1c", + "evidenceSha256": "25d21e7e061e68281eaf41b5306f533915ef92257257439216050a0a2d1506e5" + }, + "functionalSamples": 64, + "negativeControls": { + "parser-blocking": { + "status": "passed", + "sampleIndex": 44, + "htmlFile": "44-combined-inline-390-slow-parser-blocking.html" + }, + "reservation-removed": { + "status": "passed", + "sampleIndex": 45, + "htmlFile": "45-combined-inline-390-slow-reservation-removed.html" + } + }, + "measurements": [ + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.20706520518407043, + "domInteractive": 17.5, + "domContentLoaded": 18.100000008940697, + "requestMs": 45, + "firstVisibleEstimateMs": 227.79999999701977, + "runtimeResponseEnd": 6.5 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 24, + "lcp": 24, + "cls": 0.20706520518407043, + "domInteractive": 13.600000008940697, + "domContentLoaded": 14.900000005960464, + "requestMs": 36, + "firstVisibleEstimateMs": 218.90000000596046, + "runtimeResponseEnd": 6.800000011920929 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0, + "domInteractive": 16.399999991059303, + "domContentLoaded": 17, + "requestMs": 40, + "firstVisibleEstimateMs": 222.8999999910593, + "runtimeResponseEnd": 6.399999991059303 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0, + "domInteractive": 14.5, + "domContentLoaded": 16.100000008940697, + "requestMs": 38, + "firstVisibleEstimateMs": 220.79999999701977, + "runtimeResponseEnd": 6.5 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0, + "domInteractive": 14.599999994039536, + "domContentLoaded": 16, + "requestMs": 39, + "firstVisibleEstimateMs": 221.70000000298023, + "runtimeResponseEnd": 7.200000002980232 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 24, + "lcp": 24, + "cls": 0, + "domInteractive": 15, + "domContentLoaded": 15.5, + "requestMs": 37, + "firstVisibleEstimateMs": 220.20000000298023, + "runtimeResponseEnd": 5 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 13.199999988079071, + "domContentLoaded": 14.5, + "requestMs": 41, + "firstVisibleEstimateMs": 224.19999998807907, + "runtimeResponseEnd": 6 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 24, + "lcp": 24, + "cls": 0.20706520518407043, + "domInteractive": 16, + "domContentLoaded": 16.700000002980232, + "requestMs": 38, + "firstVisibleEstimateMs": 221.09999999403954, + "runtimeResponseEnd": 5.9000000059604645 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 15.5, + "domContentLoaded": 16.100000008940697, + "requestMs": 39, + "firstVisibleEstimateMs": 222.1000000089407, + "runtimeResponseEnd": 5.800000011920929 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 13.899999991059303, + "domContentLoaded": 15.299999997019768, + "requestMs": 39, + "firstVisibleEstimateMs": 221.59999999403954, + "runtimeResponseEnd": 6.799999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 24, + "lcp": 24, + "cls": 0, + "domInteractive": 14.900000005960464, + "domContentLoaded": 15.400000005960464, + "requestMs": 38, + "firstVisibleEstimateMs": 220.70000000298023, + "runtimeResponseEnd": 5.299999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0, + "domInteractive": 13.600000008940697, + "domContentLoaded": 14.900000005960464, + "requestMs": 39, + "firstVisibleEstimateMs": 221.90000000596046, + "runtimeResponseEnd": 6.100000008940697 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0, + "domInteractive": 13.900000005960464, + "domContentLoaded": 15.5, + "requestMs": 39, + "firstVisibleEstimateMs": 222.20000000298023, + "runtimeResponseEnd": 6.4000000059604645 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 24, + "lcp": 24, + "cls": 0, + "domInteractive": 15.5, + "domContentLoaded": 15.899999991059303, + "requestMs": 37, + "firstVisibleEstimateMs": 220.3999999910593, + "runtimeResponseEnd": 5.799999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 12.799999997019768, + "domContentLoaded": 14.099999994039536, + "requestMs": 38, + "firstVisibleEstimateMs": 221.5, + "runtimeResponseEnd": 6.0999999940395355 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 16, + "domContentLoaded": 16.700000002980232, + "requestMs": 38, + "firstVisibleEstimateMs": 221.09999999403954, + "runtimeResponseEnd": 5.799999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 14.700000002980232, + "domContentLoaded": 15.200000002980232, + "requestMs": 40, + "firstVisibleEstimateMs": 222.80000001192093, + "runtimeResponseEnd": 5 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 13.700000002980232, + "domContentLoaded": 15.200000002980232, + "requestMs": 39, + "firstVisibleEstimateMs": 222.3999999910593, + "runtimeResponseEnd": 6 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0, + "domInteractive": 15.299999997019768, + "domContentLoaded": 15.799999997019768, + "requestMs": 41, + "firstVisibleEstimateMs": 224.69999998807907, + "runtimeResponseEnd": 5.699999988079071 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0, + "domInteractive": 13.400000005960464, + "domContentLoaded": 14.800000011920929, + "requestMs": 39, + "firstVisibleEstimateMs": 221.6000000089407, + "runtimeResponseEnd": 6.100000008940697 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 17.099999994039536, + "domContentLoaded": 17.900000005960464, + "requestMs": 38, + "firstVisibleEstimateMs": 220.59999999403954, + "runtimeResponseEnd": 6.200000002980232 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 12.900000005960464, + "domContentLoaded": 14.299999997019768, + "requestMs": 40, + "firstVisibleEstimateMs": 223.1000000089407, + "runtimeResponseEnd": 5.9000000059604645 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0, + "domInteractive": 16.700000002980232, + "domContentLoaded": 17.299999997019768, + "requestMs": 41, + "firstVisibleEstimateMs": 224.1000000089407, + "runtimeResponseEnd": 5.9000000059604645 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0, + "domInteractive": 15.099999994039536, + "domContentLoaded": 17, + "requestMs": 39, + "firstVisibleEstimateMs": 222.40000000596046, + "runtimeResponseEnd": 6.700000002980232 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 12.5, + "domContentLoaded": 13.800000011920929, + "requestMs": 40, + "firstVisibleEstimateMs": 222.80000001192093, + "runtimeResponseEnd": 5.600000008940697 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0, + "domInteractive": 16.599999994039536, + "domContentLoaded": 17.400000005960464, + "requestMs": 39, + "firstVisibleEstimateMs": 221.79999999701977, + "runtimeResponseEnd": 6.200000002980232 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 14.900000005960464, + "domContentLoaded": 16.400000005960464, + "requestMs": 39, + "firstVisibleEstimateMs": 221.6000000089407, + "runtimeResponseEnd": 6.700000002980232 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 14.799999997019768, + "domContentLoaded": 15.299999997019768, + "requestMs": 40, + "firstVisibleEstimateMs": 222.8999999910593, + "runtimeResponseEnd": 5.299999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 13.5, + "domContentLoaded": 14.200000002980232, + "requestMs": 40, + "firstVisibleEstimateMs": 223.20000000298023, + "runtimeResponseEnd": 4.4000000059604645 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 14.099999994039536, + "domContentLoaded": 16.099999994039536, + "requestMs": 41, + "firstVisibleEstimateMs": 224, + "runtimeResponseEnd": 6.4000000059604645 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 14.699999988079071, + "domContentLoaded": 15.299999997019768, + "requestMs": 40, + "firstVisibleEstimateMs": 222.59999999403954, + "runtimeResponseEnd": 4.899999991059303 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 14.200000002980232, + "domContentLoaded": 15.600000008940697, + "requestMs": 40, + "firstVisibleEstimateMs": 222.90000000596046, + "runtimeResponseEnd": 6.9000000059604645 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0, + "domInteractive": 16.900000005960464, + "domContentLoaded": 18.80000001192093, + "requestMs": 36, + "firstVisibleEstimateMs": 219.30000001192093, + "runtimeResponseEnd": 7.5 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 15.200000002980232, + "domContentLoaded": 15.700000002980232, + "requestMs": 39, + "firstVisibleEstimateMs": 221.90000000596046, + "runtimeResponseEnd": 5.5999999940395355 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 12.400000005960464, + "domContentLoaded": 13.700000002980232, + "requestMs": 40, + "firstVisibleEstimateMs": 223.30000001192093, + "runtimeResponseEnd": 5.700000002980232 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.11113281249999998, + "domInteractive": 17.799999997019768, + "domContentLoaded": 18.399999991059303, + "requestMs": 36, + "firstVisibleEstimateMs": 218.20000000298023, + "runtimeResponseEnd": 7 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 14.300000011920929, + "domContentLoaded": 14.900000005960464, + "requestMs": 41, + "firstVisibleEstimateMs": 223.70000000298023, + "runtimeResponseEnd": 5.100000008940697 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 12.799999997019768, + "domContentLoaded": 14.200000002980232, + "requestMs": 40, + "firstVisibleEstimateMs": 223.1000000089407, + "runtimeResponseEnd": 6 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0, + "domInteractive": 14.5, + "domContentLoaded": 15, + "requestMs": 41, + "firstVisibleEstimateMs": 224.29999999701977, + "runtimeResponseEnd": 4.799999997019768 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 13.400000005960464, + "domContentLoaded": 14.799999997019768, + "requestMs": 38, + "firstVisibleEstimateMs": 221.40000000596046, + "runtimeResponseEnd": 6.5 + }, + "nativeRequests": 1 + } + ] + }, + { + "name": "mobile-cold", + "conditions": { + "runtimeDelayMs": 0, + "cpuRate": 4, + "cache": "cold", + "network": { + "offline": false, + "latency": 100, + "downloadThroughput": 200000, + "uploadThroughput": 93750 + } + }, + "sourceEvidence": { + "samplesSha256": "d7aac719372c779b414b4c44ec5fc5a333465cd667d386106e232f88d2c49780", + "artifactsSha256": "fbed590bf9480377471835a770360789588d5c37274845212e7076077f1d400a", + "runnerSha256": "6cc475663feb947ee532b5bcb89734b08926210e65567af22cccc739ec1caaf5", + "metricsSha256": "886359ad58e86f8baca93b178898148992ca5d897906cc081c720a9d099e4118", + "htmlSha256": "6c842586f4060f871e74080243152707a3ea49c9f63ca4e695b316c92fc71500", + "optionsSha256": "69aa6cbaef2cf2dfa375baa1baa8e752dd023624d4ad84409714a24054d1be1c", + "evidenceSha256": "25d21e7e061e68281eaf41b5306f533915ef92257257439216050a0a2d1506e5" + }, + "functionalSamples": 64, + "negativeControls": { + "parser-blocking": { + "status": "passed", + "sampleIndex": 44, + "htmlFile": "44-combined-inline-390-slow-parser-blocking.html" + }, + "reservation-removed": { + "status": "passed", + "sampleIndex": 45, + "htmlFile": "45-combined-inline-390-slow-reservation-removed.html" + } + }, + "measurements": [ + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 960, + "lcp": 960, + "cls": 0.20706520518407043, + "domInteractive": 954.7000000029802, + "domContentLoaded": 956.4000000059605, + "requestMs": 979, + "firstVisibleEstimateMs": 1343.1000000089407, + "runtimeResponseEnd": 897.9000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 824, + "lcp": 824, + "cls": 0.20706520518407043, + "domInteractive": 807.5, + "domContentLoaded": 1185.7000000029802, + "requestMs": 1202, + "firstVisibleEstimateMs": 1575.1000000089407, + "runtimeResponseEnd": 1177 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 956, + "lcp": 956, + "cls": 0, + "domInteractive": 950.4000000059605, + "domContentLoaded": 952.1000000089407, + "requestMs": 1012, + "firstVisibleEstimateMs": 1380.5, + "runtimeResponseEnd": 891.2000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 792, + "lcp": 792, + "cls": 0, + "domInteractive": 764.7999999970198, + "domContentLoaded": 1165.9000000059605, + "requestMs": 1202, + "firstVisibleEstimateMs": 1549.5, + "runtimeResponseEnd": 1159.4000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 800, + "lcp": 800, + "cls": 0, + "domInteractive": 786.7000000029802, + "domContentLoaded": 1186.8999999910593, + "requestMs": 1209, + "firstVisibleEstimateMs": 1565.5, + "runtimeResponseEnd": 1175.3999999910593 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 944, + "lcp": 944, + "cls": 0, + "domInteractive": 937.9000000059605, + "domContentLoaded": 939.1000000089407, + "requestMs": 984, + "firstVisibleEstimateMs": 1350, + "runtimeResponseEnd": 891.2000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 812, + "lcp": 812, + "cls": 0.20706520518407043, + "domInteractive": 776, + "domContentLoaded": 1172.5999999940395, + "requestMs": 1195, + "firstVisibleEstimateMs": 1579.9000000059605, + "runtimeResponseEnd": 1162 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 936, + "lcp": 936, + "cls": 0.20706520518407043, + "domInteractive": 927.6999999880791, + "domContentLoaded": 928.5999999940395, + "requestMs": 961, + "firstVisibleEstimateMs": 1300.8999999910593, + "runtimeResponseEnd": 893.3999999910593 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 932, + "lcp": 932, + "cls": 0.20706520518407043, + "domInteractive": 927.7000000029802, + "domContentLoaded": 928.7000000029802, + "requestMs": 967, + "firstVisibleEstimateMs": 1318.7000000029802, + "runtimeResponseEnd": 892.7999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 800, + "lcp": 800, + "cls": 0.20706520518407043, + "domInteractive": 785.7000000029802, + "domContentLoaded": 1170.2999999970198, + "requestMs": 1209, + "firstVisibleEstimateMs": 1552.4000000059605, + "runtimeResponseEnd": 1164 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 956, + "lcp": 956, + "cls": 0, + "domInteractive": 948.5999999940395, + "domContentLoaded": 950.5999999940395, + "requestMs": 976, + "firstVisibleEstimateMs": 1347.199999988079, + "runtimeResponseEnd": 898 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 796, + "lcp": 796, + "cls": 0, + "domInteractive": 790.5999999940395, + "domContentLoaded": 1177.2999999970198, + "requestMs": 1199, + "firstVisibleEstimateMs": 1574.0999999940395, + "runtimeResponseEnd": 1168.8999999910593 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 792, + "lcp": 792, + "cls": 0, + "domInteractive": 789.7000000029802, + "domContentLoaded": 1177.7999999970198, + "requestMs": 1194, + "firstVisibleEstimateMs": 1570.2000000029802, + "runtimeResponseEnd": 1167.5 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 932, + "lcp": 932, + "cls": 0, + "domInteractive": 926.6000000089407, + "domContentLoaded": 927.4000000059605, + "requestMs": 959, + "firstVisibleEstimateMs": 1305.4000000059605, + "runtimeResponseEnd": 893.1000000089407 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 796, + "lcp": 796, + "cls": 0.20706520518407043, + "domInteractive": 766.4000000059605, + "domContentLoaded": 1170, + "requestMs": 1185, + "firstVisibleEstimateMs": 1537.5, + "runtimeResponseEnd": 1163.0999999940395 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 960, + "lcp": 960, + "cls": 0.20706520518407043, + "domInteractive": 952.6000000089407, + "domContentLoaded": 953.6000000089407, + "requestMs": 995, + "firstVisibleEstimateMs": 1340.1000000089407, + "runtimeResponseEnd": 914.1000000089407 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 936, + "lcp": 936, + "cls": 0.20706520518407043, + "domInteractive": 929.9000000059605, + "domContentLoaded": 930.5, + "requestMs": 968, + "firstVisibleEstimateMs": 1333.5, + "runtimeResponseEnd": 890.7000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 788, + "lcp": 788, + "cls": 0.20706520518407043, + "domInteractive": 776, + "domContentLoaded": 1172, + "requestMs": 1199, + "firstVisibleEstimateMs": 1557.0999999940395, + "runtimeResponseEnd": 1161.8999999910593 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 948, + "lcp": 948, + "cls": 0, + "domInteractive": 940.7999999970198, + "domContentLoaded": 942.0999999940395, + "requestMs": 990, + "firstVisibleEstimateMs": 1354.8999999910593, + "runtimeResponseEnd": 894.7999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 768, + "lcp": 768, + "cls": 0, + "domInteractive": 768.6000000089407, + "domContentLoaded": 1166.2999999970198, + "requestMs": 1178, + "firstVisibleEstimateMs": 1517.2000000029802, + "runtimeResponseEnd": 1160 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 944, + "lcp": 944, + "cls": 0.11113281249999998, + "domInteractive": 929, + "domContentLoaded": 930.3999999910593, + "requestMs": 946, + "firstVisibleEstimateMs": 1286.5, + "runtimeResponseEnd": 890.7999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 824, + "lcp": 824, + "cls": 0.11113281249999998, + "domInteractive": 805.6999999880791, + "domContentLoaded": 1191.8999999910593, + "requestMs": 1239, + "firstVisibleEstimateMs": 1594.699999988079, + "runtimeResponseEnd": 1182.5 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 972, + "lcp": 972, + "cls": 0, + "domInteractive": 958, + "domContentLoaded": 959.6999999880791, + "requestMs": 984, + "firstVisibleEstimateMs": 1367.7999999970198, + "runtimeResponseEnd": 895.8999999910593 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 792, + "lcp": 792, + "cls": 0, + "domInteractive": 776.2999999970198, + "domContentLoaded": 1170.9000000059605, + "requestMs": 1201, + "firstVisibleEstimateMs": 1571, + "runtimeResponseEnd": 1161.5999999940395 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 820, + "lcp": 820, + "cls": 0, + "domInteractive": 782.1000000089407, + "domContentLoaded": 1186.2000000029802, + "requestMs": 1214, + "firstVisibleEstimateMs": 1580.5, + "runtimeResponseEnd": 1174.7000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 956, + "lcp": 956, + "cls": 0, + "domInteractive": 945.7000000029802, + "domContentLoaded": 948, + "requestMs": 1013, + "firstVisibleEstimateMs": 1390.9000000059605, + "runtimeResponseEnd": 890.4000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 836, + "lcp": 836, + "cls": 0.11113281249999998, + "domInteractive": 789.0999999940395, + "domContentLoaded": 1183.0999999940395, + "requestMs": 1202, + "firstVisibleEstimateMs": 1573.3999999910593, + "runtimeResponseEnd": 1174.199999988079 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 952, + "lcp": 952, + "cls": 0.11113281249999998, + "domInteractive": 943.5999999940395, + "domContentLoaded": 944.5999999940395, + "requestMs": 991, + "firstVisibleEstimateMs": 1346.3999999910593, + "runtimeResponseEnd": 894.2999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 936, + "lcp": 936, + "cls": 0.11113281249999998, + "domInteractive": 926.7000000029802, + "domContentLoaded": 928, + "requestMs": 961, + "firstVisibleEstimateMs": 1305.4000000059605, + "runtimeResponseEnd": 892.4000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 820, + "lcp": 820, + "cls": 0.11113281249999998, + "domInteractive": 801.2000000029802, + "domContentLoaded": 1177.6000000089407, + "requestMs": 1193, + "firstVisibleEstimateMs": 1573.1000000089407, + "runtimeResponseEnd": 1167.9000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 936, + "lcp": 936, + "cls": 0, + "domInteractive": 926.7000000029802, + "domContentLoaded": 927.6000000089407, + "requestMs": 964, + "firstVisibleEstimateMs": 1317.800000011921, + "runtimeResponseEnd": 891.3000000119209 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 852, + "lcp": 852, + "cls": 0, + "domInteractive": 794.5999999940395, + "domContentLoaded": 1192.199999988079, + "requestMs": 1215, + "firstVisibleEstimateMs": 1565.199999988079, + "runtimeResponseEnd": 1181.2999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 812, + "lcp": 812, + "cls": 0, + "domInteractive": 790.2000000029802, + "domContentLoaded": 1183.5, + "requestMs": 1201, + "firstVisibleEstimateMs": 1554.2999999970198, + "runtimeResponseEnd": 1173.7999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 960, + "lcp": 960, + "cls": 0, + "domInteractive": 950.8000000119209, + "domContentLoaded": 952.1000000089407, + "requestMs": 969, + "firstVisibleEstimateMs": 1334.6000000089407, + "runtimeResponseEnd": 892 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 824, + "lcp": 824, + "cls": 0.11113281249999998, + "domInteractive": 806.6999999880791, + "domContentLoaded": 1181.5999999940395, + "requestMs": 1201, + "firstVisibleEstimateMs": 1567.7999999970198, + "runtimeResponseEnd": 1172 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 956, + "lcp": 956, + "cls": 0.11113281249999998, + "domInteractive": 947.8999999910593, + "domContentLoaded": 948.8999999910593, + "requestMs": 967, + "firstVisibleEstimateMs": 1331.2999999970198, + "runtimeResponseEnd": 898.5999999940395 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 936, + "lcp": 936, + "cls": 0.11113281249999998, + "domInteractive": 926.2000000029802, + "domContentLoaded": 927.2000000029802, + "requestMs": 941, + "firstVisibleEstimateMs": 1293.3999999910593, + "runtimeResponseEnd": 893.2000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 848, + "lcp": 848, + "cls": 0.11113281249999998, + "domInteractive": 827.7000000029802, + "domContentLoaded": 1199, + "requestMs": 1215, + "firstVisibleEstimateMs": 1574.2000000029802, + "runtimeResponseEnd": 1189.300000011921 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 952, + "lcp": 952, + "cls": 0, + "domInteractive": 940.6999999880791, + "domContentLoaded": 941.5999999940395, + "requestMs": 960, + "firstVisibleEstimateMs": 1332.2999999970198, + "runtimeResponseEnd": 891.5999999940395 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 812, + "lcp": 812, + "cls": 0, + "domInteractive": 791.5, + "domContentLoaded": 1184.6000000089407, + "requestMs": 1209, + "firstVisibleEstimateMs": 1575, + "runtimeResponseEnd": 1174.7999999970198 + }, + "nativeRequests": 1 + } + ] + }, + { + "name": "mobile-warm", + "conditions": { + "runtimeDelayMs": 0, + "cpuRate": 4, + "cache": "warm", + "network": { + "offline": false, + "latency": 100, + "downloadThroughput": 200000, + "uploadThroughput": 93750 + } + }, + "sourceEvidence": { + "samplesSha256": "ae3d33d3f28aeefd1c1a3cbe8cf9aa9f5d8ed41ea0a736085ef72fcb61113814", + "artifactsSha256": "7472a17f0ae997f8915ae58f2ee47b1fa3203a156fe1313087526c5d57685d0d", + "runnerSha256": "6cc475663feb947ee532b5bcb89734b08926210e65567af22cccc739ec1caaf5", + "metricsSha256": "886359ad58e86f8baca93b178898148992ca5d897906cc081c720a9d099e4118", + "htmlSha256": "6c842586f4060f871e74080243152707a3ea49c9f63ca4e695b316c92fc71500", + "optionsSha256": "69aa6cbaef2cf2dfa375baa1baa8e752dd023624d4ad84409714a24054d1be1c", + "evidenceSha256": "25d21e7e061e68281eaf41b5306f533915ef92257257439216050a0a2d1506e5" + }, + "functionalSamples": 64, + "negativeControls": { + "parser-blocking": { + "status": "passed", + "sampleIndex": 44, + "htmlFile": "44-combined-inline-390-slow-parser-blocking.html" + }, + "reservation-removed": { + "status": "passed", + "sampleIndex": 45, + "htmlFile": "45-combined-inline-390-slow-reservation-removed.html" + } + }, + "measurements": [ + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 352, + "lcp": 352, + "cls": 0.20706520518407043, + "domInteractive": 318, + "domContentLoaded": 320, + "requestMs": 355, + "firstVisibleEstimateMs": 569, + "runtimeResponseEnd": 298.59999999403954 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 764, + "lcp": 764, + "cls": 0.20706520518407043, + "domInteractive": 754.2999999970198, + "domContentLoaded": 758.4000000059605, + "requestMs": 785, + "firstVisibleEstimateMs": 979.6000000089407, + "runtimeResponseEnd": 752.2000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0, + "domInteractive": 338.70000000298023, + "domContentLoaded": 340.70000000298023, + "requestMs": 354, + "firstVisibleEstimateMs": 561.2000000029802, + "runtimeResponseEnd": 298.90000000596046 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 764, + "lcp": 796, + "cls": 0, + "domInteractive": 781.2999999970198, + "domContentLoaded": 789.5999999940395, + "requestMs": 835, + "firstVisibleEstimateMs": 1061.3999999910593, + "runtimeResponseEnd": 751.8999999910593 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 756, + "lcp": 756, + "cls": 0, + "domInteractive": 759.1999999880791, + "domContentLoaded": 762.3999999910593, + "requestMs": 784, + "firstVisibleEstimateMs": 989.0999999940395, + "runtimeResponseEnd": 745.5 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0, + "domInteractive": 338.19999998807907, + "domContentLoaded": 339.3999999910593, + "requestMs": 357, + "firstVisibleEstimateMs": 578.5, + "runtimeResponseEnd": 298.79999999701977 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 756, + "lcp": 756, + "cls": 0.20706520518407043, + "domInteractive": 756.5, + "domContentLoaded": 760, + "requestMs": 784, + "firstVisibleEstimateMs": 981.7000000029802, + "runtimeResponseEnd": 746.7999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0.20706520518407043, + "domInteractive": 314.1000000089407, + "domContentLoaded": 315.79999999701977, + "requestMs": 351, + "firstVisibleEstimateMs": 550, + "runtimeResponseEnd": 296.5 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0.20706520518407043, + "domInteractive": 321.40000000596046, + "domContentLoaded": 323.40000000596046, + "requestMs": 351, + "firstVisibleEstimateMs": 559.6000000089407, + "runtimeResponseEnd": 299.6000000089407 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 760, + "lcp": 760, + "cls": 0.20706520518407043, + "domInteractive": 759.6000000089407, + "domContentLoaded": 764.6000000089407, + "requestMs": 799, + "firstVisibleEstimateMs": 1000.9000000059605, + "runtimeResponseEnd": 747 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 340, + "lcp": 340, + "cls": 0, + "domInteractive": 330.70000000298023, + "domContentLoaded": 333.09999999403954, + "requestMs": 347, + "firstVisibleEstimateMs": 546.5999999940395, + "runtimeResponseEnd": 295.5 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 756, + "lcp": 756, + "cls": 0, + "domInteractive": 763.7999999970198, + "domContentLoaded": 767.5, + "requestMs": 798, + "firstVisibleEstimateMs": 1009.5, + "runtimeResponseEnd": 745.2999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 752, + "lcp": 752, + "cls": 0, + "domInteractive": 756.9000000059605, + "domContentLoaded": 759.3000000119209, + "requestMs": 779, + "firstVisibleEstimateMs": 995.8000000119209, + "runtimeResponseEnd": 745.1000000089407 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0, + "domInteractive": 336.40000000596046, + "domContentLoaded": 338.90000000596046, + "requestMs": 360, + "firstVisibleEstimateMs": 558.9000000059605, + "runtimeResponseEnd": 299.40000000596046 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 756, + "lcp": 756, + "cls": 0.20706520518407043, + "domInteractive": 758.0999999940395, + "domContentLoaded": 761, + "requestMs": 783, + "firstVisibleEstimateMs": 980.7000000029802, + "runtimeResponseEnd": 748.9000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0.20706520518407043, + "domInteractive": 312.5, + "domContentLoaded": 313.90000000596046, + "requestMs": 347, + "firstVisibleEstimateMs": 547.5, + "runtimeResponseEnd": 295.5 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 312, + "lcp": 312, + "cls": 0.20706520518407043, + "domInteractive": 313.79999999701977, + "domContentLoaded": 313.79999999701977, + "requestMs": 339, + "firstVisibleEstimateMs": 536.2999999970198, + "runtimeResponseEnd": 298 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 760, + "lcp": 760, + "cls": 0.20706520518407043, + "domInteractive": 761.3999999910593, + "domContentLoaded": 766.3999999910593, + "requestMs": 804, + "firstVisibleEstimateMs": 1027.2999999970198, + "runtimeResponseEnd": 747.5 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 336, + "lcp": 336, + "cls": 0, + "domInteractive": 332.59999999403954, + "domContentLoaded": 333.5, + "requestMs": 359, + "firstVisibleEstimateMs": 589.0999999940395, + "runtimeResponseEnd": 294.8999999910593 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 764, + "lcp": 764, + "cls": 0, + "domInteractive": 771.9000000059605, + "domContentLoaded": 776.1000000089407, + "requestMs": 805, + "firstVisibleEstimateMs": 1008.3000000119209, + "runtimeResponseEnd": 753.3000000119209 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 360, + "lcp": 360, + "cls": 0.11113281249999998, + "domInteractive": 325.79999999701977, + "domContentLoaded": 328, + "requestMs": 357, + "firstVisibleEstimateMs": 563.7000000029802, + "runtimeResponseEnd": 302.5 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 772, + "lcp": 772, + "cls": 0.11113281249999998, + "domInteractive": 756.2999999970198, + "domContentLoaded": 762.5999999940395, + "requestMs": 791, + "firstVisibleEstimateMs": 990, + "runtimeResponseEnd": 753.5999999940395 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 352, + "lcp": 352, + "cls": 0, + "domInteractive": 337.40000000596046, + "domContentLoaded": 339.70000000298023, + "requestMs": 354, + "firstVisibleEstimateMs": 563.7000000029802, + "runtimeResponseEnd": 296.70000000298023 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 764, + "lcp": 764, + "cls": 0, + "domInteractive": 771.7999999970198, + "domContentLoaded": 778.2999999970198, + "requestMs": 820, + "firstVisibleEstimateMs": 1034.7000000029802, + "runtimeResponseEnd": 747.1000000089407 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 760, + "lcp": 760, + "cls": 0, + "domInteractive": 763.3999999910593, + "domContentLoaded": 766.5999999940395, + "requestMs": 786, + "firstVisibleEstimateMs": 991.7999999970198, + "runtimeResponseEnd": 748.7999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 352, + "lcp": 352, + "cls": 0, + "domInteractive": 332.90000000596046, + "domContentLoaded": 334.6000000089407, + "requestMs": 349, + "firstVisibleEstimateMs": 562.5, + "runtimeResponseEnd": 295.40000000596046 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 768, + "lcp": 768, + "cls": 0.11113281249999998, + "domInteractive": 754.4000000059605, + "domContentLoaded": 758.2000000029802, + "requestMs": 783, + "firstVisibleEstimateMs": 980.6000000089407, + "runtimeResponseEnd": 752.4000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 356, + "lcp": 356, + "cls": 0.11113281249999998, + "domInteractive": 324.29999999701977, + "domContentLoaded": 326.3999999910593, + "requestMs": 355, + "firstVisibleEstimateMs": 562.7000000029802, + "runtimeResponseEnd": 299.70000000298023 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 368, + "lcp": 368, + "cls": 0.11113281249999998, + "domInteractive": 329.80000001192093, + "domContentLoaded": 332.1000000089407, + "requestMs": 374, + "firstVisibleEstimateMs": 575.2000000029802, + "runtimeResponseEnd": 300.1000000089407 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 764, + "lcp": 764, + "cls": 0.11113281249999998, + "domInteractive": 750.8000000119209, + "domContentLoaded": 755.2000000029802, + "requestMs": 783, + "firstVisibleEstimateMs": 981.1000000089407, + "runtimeResponseEnd": 749 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0, + "domInteractive": 335, + "domContentLoaded": 337, + "requestMs": 350, + "firstVisibleEstimateMs": 562.7999999970198, + "runtimeResponseEnd": 298.59999999403954 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 764, + "lcp": 764, + "cls": 0, + "domInteractive": 768.1999999880791, + "domContentLoaded": 772.6999999880791, + "requestMs": 803, + "firstVisibleEstimateMs": 1007.5, + "runtimeResponseEnd": 749.2999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 764, + "lcp": 764, + "cls": 0, + "domInteractive": 768.8999999910593, + "domContentLoaded": 774, + "requestMs": 807, + "firstVisibleEstimateMs": 1018.0999999940395, + "runtimeResponseEnd": 747.5999999940395 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 352, + "lcp": 352, + "cls": 0, + "domInteractive": 340, + "domContentLoaded": 342.79999999701977, + "requestMs": 361, + "firstVisibleEstimateMs": 572.3999999910593, + "runtimeResponseEnd": 293.29999999701977 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 772, + "lcp": 772, + "cls": 0.11113281249999998, + "domInteractive": 753.2999999970198, + "domContentLoaded": 758.0999999940395, + "requestMs": 785, + "firstVisibleEstimateMs": 981.5, + "runtimeResponseEnd": 751 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 352, + "lcp": 352, + "cls": 0.11113281249999998, + "domInteractive": 319.1000000089407, + "domContentLoaded": 320.90000000596046, + "requestMs": 351, + "firstVisibleEstimateMs": 593.8000000119209, + "runtimeResponseEnd": 298 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 352, + "lcp": 352, + "cls": 0.11113281249999998, + "domInteractive": 312.90000000596046, + "domContentLoaded": 314.90000000596046, + "requestMs": 349, + "firstVisibleEstimateMs": 559, + "runtimeResponseEnd": 294.29999999701977 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 780, + "lcp": 844, + "cls": 0.11113281249999998, + "domInteractive": 812.2000000029802, + "domContentLoaded": 819.8000000119209, + "requestMs": 839, + "firstVisibleEstimateMs": 1053.5, + "runtimeResponseEnd": 760.4000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 348, + "lcp": 348, + "cls": 0, + "domInteractive": 334.09999999403954, + "domContentLoaded": 335.79999999701977, + "requestMs": 350, + "firstVisibleEstimateMs": 569.5999999940395, + "runtimeResponseEnd": 297 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 768, + "lcp": 768, + "cls": 0, + "domInteractive": 773.7000000029802, + "domContentLoaded": 779.0999999940395, + "requestMs": 814, + "firstVisibleEstimateMs": 1026.7000000029802, + "runtimeResponseEnd": 751.4000000059605 + }, + "nativeRequests": 1 + } + ] + }, + { + "name": "delayed-cold", + "conditions": { + "runtimeDelayMs": 750, + "cpuRate": 1, + "cache": "cold", + "network": { + "offline": false, + "latency": 0, + "downloadThroughput": -1, + "uploadThroughput": -1 + } + }, + "sourceEvidence": { + "samplesSha256": "11999528603313980817d111da36187b41604d063c47c9c2fbd2b11c4843bacb", + "artifactsSha256": "78c703221b1306f60c7c1c5e56186ee9b436e1b9f4f38b31bb909bd4153d3928", + "runnerSha256": "6cc475663feb947ee532b5bcb89734b08926210e65567af22cccc739ec1caaf5", + "metricsSha256": "886359ad58e86f8baca93b178898148992ca5d897906cc081c720a9d099e4118", + "htmlSha256": "6c842586f4060f871e74080243152707a3ea49c9f63ca4e695b316c92fc71500", + "optionsSha256": "69aa6cbaef2cf2dfa375baa1baa8e752dd023624d4ad84409714a24054d1be1c", + "evidenceSha256": "25d21e7e061e68281eaf41b5306f533915ef92257257439216050a0a2d1506e5" + }, + "functionalSamples": 64, + "negativeControls": { + "parser-blocking": { + "status": "passed", + "sampleIndex": 44, + "htmlFile": "44-combined-inline-390-slow-parser-blocking.html" + }, + "reservation-removed": { + "status": "passed", + "sampleIndex": 45, + "htmlFile": "45-combined-inline-390-slow-reservation-removed.html" + } + }, + "measurements": [ + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 796, + "lcp": 796, + "cls": 0.20706520518407043, + "domInteractive": 787.9000000059605, + "domContentLoaded": 788.3000000119209, + "requestMs": 810, + "firstVisibleEstimateMs": 1008.7000000029802, + "runtimeResponseEnd": 769.6000000089407 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 20, + "lcp": 20, + "cls": 0.20706520518407043, + "domInteractive": 14.400000005960464, + "domContentLoaded": 763.8000000119209, + "requestMs": 782, + "firstVisibleEstimateMs": 978.9000000059605, + "runtimeResponseEnd": 759.9000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 784, + "lcp": 784, + "cls": 0, + "domInteractive": 775.9000000059605, + "domContentLoaded": 776.2999999970198, + "requestMs": 804, + "firstVisibleEstimateMs": 985.7999999970198, + "runtimeResponseEnd": 762.7999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 22.200000002980232, + "domContentLoaded": 765, + "requestMs": 773, + "firstVisibleEstimateMs": 971.5999999940395, + "runtimeResponseEnd": 761.2000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0, + "domInteractive": 20.80000001192093, + "domContentLoaded": 763.7000000029802, + "requestMs": 769, + "firstVisibleEstimateMs": 951.4000000059605, + "runtimeResponseEnd": 760.4000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 784, + "lcp": 784, + "cls": 0, + "domInteractive": 776.2999999970198, + "domContentLoaded": 776.4000000059605, + "requestMs": 802, + "firstVisibleEstimateMs": 1000.0999999940395, + "runtimeResponseEnd": 763.4000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.20706520518407043, + "domInteractive": 21.299999997019768, + "domContentLoaded": 762.2999999970198, + "requestMs": 779, + "firstVisibleEstimateMs": 975.7999999970198, + "runtimeResponseEnd": 758.5 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 780, + "lcp": 780, + "cls": 0.20706520518407043, + "domInteractive": 775.5, + "domContentLoaded": 775.7999999970198, + "requestMs": 800, + "firstVisibleEstimateMs": 999.2000000029802, + "runtimeResponseEnd": 761.5999999940395 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 776, + "lcp": 776, + "cls": 0.20706520518407043, + "domInteractive": 771.2999999970198, + "domContentLoaded": 771.5, + "requestMs": 791, + "firstVisibleEstimateMs": 990, + "runtimeResponseEnd": 758.5999999940395 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 18.700000002980232, + "domContentLoaded": 764.1000000089407, + "requestMs": 770, + "firstVisibleEstimateMs": 968.5, + "runtimeResponseEnd": 759.4000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 780, + "lcp": 780, + "cls": 0, + "domInteractive": 773.4000000059605, + "domContentLoaded": 773.7000000029802, + "requestMs": 803, + "firstVisibleEstimateMs": 985.0999999940395, + "runtimeResponseEnd": 759.2000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 22.200000002980232, + "domContentLoaded": 763.3000000119209, + "requestMs": 779, + "firstVisibleEstimateMs": 975, + "runtimeResponseEnd": 758.9000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0, + "domInteractive": 21.700000002980232, + "domContentLoaded": 764.7000000029802, + "requestMs": 780, + "firstVisibleEstimateMs": 976.6000000089407, + "runtimeResponseEnd": 761.2000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 784, + "lcp": 784, + "cls": 0, + "domInteractive": 776.2000000029802, + "domContentLoaded": 776.5, + "requestMs": 807, + "firstVisibleEstimateMs": 988.5, + "runtimeResponseEnd": 762.1000000089407 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 23.200000002980232, + "domContentLoaded": 764, + "requestMs": 768, + "firstVisibleEstimateMs": 966.6000000089407, + "runtimeResponseEnd": 760.8000000119209 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 780, + "lcp": 780, + "cls": 0.20706520518407043, + "domInteractive": 772.7000000029802, + "domContentLoaded": 772.9000000059605, + "requestMs": 797, + "firstVisibleEstimateMs": 995.7999999970198, + "runtimeResponseEnd": 760 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 780, + "lcp": 780, + "cls": 0.20706520518407043, + "domInteractive": 771.6999999880791, + "domContentLoaded": 772, + "requestMs": 796, + "firstVisibleEstimateMs": 978, + "runtimeResponseEnd": 757.7999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 28, + "lcp": 28, + "cls": 0.20706520518407043, + "domInteractive": 22.400000005960464, + "domContentLoaded": 765.3000000119209, + "requestMs": 781, + "firstVisibleEstimateMs": 975.4000000059605, + "runtimeResponseEnd": 761 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 780, + "lcp": 780, + "cls": 0, + "domInteractive": 775.4000000059605, + "domContentLoaded": 775.7000000029802, + "requestMs": 806, + "firstVisibleEstimateMs": 1004.4000000059605, + "runtimeResponseEnd": 760.2000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 390, + "height": 844 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0, + "domInteractive": 23.100000008940697, + "domContentLoaded": 764.3000000119209, + "requestMs": 781, + "firstVisibleEstimateMs": 974.9000000059605, + "runtimeResponseEnd": 760.3000000119209 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 784, + "lcp": 784, + "cls": 0.11113281249999998, + "domInteractive": 772.2000000029802, + "domContentLoaded": 772.4000000059605, + "requestMs": 792, + "firstVisibleEstimateMs": 990.6000000089407, + "runtimeResponseEnd": 757.9000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 20.700000002980232, + "domContentLoaded": 764.9000000059605, + "requestMs": 781, + "firstVisibleEstimateMs": 977, + "runtimeResponseEnd": 760.5 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 784, + "lcp": 784, + "cls": 0, + "domInteractive": 771.9000000059605, + "domContentLoaded": 772.2000000029802, + "requestMs": 793, + "firstVisibleEstimateMs": 975.0999999940395, + "runtimeResponseEnd": 758.0999999940395 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 48, + "lcp": 48, + "cls": 0, + "domInteractive": 23.099999994039536, + "domContentLoaded": 766.5999999940395, + "requestMs": 785, + "firstVisibleEstimateMs": 966.7999999970198, + "runtimeResponseEnd": 762.2999999970198 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 44, + "lcp": 44, + "cls": 0, + "domInteractive": 18.599999994039536, + "domContentLoaded": 763.7999999970198, + "requestMs": 783, + "firstVisibleEstimateMs": 964.5999999940395, + "runtimeResponseEnd": 759.5 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 784, + "lcp": 784, + "cls": 0, + "domInteractive": 773, + "domContentLoaded": 773.2000000029802, + "requestMs": 797, + "firstVisibleEstimateMs": 978.4000000059605, + "runtimeResponseEnd": 758.9000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0.11113281249999998, + "domInteractive": 20.799999997019768, + "domContentLoaded": 765.7999999970198, + "requestMs": 771, + "firstVisibleEstimateMs": 969.8999999910593, + "runtimeResponseEnd": 759.8999999910593 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 784, + "lcp": 784, + "cls": 0.11113281249999998, + "domInteractive": 771.7000000029802, + "domContentLoaded": 772.0999999940395, + "requestMs": 794, + "firstVisibleEstimateMs": 992.5999999940395, + "runtimeResponseEnd": 758.2000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 784, + "lcp": 784, + "cls": 0.11113281249999998, + "domInteractive": 772.0999999940395, + "domContentLoaded": 772.3999999910593, + "requestMs": 795, + "firstVisibleEstimateMs": 976.7000000029802, + "runtimeResponseEnd": 759.0999999940395 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0.11113281249999998, + "domInteractive": 24.900000005960464, + "domContentLoaded": 766.2000000029802, + "requestMs": 781, + "firstVisibleEstimateMs": 973.8000000119209, + "runtimeResponseEnd": 761.7000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 784, + "lcp": 784, + "cls": 0, + "domInteractive": 772.7000000029802, + "domContentLoaded": 772.8999999910593, + "requestMs": 799, + "firstVisibleEstimateMs": 997.2000000029802, + "runtimeResponseEnd": 758.7000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 15.5, + "domContentLoaded": 762.7000000029802, + "requestMs": 767, + "firstVisibleEstimateMs": 949.8000000119209, + "runtimeResponseEnd": 759.5 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 32, + "lcp": 32, + "cls": 0, + "domInteractive": 18.100000008940697, + "domContentLoaded": 761.9000000059605, + "requestMs": 767, + "firstVisibleEstimateMs": 966, + "runtimeResponseEnd": 758 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 788, + "lcp": 788, + "cls": 0, + "domInteractive": 774.7000000029802, + "domContentLoaded": 775, + "requestMs": 798, + "firstVisibleEstimateMs": 980.0999999940395, + "runtimeResponseEnd": 760.5 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 52, + "lcp": 52, + "cls": 0.11113281249999998, + "domInteractive": 27, + "domContentLoaded": 767.9000000059605, + "requestMs": 773, + "firstVisibleEstimateMs": 971.5999999940395, + "runtimeResponseEnd": 763.5 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 788, + "lcp": 788, + "cls": 0.11113281249999998, + "domInteractive": 777.1000000089407, + "domContentLoaded": 777.3000000119209, + "requestMs": 801, + "firstVisibleEstimateMs": 999.7000000029802, + "runtimeResponseEnd": 762.4000000059605 + }, + "nativeRequests": 1 + }, + { + "arm": "baseline", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 784, + "lcp": 784, + "cls": 0.11113281249999998, + "domInteractive": 771.5999999940395, + "domContentLoaded": 771.8999999910593, + "requestMs": 791, + "firstVisibleEstimateMs": 972.6999999880791, + "runtimeResponseEnd": 756.6999999880791 + }, + "nativeRequests": 1 + }, + { + "arm": "loading", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0.11113281249999998, + "domInteractive": 21.700000002980232, + "domContentLoaded": 764.7999999970198, + "requestMs": 773, + "firstVisibleEstimateMs": 967.6000000089407, + "runtimeResponseEnd": 760.7000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "reservation", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 780, + "lcp": 780, + "cls": 0, + "domInteractive": 769.1000000089407, + "domContentLoaded": 769.3000000119209, + "requestMs": 798, + "firstVisibleEstimateMs": 980.1000000089407, + "runtimeResponseEnd": 757.2000000029802 + }, + "nativeRequests": 1 + }, + { + "arm": "combined", + "viewport": { + "width": 1280, + "height": 1000 + }, + "metrics": { + "fcp": 36, + "lcp": 36, + "cls": 0, + "domInteractive": 21.200000002980232, + "domContentLoaded": 763.7999999970198, + "requestMs": 780, + "firstVisibleEstimateMs": 972.0999999940395, + "runtimeResponseEnd": 759.8999999910593 + }, + "nativeRequests": 1 + } + ] + } + ], + "postMeasurementValidation": { + "originalBaselineProductionSources": { + "crates/trusted-server-core/src/html_processor.rs": "f0142d05d153603f3ac96a002fb625a73485ce9125194d5852f1628bd6941a2c", + "crates/trusted-server-core/src/tsjs.rs": "d431d59f523f2ac3d22f497a3e67a6bfb77fa69b8e645a057286233ebafc4a26", + "crates/trusted-server-core/src/integrations/gpt_bootstrap.js": "ac5f89676ae9d6d7ee6a74feab2f70948e9ad59860192cc82a03a8d965580110" + }, + "typeOnlyCleanup": { + "baselineTypeScriptDiagnostics": 321, + "beforeCandidateDiagnostics": 323, + "afterCandidateDiagnostics": 315, + "newOrIncreasedDiagnosticSignatures": 0, + "allProductionJsAndTraceBytesIdentical": true, + "productionModules": 14, + "files": [ + { + "file": "trace/v1.css", + "frozenSha256": "d3289f4dab38cf1321a07aa6c8e7989eb1c96a6885656a68d7faab8b94a1006b", + "builtSha256": "d3289f4dab38cf1321a07aa6c8e7989eb1c96a6885656a68d7faab8b94a1006b", + "byteIdentical": true, + "bytes": 6233 + }, + { + "file": "trace/v1.js", + "frozenSha256": "5f52f94ae50cdcd93d838c10441b72263001615c92f830a32f1e7ab22b9cf009", + "builtSha256": "5f52f94ae50cdcd93d838c10441b72263001615c92f830a32f1e7ab22b9cf009", + "byteIdentical": true, + "bytes": 45726 + }, + { + "file": "tsjs-bootstrap.js", + "frozenSha256": "7e39e93e1717d0f2aec4657a2876f648eeaba47d32fadf6f2aa75671db8579f1", + "builtSha256": "7e39e93e1717d0f2aec4657a2876f648eeaba47d32fadf6f2aa75671db8579f1", + "byteIdentical": true, + "bytes": 69512 + }, + { + "file": "tsjs-core.js", + "frozenSha256": "116c4626cb6ff5af932117c99d2926da9bf38d7e36b3258c3dc56c70870e870e", + "builtSha256": "116c4626cb6ff5af932117c99d2926da9bf38d7e36b3258c3dc56c70870e870e", + "byteIdentical": true, + "bytes": 33359 + }, + { + "file": "tsjs-creative.js", + "frozenSha256": "d4fb6562058ca59d14fde1ccf263ab5c85fabe963501aa09c8330b84fbfe5cac", + "builtSha256": "d4fb6562058ca59d14fde1ccf263ab5c85fabe963501aa09c8330b84fbfe5cac", + "byteIdentical": true, + "bytes": 14830 + }, + { + "file": "tsjs-datadome.js", + "frozenSha256": "d3b2166f1dc38cf2183aa0ac6479ab7ccea9fb78433054de0b64f4768971b4b4", + "builtSha256": "d3b2166f1dc38cf2183aa0ac6479ab7ccea9fb78433054de0b64f4768971b4b4", + "byteIdentical": true, + "bytes": 6568 + }, + { + "file": "tsjs-didomi.js", + "frozenSha256": "ca11403af8b24d0ea29c236fdc86f422c6b421fe58e260370f368b48fb6914ad", + "builtSha256": "ca11403af8b24d0ea29c236fdc86f422c6b421fe58e260370f368b48fb6914ad", + "byteIdentical": true, + "bytes": 1682 + }, + { + "file": "tsjs-google_tag_manager.js", + "frozenSha256": "08656a6fdd929fd9802973771a5433c2bc2b4eba02b3a1d7b984fbed1c94d71b", + "builtSha256": "08656a6fdd929fd9802973771a5433c2bc2b4eba02b3a1d7b984fbed1c94d71b", + "byteIdentical": true, + "bytes": 8392 + }, + { + "file": "tsjs-gpt.js", + "frozenSha256": "c6fd0895a70f5f23734245d7e54dcd966d4b9d94c78bbe158213f4fe8ea8f372", + "builtSha256": "c6fd0895a70f5f23734245d7e54dcd966d4b9d94c78bbe158213f4fe8ea8f372", + "byteIdentical": true, + "bytes": 58738 + }, + { + "file": "tsjs-gpt_diagnostics.js", + "frozenSha256": "b1401ad2f4a1ace1818965be094b08ac2d8abd482f2197525f0050d20e30a6c4", + "builtSha256": "b1401ad2f4a1ace1818965be094b08ac2d8abd482f2197525f0050d20e30a6c4", + "byteIdentical": true, + "bytes": 88039 + }, + { + "file": "tsjs-lockr.js", + "frozenSha256": "7bbedcbf07e6aae567c9d753a36144180612095c019605804b0e23f7b619edbc", + "builtSha256": "7bbedcbf07e6aae567c9d753a36144180612095c019605804b0e23f7b619edbc", + "byteIdentical": true, + "bytes": 6653 + }, + { + "file": "tsjs-osano.js", + "frozenSha256": "43f1a0ee70762bf5cda4123e1006d936482400ac9f4f298102e61a7a6bd37780", + "builtSha256": "43f1a0ee70762bf5cda4123e1006d936482400ac9f4f298102e61a7a6bd37780", + "byteIdentical": true, + "bytes": 5725 + }, + { + "file": "tsjs-permutive.js", + "frozenSha256": "5f2a08a296c96fcbec32c5028cab6b22a6f603437919a42d65ae619f8cb3ab03", + "builtSha256": "5f2a08a296c96fcbec32c5028cab6b22a6f603437919a42d65ae619f8cb3ab03", + "byteIdentical": true, + "bytes": 8341 + }, + { + "file": "tsjs-prebid.js", + "frozenSha256": "8359d76a03a6743bf5bd3585d23518b0ae0b010a232e010bd23630a80eed6f08", + "builtSha256": "8359d76a03a6743bf5bd3585d23518b0ae0b010a232e010bd23630a80eed6f08", + "byteIdentical": true, + "bytes": 52599 + }, + { + "file": "tsjs-sourcepoint.js", + "frozenSha256": "df53db3050f851cb8b4d8b2bcdea672288da5f9e3b488b666ffcd56266fd2d9b", + "builtSha256": "df53db3050f851cb8b4d8b2bcdea672288da5f9e3b488b666ffcd56266fd2d9b", + "byteIdentical": true, + "bytes": 9433 + }, + { + "file": "tsjs-testlight.js", + "frozenSha256": "2517e7e6c43d4cd486fe09ee528f63411727125178e47f1040c69bbcd1201a32", + "builtSha256": "2517e7e6c43d4cd486fe09ee528f63411727125178e47f1040c69bbcd1201a32", + "byteIdentical": true, + "bytes": 2164 + } + ] + }, + "servableInputs": { + "passed": true, + "wasmSha256": "560ff7193bb00871ef97e607e046ae6aeb1a9672c02c488a202a76234b22309c", + "profiles": { + "loading": { + "files": [ + { + "file": "app.toml", + "sha256": "e57f0fa799b7c22eba359a12d842688a6355097dac566f31d06975e5892eb75c" + }, + { + "file": "inline.html", + "sha256": "384da77420d692858bf3f57d1736fe10729e117f5f9bf91f3f5825f0999ad58b" + }, + { + "file": "csp.html", + "sha256": "2aaf9560bd27a6ec6c9329d1aed4c30c2b843149198254675837d16faa8fa4b8" + }, + { + "file": "shared.html", + "sha256": "5edcb1a6b3c6cef44366c86a83fca4b2036e616ad257eb71bd9bd797e8032cdb" + }, + { + "file": "registered.html", + "sha256": "00af7c6c4d7be52e5ae8be9ce4db9bcf53b8b37ed07252fb773d8b460107b8a0" + }, + { + "file": "tall.html", + "sha256": "5c5ab8f3653ea6809b80aaea015cdc56e728bf1b37b1c7fa1686493afd65488c" + }, + { + "file": "unknown-height.html", + "sha256": "d979d51618de4b318136bfe2de82bfbe65e2d8ec1a618724c434e59d518a6eb2" + }, + { + "file": "absent-wrapper.html", + "sha256": "0913aeb4ee9653af3c82ce11a73bfbc179ce53deaeb31bbe3ab2749d2f0b4c00" + }, + { + "file": "unmatched.html", + "sha256": "e6fb5771a8beb3b310483140324978d4e165d54cd51344732bcfd794888e5f4b" + }, + { + "file": "child-replacement.html", + "sha256": "895e5674d75d900e8f03fd9c957ed1db5332dc53bc7dbf82726d764d103f7977" + }, + { + "file": "assets/0.js", + "sha256": "c6fd0895a70f5f23734245d7e54dcd966d4b9d94c78bbe158213f4fe8ea8f372" + }, + { + "file": "assets/1.js", + "sha256": "79d7792bfbbcb918bfd49c9fca59f4b14f8cfb1723455c2d66c68d48eeebc0fa" + } + ], + "servableFilesIdentical": true, + "manifestMetadataChanged": true + }, + "combined": { + "files": [ + { + "file": "app.toml", + "sha256": "9f89c834c3dc56847a30f5d1c9b4094c65d8db860ce6992b41d0bacb4c3083d4" + }, + { + "file": "inline.html", + "sha256": "27bdaa1eef5378e6e9e40e535294982e11f7ad18283800580fab9a469294dc75" + }, + { + "file": "csp.html", + "sha256": "58cadbc1116135811f64ed1593fbd130d44df39464bab0f753b606d2fa9a453f" + }, + { + "file": "shared.html", + "sha256": "7dff2a667f391019f838255120b3458018829271b2e7e07e70def091113c627c" + }, + { + "file": "registered.html", + "sha256": "d05d02593888c3e2a6af8f9015d2bede253d0970169d434e2e6610e0982e889d" + }, + { + "file": "tall.html", + "sha256": "a1c9a8070d7deb9776089f8983795f90ca411a27e2ec3acc7c7d01c5a8ff5e3d" + }, + { + "file": "unknown-height.html", + "sha256": "0e24bcdb1f121d3212a8abcfd327be93251f566367ffe832578c73e5bee810ed" + }, + { + "file": "absent-wrapper.html", + "sha256": "d00492acc6a8eafaf59ba5fe63ba4cb229d8c81848a629716a45e7c812040daa" + }, + { + "file": "unmatched.html", + "sha256": "e6fb5771a8beb3b310483140324978d4e165d54cd51344732bcfd794888e5f4b" + }, + { + "file": "child-replacement.html", + "sha256": "0026e165cfdfcf36c8de45d22c4762f945771715cc692c374342b0adbcebbd8f" + }, + { + "file": "assets/0.js", + "sha256": "c6fd0895a70f5f23734245d7e54dcd966d4b9d94c78bbe158213f4fe8ea8f372" + }, + { + "file": "assets/1.js", + "sha256": "79d7792bfbbcb918bfd49c9fca59f4b14f8cfb1723455c2d66c68d48eeebc0fa" + } + ], + "servableFilesIdentical": true, + "manifestMetadataChanged": true + } + } + } + }, + "postHostedFixtureValidation": { + "measuredProxySha256": "ad6600d2ed80394f79b1eea74c5c945a3e278330f4c10752cc84c51ea5c9cc27", + "currentProxySha256": "dcae99e59be2488ea4a3cb946e32ad1e51bcab566eeb9f93a5b2fb21636346fa", + "connectionBoundary": "Same exact server-created origin allowlist and wire/cache behavior; connection hostname/port now selected only from trusted origin entries.", + "configurationModes": [ + { + "legacy": false, + "appConfigByteIdentical": true, + "sha256": "bd04c6be7c2ed02f89ef46eadb7352046df5e6c6df6592ba51da48def53c7dbc" + }, + { + "legacy": true, + "appConfigByteIdentical": true, + "sha256": "63dfd3ba4751ec50fca1bcee3646d538c9d4f64ff72a49d06baeb6b04e455bdf" + } + ], + "nodeTestsPassed": 82, + "coldBrowserChecks": 4, + "warmBrowserChecks": 32, + "productionInputsChanged": false, + "originalTimingSamplesReexecuted": false + } +} 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 new file mode 100644 index 0000000000..455cd75d43 --- /dev/null +++ b/docs/superpowers/specs/2026-10-27-854-publisher-registered-initial-delivery-design.md @@ -0,0 +1,558 @@ +# Publisher-registered initial GPT delivery + +Status: [Independently re-reviewed](../reviews/2026-10-27-854-publisher-registered-initial-delivery-rereview.md); A1 through A4 resolved at documentation level. The user separately authorized implementation and a draft PR on 2026-10-27. See the [plan](../plans/2026-10-27-854-publisher-registered-initial-delivery-plan.md) for execution status. + +Issue: [#854](https://github.com/IABTechLab/trusted-server/issues/854). + +Base: `5bc38862512c5c71085b5052bc3e528ee9b9987d`. + +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. + +## 1. Problem and evidence + +The current automatic initializer can request an ad in a server-rendered slot +before a framework establishes its final element ID and size list. A later +publisher definition can create a second native GPT slot for that placement. +Changed-ID handoff currently requires serialized size-array equality. Reordered +or narrowed sizes do not match. Matching sizes alone is insufficient: reusing a +native slot associated with a removed element does not establish visible delivery +in a replacement element. + +Prior local experiments exercised the actual bootstrap and built runtime with a +transport-only GPT stub. Reordered and narrowed sizes produced another request +after explicit element replacement. The identical-size control suppressed a +request but did not preserve creative nodes after full-subtree replacement. The +stub did not implement ownership suppression. A separate approved live observation +showed an early TS-marked header request followed by a publisher batch containing +a header with a changed ID and narrower sizes. Post-load observation could not +distinguish node replacement from renaming. The deployed bootstrap fingerprint +differed from the checkout, so production causality is not established solely by +the local reproduction. + +The previous experimental tooling remains uncommitted in the original checkout. +It is not present in this clean worktree and is not an implementation dependency. +New regression coverage must be reproducible from this branch alone. No customer +domains, identifiers, targeting values, or raw browser captures belong in Git. + +### GPT constraints + +- A slot's `getSlotElementId()` returns its originally defined element ID. No + public rebinding API is documented. +- `display()` requires a defined slot and a present element. Each slot should be + displayed once. +- Removing a slot div without first destroying the slot is documented undefined + behavior. +- With initial loading enabled, SRA can fetch other unfetched slots during + `display()`. Therefore v1 of this opt-in mode requires effective initial loading + to be disabled before services are enabled. TS must not change that setting. +- Disabled initial load separates `display()` registration from the explicit + `refresh([slot])` request. This also allows selection of an SRA batch without + requesting unrelated defined slots. + +References: [GPT reference](https://developers.google.com/publisher-tag/reference), +[loading and refresh](https://developers.google.com/publisher-tag/guides/control-ad-loading), +[React integration guidance](https://developers.google.com/publisher-tag/samples/integrations/react), +and [SRA batching](https://developers.google.com/publisher-tag/samples/control-sra-batching). + +## 2. Outcome and scope + +For an opted-in placement, TS must not define a fallback slot or request an ad +until both the current navigation's admitted server payload and an explicit +publisher registration are available. It then requests the publisher's live +native slot once, with eligible TS targeting applied before the request. + +This requires publisher integration. A timeout, `window.load`, two animation +frames, or temporary DOM stability cannot prove that a framework has finished +replacing a placement. The existing load/frame scheduler remains for automatic +placements; it is not a readiness signal for this mode. + +### In scope + +- One per-placement opt-in setting, its wire representation, and a public + publisher registration API. +- A shared bootstrap/runtime registration protocol and current-navigation payload + admission, including initial inline, shared-template and SPA delivery paths. +- Explicit scoped delivery, initial-claim arbitration, targeting compatibility, + cancellation and navigation invalidation. +- A fictional React/Next.js publisher integration, browser regressions and CI + evidence from actual generated HTML and built artifacts. +- An operator/publisher integration guide and a real-GPT staging verification gate. + +### Not in scope + +- Deferring the main bundle, adding min-height rules, changing auction selection, + changing page-level SRA or initial-load policy, or fixing the publisher's React + hydration errors. +- Sorting formats as an automatic-mode fix, broadening publisher formats, aliasing + a native slot to a different DOM element, moving rendered creative iframes, or + deduplicating placements by GAM unit path. +- Preventing arbitrary publisher code from making its own requests. Publishers + using this mode explicitly delegate initial delivery to TS. +- Preserving a creative through arbitrary post-request subtree replacement. + Supported publishers keep the element alive until explicit teardown. +- General TS ownership of publisher-slot destruction or a new refresh-auction + mechanism. + +## 3. Configuration and wire contract + +Add an `initialization` enum to each creative-opportunity slot: + +| Value | Meaning | +| ---------------------- | ------------------------------------------------------------------------------------------------- | +| `auto` | Existing automatic behavior. Default when absent. | +| `publisher_registered` | Publisher defines the final native slot; TS waits for explicit registration and admitted payload. | + +Example, using fictional inventory: + +```toml +[[creative_opportunities.slot]] +id = "header" +div_id = "example-header-" +page_patterns = ["/example/*"] +formats = [{ width = 300, height = 250 }, { width = 300, height = 600 }] +initialization = "publisher_registered" +``` + +Unknown enum values are configuration errors. Serialize the field into the +client slot only when non-default, preserving the existing automatic wire shape. +Use the same `build_slot_json` path for initial documents and SPA responses. +The setting affects delivery only. It does not independently enable templates, +auctions, consent, or an otherwise gated slot. + +Old documents and placements without the field continue to behave exactly as +before. Activation requires a matched server/bootstrap/runtime/publisher release; +new configuration must not be served with older code that ignores the mode. + +## 4. Public publisher contract + +The bootstrap and runtime expose protocol version 1 and the same API: + +```ts +interface PublisherSlotRegistrationOptions { + slotId: string + gptSlot: PublisherGptSlot + navigationGeneration: number + eligibleFormats: ReadonlyArray +} + +interface PublisherSlotRegistration { + getStatus(): PublisherSlotRegistrationStatus + cancel(): void +} + +// Public window.tsjs API: +// publisherSlotRegistrationVersion === 1 +// registerPublisherSlot(options): PublisherSlotRegistration +``` + +`PublisherGptSlot` is the minimum structural public GPT interface needed by the +implementation. Do not expose private GPT internals or use `any`. + +`eligibleFormats` is the publisher's finite eligible creative sizes for the +selected breakpoint, using the same values that feed its slot definition or size +mapping. The publisher remains responsible for that mapping. An empty list permits +GAM delivery but no TS bid candidate. This is not a second TS size-mapping engine. +The API never changes the native slot's sizes. Values must be finite positive +integer pairs; deduplicate only for eligibility comparison, not to rewrite GPT. + +This snapshot must remain valid until dispatch. While a registration is pending, +the publisher adapter must synchronously cancel and re-register when its effective +eligible set changes, including a breakpoint change while the document is hidden. +Update from the publisher's own mapping-change/visibility handlers before allowing +foreground delivery, not from a later passive React effect. A set-equivalent +reordering does not change eligibility. TS does not poll the viewport or implement +a second responsive mapping engine. See section 8 for cancellation boundaries. + +### Preconditions + +The publisher must: + +1. Configure initial load disabled before enabling services. Configure SRA and + other service policy itself. TS does not enable services for this mode. +2. Mount the final slot element, define its native GPT slot, add PubAdsService, + and enable services through GPT's command queue. +3. Capture `tsjs.navGeneration ?? 0` before queuing work that may run later. Pass + that captured generation, not a fresh value inside an old callback. +4. Call the API before any `display`, request, or initial publisher auction for + this placement. Delegate that first delivery to TS, including when it has no + eligible bid. Do not independently run a first Prebid delivery for it. +5. Keep the same connected element and ID through request/render. Cancel pending + TS work and call GPT destruction before removing the native slot's element. + TS does not destroy a publisher-owned slot. +6. Keep pending size eligibility current under the cancellation/re-registration + contract above. Once dispatch begins, the request is consumed; a later resize + belongs to the publisher's normal responsive/refresh lifecycle, not a new TS + initial request. + +GPT does not expose a reliable public "already displayed but not requested" flag. +The fourth precondition is an integration contract, not a claim that the API can +prove every historical call. An observed prior publisher claim/request must make +TS stand down without overwriting targeting. + +A synchronous invalid call returns a rejected handle without native GPT delivery +calls. A successful registration is not permission to request yet. Its status +may remain waiting while the payload is pending. Cancellation is explicit; no +arbitrary grace-period or retry timer changes ownership. + +### Publisher example shape + +The integration guide will show one stable mounted element and one native slot. +The publisher captures generation before its GPT callback, uses one `formats` +variable for native setup and `eligibleFormats`, then calls registration. It does +not call `display` or the initial `refresh` after that handover. A component tracks +the returned handle and native slot for cancellation and teardown. A real +framework fixture must prove cleanup occurs before DOM removal, rather than +assuming ordinary effect cleanup has that ordering. The adapter also owns its +mapping-change subscriptions. While waiting for payload or foreground delivery, +it cancels the old handle before registering changed eligibility for the same +native slot and element, and removes those subscriptions on teardown. + +The production publisher source is outside this repository. The PR can verify +the API and example; the affected site is fixed only after its adapter adopts +this contract and passes the staging gate. + +## 5. Registration identity and state + +Store live records on `window.tsjs`, shared by bootstrap and runtime, not only in +module-local variables. A record is keyed by navigation generation and logical +configured slot ID. It retains the exact native GPT object, the exact current +element object and ID, eligible-format snapshot, status, and cancellation token. +Native objects are browser-local state, never serialized into a server payload or +an existing diagnostics export. + +A repeated call with the same generation, slot ID, native slot, element and +set-equivalent finite eligibility snapshot returns a handle for the same record. It must not enqueue another +request. A conflicting active registration is rejected; the publisher must cancel +a pending record before replacing it. Once dispatch is committed, the logical +placement remains consumed for that navigation even if its element disappears. +A physical-element change cannot reopen that initial request. + +```mermaid +flowchart TD + A[Publisher registration] --> C[Waiting for current payload and admission] + B[Admitted current-navigation payload] --> C + C --> D[Check native slot, element, generation and load policy] + D --> E[Claim first impression and commit dispatch once] + E --> F[Apply targeting, display, explicit scoped refresh] + F --> G[Requested or rendered] + C --> H[Cancelled, stale, inactive or publisher-owned] + D --> H +``` + +Statuses are a discriminated result, with reason codes where needed: + +- `waiting_payload`: no authoritative payload settlement for this generation. +- `waiting_delivery`: admitted registration queued for foreground delivery. +- `dispatching`: first-impression claim consumed; no automatic retry is allowed. +- `requested`: the initial native invocation has been submitted. +- `rendered`: a matching GPT render event was observed. +- `inactive`: payload excluded the slot or its mode was not this mode. +- `publisher_owned`: an earlier valid publisher auction/request claim wins. +- `cancelled`: pending work was explicitly cancelled, invalidated by navigation, + or lost its element/native slot before dispatch. +- `rejected`: invalid arguments, generation, conflicting registration, or enabled/ + unknown initial-load policy. No ownership is acquired. +- `failed_after_dispatch`: a delivery call threw after dispatch began. Do not + assume no request occurred, release the claim, or retry automatically. + +Status is useful integration feedback, not a request source inferred from later +render events. Optional diagnostics must never determine delivery admission. + +## 6. Joining registration with admitted payload + +Neither `tsjs.adSlots` defaults nor a head slot list proves the auction/template/ +consent gate has admitted delivery. Track authoritative payload settlement for +its generation explicitly. + +### Initial document + +The existing `scheduleInitialAdInit` entrypoint accepts slots and bids under its +existing generation-0 and first-call checks. Once it actually accepts that +payload, publish settlement to the registration join. Head-only configuration, +core defaults, duplicate scheduler calls and discarded SSR payloads do not settle +a new payload. Preserve initial slots omitted versus explicitly empty semantics. + +For shared-template assembly, `initialSlots: []` settles with no eligible TS +placements. For an inline page that never emits a bids seam because no ad stack +was admitted, registrations remain waiting until cancellation; they must not +infer permission from `load` or request anyway. + +### SPA navigation + +At accepted navigation, capture and clear the old generation's automatic and +registered targeting/mapping bookkeeping before invalidating its registration +records and resetting payload admission. If GPT cleanup is queued, retain the old +key/native-identity snapshot and order cleanup before new-generation delivery; +never derive old cleanup from a newer route's records. Then invalidate old records +before queuing any work for the new generation. Accept new registration calls +with that generation while page-bids is pending. Settle admission only after the +existing endpoint response and generation checks accept slots/bids. Empty admitted +responses settle inactive; failed/aborted responses do not authorize a request. +Old command-queue callbacks, payloads, handles and cancelled frame callbacks must +not operate on the new record or route. Returning to the same pathname still uses +navigation generation, not URL equality, for identity. + +Automatic placements retain their existing DOM wait and initialization behavior. +Registered-only SPA routes must not wait on the automatic all-slots DOM heuristic +before publishing an otherwise accepted payload. Mixed routes must not let one +registered missing element delay admission or the existing automatic work. + +### Dispatch checks + +Immediately before mutation, recheck: + +- The generation and registration token are current and the record is unconsumed. +- The accepted payload still admits this slot in `publisher_registered` mode. +- The original element is connected, its ID is unchanged, and exact ID lookup + yields that same element with no duplicate DOM ID. +- The offered native object is still in `pubads().getSlots()`, uses that exact + element ID, and its ad-unit path equals the configured rendered path. +- Effective initial load is known disabled via the supported GPT getter or the + existing observed legacy disable hook. Recheck policy at dispatch as well as + registration. Unknown is not permission to send an uncontrolled display. +- The existing first-impression registry permits TS to claim the untouched slot. + +Use existing foreground/rAF behavior to avoid spending initial requests in hidden +documents. Frames are delivery scheduling, not proof of framework readiness. +There is no timer fallback, whole-page reinitialization, or implicit alias to a +new element if a check fails. + +## 7. Initial delivery and bid eligibility + +Once dispatch is committed: + +1. Reuse the offered native slot. Never call `defineSlot`, create a late-handoff + record, or add this slot to `prevGptSlots` for TS destruction. +2. Apply configured slot targeting and `ts_initial=1` using existing targeting and + render-bridge bookkeeping. Preserve unrelated publisher targeting keys. +3. Apply TS `hb_*` candidate targeting only if the TS renderer capability is + installed, finite positive winning `w`/`h` are available, and those dimensions + match both a configured format and a declared eligible final publisher format. Reordered sets do not change that eligibility. If dimensions + are absent, incompatible or only fluid size is known, do not apply that TS bid + or its renderable-candidate correlation. Continue normal GAM delivery once. + Do not substitute the first configured size or broaden publisher formats. +4. Register exactly once with `display(actualId)` and request with + `pubads().refresh([offeredSlot])`. The publisher already enabled services and + disabled initial load. Do not call `enableSingleRequest`, `enableServices`, or + a global refresh here. +5. Scope the existing `gptSlotHandoffInternal` and `adInitRefreshInProgress` + bypasses with `try/finally`. Real Prebid refresh wrappers must forward this + first delivery without clearing TS targeting or running a new auction. +6. Leave genuinely later publisher refreshes, auction ownership, refresh options + and unrelated slots untouched. Do not install a one-shot publisher-refresh + suppression for these registrations: the publisher delegated the first call + and does not have a first call to suppress. + +The automatic initializer and its retry/fallback paths must skip registered slots +before claiming, defining, refreshing, or treating them as renderable work. The +new mode must not be reachable through a legacy changed-ID alias. Existing automatic +behavior and its handoff policy remain unchanged in this PR. + +Rejected candidate targeting must not break the existing renderer for automatic +placements or alter auction results stored by the server. Registered-slot render +correlation must not later reinterpret an excluded bid as the selected candidate. + +### Renderer-unavailable degradation + +The runtime publishes `tsRenderBridgeVersion === 1` on shared `window.tsjs` only +after its TS creative message handler is installed. The marker is an internal +capability signal for this matched release, not a substitute for installing or +testing the handler. Reinstallation must not reset or duplicate the handler. The +bootstrap does not claim this capability and does not add a creative renderer. + +If that capability is absent at registered dispatch, omit TS candidate `hb_*` +targeting and candidate/render correlation even when a fitting server bid exists. +Make the single ordinary scoped GAM request with non-candidate slot targeting. +Actual GAM may return a publisher creative or no-fill; the guarantee is a valid +request path without selecting a TS creative that this code cannot render, not +guaranteed ad fill. Record a no-candidate reason without exposing bid payloads. + +The delivery remains consumed if the runtime arrives later. Do not add TS +candidate targeting retroactively, refresh to replace this first delivery, or +resurrect it through the global retained bid map. A registration still waiting +when the real renderer installs may use its eligible TS bid at its eventual +single dispatch. The tradeoff is explicit: bootstrap-only ordinary-GAM delivery +gives up TS demand participation in exchange for avoiding an unrenderable TS fill. +This policy changes only the new registered mode, not legacy automatic behavior. + +### Ownership of mapping and cleanup writes + +The public `divToSlotId` and `prevSlotTargetingKeys` maps represent both automatic +and registered deliveries for the current generation. Registration records are +the source of their registered entries, including consumed/cancelled-after-dispatch +records whose live slot still needs correlation or later targeting cleanup. Do +not add another independent mutable registered-mapping registry. + +An automatic pass replaces only its automatic entries, then retains/derives the +current-generation registered entries from those records. A registered dispatch +updates only its owned entries and leaves automatic and other registered entries +intact. Both paths use this same ownership rule in bootstrap and runtime. An empty +automatic pass on a registered-only page must not clear registered mappings. + +Navigation snapshots and clears both owners before retiring the old records; no +old-generation merge is allowed. Registered slots remain absent from +`prevGptSlots`, so retaining cleanup metadata never transfers native destruction +to TS. Tests must cover either dispatch order, a registered-only page, a mixed SPA +route, an accepted final ID outside the configured prefix, and cleanup of the +registered TS targeting on the next navigation. Exact render lookup must not +depend on a configured-prefix fallback surviving a whole-map overwrite. + +## 8. Cancellation, teardown and replacement + +For a pending eligibility change, the publisher cancels before registering the +replacement snapshot, including when the native slot and element are unchanged. +Every queued payload/foreground callback checks its own record token again. Old +callbacks and old handles cannot request, clear, or cancel replacement work. The +publisher's mapping-change handling must run before dispatch, including after a +hidden-tab breakpoint change. No native destruction is needed solely to replace +an unconsumed eligibility snapshot on the same live slot. + +Before dispatch, `cancel()` invalidates only its own record/token. It prevents +queued work, removes retained native/element references when safe, and permits a +new explicit registration for that unconsumed logical placement. It does not call +GPT, destroy a slot or transfer ownership to another element. + +After dispatch, cancellation cannot undo a request. It stops future registration +work and releases no claim for another initial TS delivery. Retain only the +minimal consumed marker needed through that navigation; keep correlation needed +by the current render bridge until its existing lifecycle ends. + +The publisher owns native teardown. Destroy before removing the div. A later +intentional replacement may require a new publisher-owned request; it is outside +the promise of preserving one initial ad through arbitrary DOM replacement. TS +must not quietly create another fallback or pretend that returning an old native +object proves a creative is visible in a new element. + +## 9. Bootstrap/runtime protocol + +The inline bootstrap installs API version 1 even when no payload is available. +The full bundle fills in the same API and adopts existing records, handle status, +payload generation, cancellation tokens and committed-delivery markers. + +Queued bootstrap callbacks resolve the current shared dispatcher rather than +retaining an independent request loop after runtime installation. The native slot +and element references are the same objects in the browser realm. + +The bootstrap must observe successful pathname-changing history operations and +`popstate` before the optional runtime arrives. Its provisional handler snapshots +cleanup, advances the existing navigation generation and invalidates old admission; +it does not fetch bids. The runtime takes over the same navigation bridge without +a second listener or generation increment. If navigation happened before takeover, +it reuses the existing page-bids operation to obtain the current generation's +payload, preserving waiting registrations and handles. Without such navigation, +runtime installation must not start another auction. Same-path query/hash changes +retain existing policy, while a pathname round trip advances the generation twice. +This also drops stale generation-zero automatic callbacks, fulfilling the existing +stale-SSR guard rather than authorizing an old-route fallback. Installing or +reinstalling the runtime cannot reset statuses, add wrappers twice, re-display, +re-request, lose a pending registration, or adopt stale SSR payload. + +Both implementations must pass the same executable registration contract cases. +Prefer a small shared pure module if the current build pipeline can emit it into +the inline bootstrap without adding parser-blocking dependencies. Otherwise keep +the current deliberate duplication with parity tests; do not turn this PR into a +bootstrap build-system migration. + +## 10. Compatibility and rollout + +- Default `auto` behavior, existing wire shape, script order, auction gates, + diagnostics activation and TS-only fallback remain unchanged. +- Registered mode v1 requires initial load already disabled. Enabled or + unverifiable policy is a tested rejection, not a silent global change. +- Publish code with the mode unused first. Verify the HTML bootstrap, selected + built assets and publisher adapter all implement protocol version 1. Only then + activate selected placements and routes with the matching publisher adapter. +- The adapter must not treat every rejected/inactive registration as permission to + run a second first impression. Define its rollback/legacy path as a separate + release decision, not an asynchronous API-error callback that might race TS. +- Do not change initialization mode mid-document. Stop new opt-in assignments and + serve a previously compatible server/adapter/configuration pair on rollback; + already-open documents retain their agreed owner. +- Production adoption and deployment are separate approvals from implementing or + merging this repository PR. + +## 11. Verification and acceptance + +Source inspection and simulated transport counts are not sufficient on their own. +The implementation plan must deliver all of the following: + +1. Baseline reproduction of automatic changed-ID/reordered/narrowed-size requests, + with an explicit note that a matching old slot does not prove a visible ad. +2. Failing-then-passing API/serializer tests and bootstrap/runtime parity for both + payload arrival orders, repeated registration, cancellation and navigation. +3. Zero requests or fallback definitions for registered placeholders before final + registration and current admitted payload. Exactly one intended per-slot + request afterwards, with targeting at native invocation and a visible creative + in the final registered element when the test ad server returns a fill. Cover + registered dispatch before and after automatic initialization, including a + registered-only page and next-navigation cleanup. +4. Known disabled load required; enabled/unknown state and changes while waiting + reject without new requests. Model SRA with unrelated unfetched slots so an + unsafe `display` cannot pass a permissive fake. +5. Missing/no-fill/incompatible TS bids and an unavailable renderer still give one + ordinary scoped GAM request, with no ineligible TS creative correlation. Assert + visible publisher creative content when the fixture supplies a fill; accept an + explicit GAM no-fill as empty, not a TS renderer failure. Prior publisher claims + are not overwritten or followed by a competing TS request. +6. Real Prebid plus Universal Creative in the browser fixture: first TS delivery + bypasses refresh auctions; a later publisher refresh still runs normally. An + inert external Prebid stub cannot satisfy this gate. Separately prove real + ordinary creative content with no TS bridge installed, and no extra request or + candidate resurrection when the bridge later installs. +7. Real Next.js/React mount, delayed hydration, changed pre-registration IDs, + responsive narrowing, cancellation and teardown ordering. Include effective + eligibility changes while waiting for payload and while hidden: cancel the old + token, re-register the current set, reject old callbacks, and dispatch once. Assert native object + identity, final-element identity and visible creative content independently. +8. Broader required project checks on the exact combined revision. Existing + failures must be reproduced on the clean base and resolved or explicitly + reviewed; do not convert timed-out/incomplete checks into passes. +9. A separately approved real-GPT staging canary on the exact deployed artifacts, + covering one initial request, targeting and visible rendering, plus a later + legitimate refresh and an unrelated slot. Record commit, bootstrap/asset hashes, + deployment mapping, settings and observation window. No production CWV claim is + required for this correctness PR. + +### Local provider transport for the framework gate + +The application provider keeps a canonical fictional HTTPS endpoint and +`certificate_check: true`. The isolated Viceroy 0.17.0 fixture maps the exact +production-predicted provider backend names to the plaintext loopback Next.js +auction route through static local backend configuration. Include all reachable +Fastly transport-timeout buckets; provider budget changes must not escape the +mapping. Generate names with the shared naming/canonicalization policy, never a +copied hash algorithm or one assumed timeout. + +This overrides transport only in the generated test Viceroy configuration. Do not +relax HTTPS validation, change application provider endpoints to HTTP, disable +certificate checks, or add a production provider implementation. A mandatory +configuration/provider smoke preflight must prove the static-name collision is +reused and an actual Rust provider request reaches the local route and is admitted. +Source inspection supports this arrangement, but runtime success is not claimed +until that preflight runs. If it fails, stop before delivery implementation and +review the fixture transport instead of weakening validation. + +The PR may merge only when its code/fixture/CI gates pass and its publisher contract +is documented. It must not be described as fixing the affected live site, closing +all of #854, or being production-verified until the adoption and real-GPT gate have +actually passed. If real-GPT validation is unavailable, record it as a deployment +blocker rather than silently substituting fake request counts. + +## 12. Decisions requiring reviewer scrutiny + +- Is explicit publisher registration the smallest contract that removes the race + without inventing a hydration heuristic? +- Is disabled-load-only v1 sufficient and accurately scoped, given SRA behavior? +- Are payload admission and navigation identity shared with existing paths rather + than maintained by another scheduler? +- Do consumed placement identity, cancellation and rendering correlation prevent + both duplicate delivery and a misleading one-request/blank-creative result? +- Does candidate eligibility avoid serving a broad-config winner into a narrowed + publisher placement without turning this into a new responsive-auction system? 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..192e938252 --- /dev/null +++ b/docs/superpowers/specs/2026-10-29-854-client-cwv-completion-design.md @@ -0,0 +1,129 @@ +# Client CWV and initial-delivery completion for issue 854 + +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`. + +## 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. + +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. + +### 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/prepare-client-cwv-baseline.mjs b/scripts/prepare-client-cwv-baseline.mjs new file mode 100644 index 0000000000..9419f899da --- /dev/null +++ b/scripts/prepare-client-cwv-baseline.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const exporterPath = + 'crates/trusted-server-integration-tests/examples/cwv_html.rs' +const publisherPath = 'crates/trusted-server-core/src/publisher.rs' +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex') + +function replaceOnce(source, before, after) { + assert.equal( + source.split(before).length, + 2, + `Expected one fixture adaptation: ${before}` + ) + return source.replace(before, after) +} + +/** Adapt only the copied test exporter, leaving baseline production behavior intact. */ +export function baselineExporter(source) { + source = replaceOnce( + source, + ' AssemblyMode, CreativeOpportunityInitialization,', + ' AssemblyMode,' + ) + source = replaceOnce( + source, + ' ("registered", "/news/article", false, false, false, 250),\n', + '' + ) + source = replaceOnce( + source, + ` if name == "registered" { + page_settings + .creative_opportunities + .as_mut() + .expect("should configure slots") + .slot[0] + .initialization = CreativeOpportunityInitialization::PublisherRegistered; + } +`, + '' + ) + source = replaceOnce(source, ' .with_request_path(path)\n', '') + source = replaceOnce( + source, + ' .with_enforced_csp(name == "csp")\n', + '' + ) + return source +} + +/** Extract the test-only bridge; no loading, registration or reservation fix is backported. */ +export function fixtureBridge(source) { + const start = source.indexOf( + '/// Generate production slot and bid snippets for isolated browser fixtures.' + ) + const end = source.indexOf( + '/// Build the empty-bids `