From 423de0fb98979da0137feddc5031e8a175a27333 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:30:31 -0600 Subject: [PATCH] fix: start analytics for eligible public docs visits --- CLOUDFLARE.md | 7 +- scripts/docs-site/ANALYTICS.md | 93 ++--- scripts/docs-site/analytics-behavior.test.mjs | 63 ++- scripts/docs-site/analytics-bfcache.test.mjs | 87 ++-- scripts/docs-site/analytics-consent.mjs | 175 +------- scripts/docs-site/analytics-consent.test.mjs | 386 +++++------------- scripts/docs-site/analytics-events.mjs | 2 +- scripts/docs-site/analytics.test.mjs | 2 - 8 files changed, 224 insertions(+), 591 deletions(-) diff --git a/CLOUDFLARE.md b/CLOUDFLARE.md index d0bd81d28d..ec2bc2c76f 100644 --- a/CLOUDFLARE.md +++ b/CLOUDFLARE.md @@ -54,8 +54,9 @@ Cloudflare `request.cf.country`; browser-supplied geography headers are ignored. No Google credentials or visitor geography belong in site artifacts. See [the event, privacy, consent, and verification contract](scripts/docs-site/ANALYTICS.md). -The shared regional policy governs a Basic Consent Mode gate and persistent -Google Analytics choices. Enhanced Measurement remains enabled; automatic +Eligible public production visits start Google Analytics automatically, subject +to browser DNT/GPC and public-page safety. Startup does not consult old saved +choices or the geography endpoint; no analytics choice UI is rendered. Enhanced Measurement remains enabled; automatic history pageviews are suppressed because the docs shell owns initial/PJAX views. User-provided data and advertising features stay off. Existing server traffic counts remain separate from these controls. @@ -64,7 +65,7 @@ Run `node --test scripts/docs-site/analytics*.test.mjs workers/analytics-consent The optional `DOCS_GA4_SDK_PATH` points to a coordinator-approved public SDK file; fixtures intercept every request without forwarding collection. Browser transport is not provider ingestion. Production activation requires the coordinated release -GO after hosting, consent, actual-SDK, and repository checks. +GO after the exact policy/diff, focused SDK, and repository checks. ## Required Cloudflare Access diff --git a/scripts/docs-site/ANALYTICS.md b/scripts/docs-site/ANALYTICS.md index 2d8998b648..d99288123a 100644 --- a/scripts/docs-site/ANALYTICS.md +++ b/scripts/docs-site/ANALYTICS.md @@ -11,17 +11,18 @@ The site uses native `gtag.js`; existing hosting and analytics integrations rema | `scroll`, `click`, `file_download`, `video_start/progress/complete`, `view_search_results`, `form_start/submit` | Native Enhanced Measurement, when supported | Preserve the capabilities; shared automatic **history pageviews** must be off. Native form submission is an attempt, not verified success. Native video needs a supported embedded player; the current docs sources contain only a video code example, not a live supported player, so no watched-video outcome is claimed. | | `scroll_depth` | Actual scrolling: once per 25/50/75% threshold per view | `percent_scrolled`; native 90% remains separate. | | `section_view` | A build-allowlisted heading stays at least half visible for one second | `section_id`, once per view; first 24 TOC headings. | -| `content_engagement` | 30/60 visible seconds on public documentation, excluding open search/chat/consent panels | `engagement_seconds=30/60`, `content_type=documentation`, `content_id`. | +| `content_engagement` | 30/60 visible seconds on public documentation, excluding open search/chat panels | `engagement_seconds=30/60`, `content_type=documentation`, `content_id`. | | `select_content` | Public docs/site links, curated references, search results, code tabs, feedback choices | `content_type`, `content_id`, `ui_location`; code tabs may add `method`, `install_platform`. Public editorial/resource handles are allowed; visitor identifiers are not. | | `popup_view`, `popup_dismiss` | Search dialog, community invitation, diagram expansion, assistant shell | `popup_id`, `ui_location`, `dismiss_method` on dismissal. Layout occlusion is not a user dismissal. | | `form_attempt` | Accessible feedback issue-launch button | `form_id=docs_feedback`, `ui_location=page_feedback`; no published-issue or lead claim. | | `copy_action` | Clipboard API or fallback has resolved | `content_type`, stable `content_id`, `action_result=success/error`, `ui_location`; optional `method`, `install_platform`. Never copied text. | | `search` | A custom Pagefind result set is visibly stable for 1.2 seconds, selected, or closed | `search_context=docs`, `search_filter=none`, `search_status=complete/partial/error`, actual rendered `result_count` on complete/partial results (top 12 of N is 12/partial); error omits count; `search_term` only when every word is in public navigation/heading/result-title vocabulary and passes sensitive-value checks. No input-event emission. | -| `web_vital` | Official `web-vitals` LCP/INP/CLS reporting | `metric_name`, exactly one of `lcp_ms`, `inp_ms`, `cls_score` (first two milliseconds; CLS unitless), `metric_rating`, `navigation_type`, `release`. One report per metric instance. Only a valid saved grant covering the document from its start permits document metrics. Any private/unclassified/denied interval permanently invalidates remaining metrics, including pending imports; a late grant does not read buffered metrics. Public-only PJAX keeps initial document context. | +| `web_vital` | Official `web-vitals` LCP/INP/CLS reporting | `metric_name`, exactly one of `lcp_ms`, `inp_ms`, `cls_score` (first two milliseconds; CLS unitless), `metric_rating`, `navigation_type`, `release`. One report per metric instance. Only a public document allowed by browser privacy signals from its start permits document metrics. Any private/unclassified/denied interval permanently invalidates remaining metrics, including pending imports; a later allowed interval does not read buffered metrics. Public-only PJAX keeps initial document context. | | `client_error` | Runtime/resource/rejection categories and owned search failures | Fixed `error_type`, fixed `error_code`, `release`; at most five distinct categories per view. No exception strings, stacks, filenames, or input values. | -All custom events carry safe public page context. `release` is the publishing -workflow's 12-character commit SHA, or `local` in local builds. Public static +All custom events carry safe public page context. `release` is the executing JavaScript identity +`js-<12 hex>`, derived from emitted runtime content before its identity is prepended. +Full build provenance is separate in the R2 publication catalog. Public static content IDs can be high-cardinality: register them only when a report needs the dimension. `result_count`, `lcp_ms`, `inp_ms`, and `cls_score` are metrics, not identifier dimensions. Zero results are derived only from `search_status=complete` with `result_count=0`. Do not combine native outbound events with content selections as one interaction @@ -32,9 +33,9 @@ total or sum distinct users across hosts. No docs event proves installation. Preview builds, other origins, the hidden component fixture, unknown/error/API routes, and unclassified history transitions cannot emit. Back/forward navigation suspends measurement before its asynchronous fetch. A trusted BFCache restore -refreshes the persisted choice in a capture-phase `pageshow` handler before the +refreshes browser privacy signals in a capture-phase `pageshow` handler before the native SDK, then uses the same owner/dedupe for one allowed restored view. Ordinary -initial `pageshow` adds none. An unmeasured A→B→A journey starts one current view on regrant; query-only DOM replacements rebind observers without another pageview or fresh event budgets. Document metrics stay invalid after a restore boundary. The tag keeps native +initial `pageshow` adds none. An unmeasured A→B→A journey starts one current view on browser-signal recovery; query-only DOM replacements rebind observers without another pageview or fresh event budgets. Document metrics stay invalid after a restore boundary. The tag keeps native cross-domain/cookie behavior; configured Google linker behavior still requires shared-SDK verification. @@ -51,31 +52,23 @@ are therefore a deliberate measurement gap. Public documentation remains measure for signed-in visitors. A session check is not a new login or signup. Google automatic user-provided-data collection is off. Google signals and -ad-personalization signals are also disabled locally. The approved regional -policy is `2026-10-02.v2`: EEA + GB + CH and AX/GF/GP/MQ/RE/YT/MF (39 codes) require opt-in; other validated countries -use notice/opt-out; unknown geography remains off without explicit permission. -The public shell now uses the narrower existing-grant-only rule described below; -these region classes cannot enable collection. The existing Worker classifies trusted `request.cf.country` and returns only the -region class from uncached `/api/analytics-consent`. It never uses client-supplied -country headers or exposes country/IP in the response. - -Frozen policy SHA-256: `96bb4b0abc931d856ce14aff0131320e2c8f520d0bb2632037f864fe79bd5e93`. Both client and endpoint require the v2 version. An old-version record is not absence: it stays off until a fresh explicit v2 choice, even in a notice/opt-out region. Visits without a valid stored grant remain off in every region. - -The public shell no longer renders automatic analytics notices or footer on/off -controls. Collection now requires an existing, valid explicit grant in -`openclaw.analytics.consent`; no region can create a new grant. Existing choices -retain their 180-day expiry and the same validation/lifecycle handling. New visitors -and expired choices remain off, including notice/opt-out regions. No replacement -popup or new grant flow is provided. This reduces measurement coverage. DNT/GPC and explicit denial override -an allow; failed storage, failed/invalid region responses, expired records, and -unknown regions fail closed. No automatic regional default is enabled. - -Basic Consent Mode holds the SDK until allowed. Global analytics/advertising -consent starts denied; only analytics storage is granted when allowed. Denial -closes the application gate and sets Google's measurement opt-out before the -consent update. Pre-choice/private actions are never replayed. Async copy/search outcomes and deferred visibility callbacks carry the original eligible interval; withdrawal or a private boundary invalidates them permanently. Already-consented queued/in-flight public batches may finish sending after denial; this is distinct from new denied-state collection. Persisted denial propagates to same-origin tabs. Failed persistence keeps this runtime denied and preserves drafts without claiming durable storage. Only the observed `_ga` and `_ga_3SK7X2YLSJ` cookies at the installation's host/parent root scope are removed; unrelated cookies remain. These controls -apply to Google Analytics only; existing server traffic counts remain separate. -No global legal-compliance claim is made. +ad-personalization signals are disabled locally. Eligible public production pages +start analytics automatically unless the browser sends a recognized DNT/GPC +signal. No saved analytics-choice record is read, written, migrated or consulted; +missing, granted, denied, expired, malformed and inaccessible old records all +have the same eligibility. No regional response is requested or awaited. The +existing geography endpoint is unused by analytics startup. + +No analytics notice, banner, dialog or footer toggle is rendered. The minimal +shared Privacy policy and footer link remain unchanged. Basic Consent Mode keeps +all advertising consent denied; only analytics storage becomes granted on an +eligible public page. Browser privacy signals and private/unclassified boundaries +close the native measurement gate. Async outcomes and deferred callbacks from a +blocked interval cannot replay on recovery. Already-allowed queued/in-flight +public batches may finish sending; they are distinct from newly blocked activity. +Observed GA cookies are cleared when a browser privacy signal blocks an active +runtime; unrelated cookies and user drafts remain. Server traffic counts are +separate. This is a global public-page default, not a regional default. ## Verification and limits @@ -105,32 +98,26 @@ not an assumption that `send_page_view: false` disables history measurement. "chromium"`) without Playwright's `--disable-back-forward-cache` default. The headless-shell delegate cannot prove this lifecycle. The fixture traverses real history using CDP, checks trusted `pageshow.persisted` plus retained document -state, and tests other-tab denial plus first-ever grant while cached (both no-choice and saved-deny starts). Positive activation is deferred only between trusted pagehide/pageshow; ordinary background tabs still activate. Run with +state, and tests ignored old-record changes plus browser GPC across restore. Positive activation is deferred only between trusted pagehide/pageshow; ordinary background tabs still activate. Run with `DOCS_GA4_REQUIRE_BFCACHE=1` and the approved SDK file to require actual cache restoration; ordinary CI also checks correct behavior if Chromium chooses reload. No browser network request to Google is forwarded. No synthetic pageshow is used as native lifecycle proof. -## Two-stage deployment gate +## Deployment controls `DOCS_SITE_GA4_ENABLED` must equal `1` at build time to produce analytics page -metadata. The existing R2 workflow reads this repository -variable with a default of `0`; CI explicitly builds an enabled artifact. No -Actions permission, required check, deployment queue, upload scope, or router -infrastructure changes are part of this gate. - -1. After closed-gate publishing authorization, verify/initialize the resolved - repository/environment setting to `0`, land the reviewed head through normal - checks, and let the existing R2/router workflow publish it. Verify the served - router/asset commit, `/api/analytics-consent` decision and no-store headers, - spoofed-country-header immunity, absent analytics page metadata, and no SDK - loading in a bounded browser check. -2. After separate activation authorization, set `DOCS_SITE_GA4_ENABLED=1` and - dispatch the existing R2 workflow with `artifact_scope=shell` at the reviewed - current main. Verify the actual served metadata/assets and repeat bounded - consent/SDK request checks. Coordinate provider ingestion with the Google - collector owner; transport alone is not ingestion proof. - -The removed UI previously displayed the approved withdrawal and configured -14-month retention addenda; retention configuration is unchanged. Retention configuration is not a claim that the -provider's 24-hour change window has elapsed or that historical data was restored. +metadata. The existing R2 workflow reads this repository variable with a default +of `0`; CI builds an enabled artifact. The current correction leaves the verified +production flag enabled. No Actions permission, required check, queue, upload +scope or hosting infrastructure is changed. + +Land a reviewed exact head through normal checks and the existing publication. +Record first observed new JavaScript separately from complete HTML publication: +R2 uploads shared assets first, and cached/previously open documents may retain +an older runtime. Preserve the content-derived runtime ID and the independent +catalog build commit. Coordinate the effective collection scope with the source +and reader owners; reporting metadata holds must not be mislabeled as site gates. +All acceptance browser collection is intercepted, never forwarded as synthetic +QA. Provider settings, historical observations and unrecorded past visits are not +changed or reconstructed by this correction. diff --git a/scripts/docs-site/analytics-behavior.test.mjs b/scripts/docs-site/analytics-behavior.test.mjs index 23cfa0b513..df4a97ed5b 100644 --- a/scripts/docs-site/analytics-behavior.test.mjs +++ b/scripts/docs-site/analytics-behavior.test.mjs @@ -11,7 +11,7 @@ let browser; before(async () => { browser = await chromium.launch({ headless: true }); }); after(async () => { await browser?.close(); }); -async function openSite(t, { privacy, clock = false, diagram = false, showConsent = false, theme = "dark", viewport = { width: 1440, height: 900 } } = {}) { +async function openSite(t, { privacy, clock = false, diagram = false, theme = "dark", viewport = { width: 1440, height: 900 } } = {}) { const f = fixture(t, [], { "guide.md": '# Guide\n\n## Install\n\n```sh title="npm"\nnpm install EXAMPLE_PUBLIC_CODE\n```\n\n[Home](/)\n\n[Contributor](https://github.com/public-contributor)\n\n## Configure\n\nConfiguration guidance.\n\n## Password\n\nPublic password documentation.\n', }); @@ -52,18 +52,18 @@ async function openSite(t, { privacy, clock = false, diagram = false, showConsen const contentType = ({ ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2" })[path.extname(file)] || "application/octet-stream"; return route.fulfill({ contentType, body: fs.readFileSync(file) }); }); - await context.addInitScript(({ privacy, showConsent, theme }) => { - if (privacy === "gpc") Object.defineProperty(navigator, "globalPrivacyControl", { value: true }); - if (privacy === "dnt") Object.defineProperty(navigator, "doNotTrack", { value: "1" }); - const now = Date.now(); - if (!showConsent) localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: privacy === "optout" ? "denied" : "granted", updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() })); + await context.addInitScript(({ privacy, theme }) => { + window.__gpc = privacy === "gpc"; + window.__dnt = privacy === "dnt"; + Object.defineProperty(navigator, "globalPrivacyControl", { get: () => window.__gpc }); + Object.defineProperty(navigator, "doNotTrack", { get: () => window.__dnt ? "1" : "0" }); localStorage.setItem("theme", theme); window.__copyFails = false; Object.defineProperty(navigator, "clipboard", { value: { writeText: async () => { if (window.__copyFails) throw new Error("Unavailable"); } } }); document.execCommand = () => false; window.__opened = []; window.open = (...args) => { window.__opened.push(args); return null; }; - }, { privacy, showConsent, theme }); + }, { privacy, theme }); const page = await context.newPage(); if (clock) await page.clock.install(); await page.goto(`${origin}/guide?utm_source=chatgpt&utm_medium=referral&utm_campaign=docs_launch#intro`, { referer: "https://chatgpt.com/c/PRIVATE_REFERRER_VALUE?key=example" }); @@ -74,13 +74,11 @@ async function openSite(t, { privacy, clock = false, diagram = false, showConsen return { page, context, events, collected, googleRequests, runtimeRelease }; } -async function setSavedChoice(page, analytics) { - await page.evaluate(analytics => { - const key = "openclaw.analytics.consent", now = Date.now(); - const value = JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics, updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() }); - localStorage.setItem(key, value); - dispatchEvent(new StorageEvent("storage", { key, newValue: value })); - }, analytics); +async function setBrowserPrivacy(page, blocked) { + await page.evaluate(blocked => { + window.__dnt = blocked; + dispatchEvent(new Event("focus")); + }, blocked); } test("acquisition, actual copy outcomes, public identifiers and feedback launch stay useful and safe", async t => { @@ -167,7 +165,7 @@ test("custom search counts stable rendered results and selection, never keystrok test("async copy and search cannot cross a withdrawn collection interval", async t => { const { page, events, collected } = await openSite(t); const choose = async allow => { - await setSavedChoice(page, allow ? "granted" : "denied"); + await setBrowserPrivacy(page, !allow); }; await page.evaluate(() => { navigator.clipboard.writeText = () => new Promise(resolve => { window.__finishCopy = resolve; }); @@ -184,18 +182,11 @@ test("async copy and search cannot cross a withdrawn collection interval", async await page.locator("[data-search-open]").first().click(); await page.locator("[data-search-input]").fill("delay guide"); await page.waitForTimeout(350); - // Save preference through another same-origin tab while the search dialog - // stays open, then let the real storage listener update this runtime. - const peer = await page.context().newPage(); - await peer.goto(origin + "/"); - for (const analytics of startsDenied ? ["granted"] : ["denied", "granted"]) { - await peer.evaluate(analytics => { - const key = "openclaw.analytics.consent"; - localStorage.setItem(key, JSON.stringify({ ...JSON.parse(localStorage.getItem(key)), analytics })); - }, analytics); - await page.waitForFunction(denied => window["ga-disable-G-3SK7X2YLSJ"] === denied, analytics === "denied"); + // A browser privacy-signal change can interrupt an open search dialog. + for (const blocked of startsDenied ? [false] : [true, false]) { + await setBrowserPrivacy(page, blocked); + await page.waitForFunction(value => window["ga-disable-G-3SK7X2YLSJ"] === value, blocked); } - await peer.close(); await page.evaluate(() => window.__finishSearch()); await page.locator(".search-result").first().waitFor(); await page.keyboard.press("Escape"); @@ -227,9 +218,14 @@ test("private assistant pauses native collection through closing, without disabl assert.equal((await events("page_view")).length, initialViews, "closing a private panel is not a new public page"); await page.locator("[data-code-copy]").click(); assert.equal((await events("copy_action")).length, 1, "public actions still work for the authenticated visitor"); - await page.waitForTimeout(2100); assert.doesNotMatch(JSON.stringify(await events()), /PRIVATE_CHAT_VALUE|PRIVATE_CHAT_QUERY|PRIVATE_QUESTION_VALUE/); - if (sdk) assert.doesNotMatch(JSON.stringify(collected), /PRIVATE_CHAT_VALUE|PRIVATE_CHAT_QUERY|PRIVATE_QUESTION_VALUE/); + if (sdk) { + const copied = () => collected.some(request => /(?:^|[&\n])en=copy_action(?:&|$)/.test(new URL(request.url).search.slice(1) + "&" + request.body)); + const deadline = Date.now() + 7000; + while (!copied() && Date.now() < deadline) await page.waitForTimeout(100); + assert.ok(copied(), "the native post-close public batch was actually observed"); + assert.doesNotMatch(JSON.stringify(collected), /PRIVATE_CHAT_VALUE|PRIVATE_CHAT_QUERY|PRIVATE_QUESTION_VALUE/); + } }); test("diagram popup reports actual opening and Escape dismissal", async t => { @@ -262,7 +258,7 @@ test("navigation performed under the private assistant hold is not replayed on c test("regrant after an unmeasured return to the same route starts one current view and rebinds sections", async t => { const { page, events } = await openSite(t); await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "section_view" && entry[2].section_id === "install")); - await setSavedChoice(page, "denied"); + await setBrowserPrivacy(page, true); await page.locator('.doc a[href="/"]').click(); await page.waitForURL(`${origin}/`); await page.locator('.main[data-analytics-path="/"]').waitFor(); // A history URL change precedes the asynchronous PJAX DOM commit. Hold that @@ -278,7 +274,7 @@ test("regrant after an unmeasured return to the same route starts one current vi await page.goBack(); await page.waitForURL("**/guide**"); await requestStarted; assert.equal(await page.locator(".main").getAttribute("data-analytics-path"), "/"); assert.equal((await events("page_view")).length, 1); - await setSavedChoice(page, "granted"); + await setBrowserPrivacy(page, false); assert.equal((await events("page_view")).length, 1, "an uncommitted URL cannot start a public view"); } finally { releaseReturn(); } await page.locator('.main[data-analytics-path="/guide"]').waitFor(); @@ -321,7 +317,7 @@ test("reading milestones report only bounded visible time without inflating nati assert.ok(milestones.every(event => event.engagement_time_msec === undefined)); }); -for (const privacy of ["gpc", "dnt", "optout"]) test(`${privacy} prevents Google tag loading and custom collection`, async t => { +for (const privacy of ["gpc", "dnt"]) test(`${privacy} prevents Google tag loading and custom collection`, async t => { const { page, events, googleRequests } = await openSite(t, { privacy }); await page.locator("[data-code-copy]").click(); assert.deepEqual(await events(), []); @@ -330,11 +326,10 @@ for (const privacy of ["gpc", "dnt", "optout"]) test(`${privacy} prevents Google test("public pages have no analytics prompts or controls on desktop and mobile", async t => { for (const theme of ["light", "dark"]) for (const viewport of [{ width: 1440, height: 900 }, { width: 320, height: 568 }]) { - const { page, context, googleRequests } = await openSite(t, { showConsent: true, theme, viewport }); + const { page, context, googleRequests } = await openSite(t, { theme, viewport }); assert.equal(await page.locator("[data-analytics-consent],[data-analytics-choices],[data-analytics-allow],[data-analytics-deny]").count(), 0); - assert.equal(googleRequests.length, 0); + assert.equal(googleRequests.length, 1); assert.equal(await page.locator(".site-footer-legal").getByRole("link", { name: "OpenClaw Foundation", exact: true }).getAttribute("href"), "https://openclaw.org"); - assert.equal((await context.cookies()).filter(cookie => cookie.name.startsWith("_ga")).length, 0); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); if (process.env.DOCS_GA4_VISUAL_DIR) { fs.mkdirSync(process.env.DOCS_GA4_VISUAL_DIR, { recursive: true }); diff --git a/scripts/docs-site/analytics-bfcache.test.mjs b/scripts/docs-site/analytics-bfcache.test.mjs index 57710fdcbc..e48922aad3 100644 --- a/scripts/docs-site/analytics-bfcache.test.mjs +++ b/scripts/docs-site/analytics-bfcache.test.mjs @@ -3,7 +3,6 @@ import fs from "node:fs"; import { before, after, test } from "node:test"; import { chromium } from "playwright"; import { siteJs } from "./site-js.mjs"; -import { analyticsConsentHtml } from "./analytics-consent.mjs"; import { webVitalsAssetName, webVitalsRuntime } from "./web-vitals-runtime.mjs"; const origin = "https://docs.openclaw.ai"; @@ -18,20 +17,22 @@ before(async () => { after(async () => { await browser?.close(); }); const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); -async function fixture(t, initialConsent = "granted") { +async function fixture(t, { choice, privacy } = {}) { const context = await browser.newContext({ serviceWorkers: "block" }); context.setDefaultTimeout(10000); t.after(() => context.close()); const events = [], rejected = []; let sdkLoads = 0; - await context.addInitScript(({ initialConsent }) => { + await context.addInitScript(({ choice, privacy }) => { + window.__gpc = privacy === "gpc"; + Object.defineProperty(navigator, "globalPrivacyControl", { get: () => window.__gpc }); window.__lifecycle = []; addEventListener("pageshow", event => window.__lifecycle.push({ persisted: event.persisted, trusted: event.isTrusted })); - if (initialConsent && !localStorage.getItem("openclaw.analytics.consent")) { + if (choice && !localStorage.getItem("openclaw.analytics.consent")) { const time = Date.now() - 1000; - localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: initialConsent, updated_at: new Date(time).toISOString(), expires_at: new Date(time + 180 * 86400000).toISOString() })); + localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: choice, updated_at: new Date(time).toISOString(), expires_at: new Date(time + 180 * 86400000).toISOString() })); } - }, { initialConsent }); + }, { choice, privacy }); await context.route("**/*", async route => { const request = route.request(), url = new URL(request.url()); if (url.hostname === "www.googletagmanager.com" && url.pathname === "/gtag/js") { sdkLoads++; return route.fulfill({ contentType: "text/javascript", body: sdk }); } @@ -44,16 +45,16 @@ async function fixture(t, initialConsent = "granted") { if (url.pathname === "/api/analytics-consent") return route.fulfill({ contentType: "application/json", headers: { "Cache-Control": "private, no-store" }, body: JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", region_class: "opt_in" }) }); if (url.pathname === "/assets/" + webVitalsAssetName) return route.fulfill({ contentType: "text/javascript", body: webVitalsRuntime }); if (url.pathname !== "/") return route.fulfill({ status: 404 }); - return route.fulfill({ contentType: "text/html", headers: { "Cache-Control": "public, max-age=600" }, body: `Public cache fixture

Public cache fixture

${analyticsConsentHtml()}` }); + return route.fulfill({ contentType: "text/html", headers: { "Cache-Control": "public, max-age=600" }, body: `Public cache fixture

Public cache fixture

` }); }); const page = await context.newPage(); const cdp = await context.newCDPSession(page); await cdp.send("Page.enable"); cdp.on("Page.backForwardCacheNotUsed", event => rejected.push(event.notRestoredExplanations)); await page.goto(origin); - if (initialConsent === "granted") await page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); - else await page.waitForFunction(() => document.querySelector("[data-analytics-consent-status]")?.textContent.length > 0); - if (sdk && initialConsent === "granted") for (let i = 0; i < 70 && events.filter(event => event.en === "page_view").length < 1; i++) await delay(100); + if (!privacy) await page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + else await page.waitForFunction(() => document.readyState === "complete"); + if (sdk && !privacy) for (let i = 0; i < 70 && events.filter(event => event.en === "page_view").length < 1; i++) await delay(100); await page.evaluate(() => { window.__retained = true; window.__afterSdkRestore = []; @@ -61,7 +62,7 @@ async function fixture(t, initialConsent = "granted") { if (event.persisted) { const disabled = window["ga-disable-G-3SK7X2YLSJ"] === true; window.__afterSdkRestore.push({ disabled, trusted: event.isTrusted }); - if (disabled) window.gtag("event", "select_content", { content_id: "DENIED_BFCACHE_MARKER" }); + if (disabled) window.gtag?.("event", "select_content", { content_id: "DENIED_BFCACHE_MARKER" }); } }); }); @@ -108,59 +109,33 @@ test("real restored public navigation has one pageview; ordinary pageshow adds n } }); -test("a cached tab reads another tab's denial before native restore callbacks", { timeout: 45000 }, async t => { - const f = await fixture(t); +test("old same-origin choice changes while cached do not suppress or duplicate the restored view", { timeout: 45000 }, async t => { + const f = await fixture(t, { choice: "denied" }); + assert.equal((await f.state()).views, 1); await f.page.goto("https://away.example/"); - const peer = await f.context.newPage(); - await peer.goto(origin + "/choice"); - const setChoice = choice => peer.evaluate(choice => { - const now = Date.now(); - localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: choice, updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() })); - }, choice); - await setChoice("denied"); - const state = await f.restore(); - verifyLifecycle(t, state, f.rejected); - if (state.retained) { - assert.equal(state.disabled, true); - assert.deepEqual(state.afterSdk, [{ disabled: true, trusted: true }]); - assert.equal(state.views, 1); - } else assert.equal(state.views, 0); - await delay(1500); - if (sdk) assert.equal(f.events.filter(event => event.en === "page_view").length, 1, "the denied return is not measured"); - await setChoice("granted"); - await delay(1800); - assert.doesNotMatch(JSON.stringify(f.events), /DENIED_BFCACHE_MARKER/, "denied restore work is never replayed on a later grant"); + const peer = await f.context.newPage(); await peer.goto(origin + "/choice"); + await peer.evaluate(() => localStorage.setItem("openclaw.analytics.consent", "malformed old denial")); + const state = await f.restore(); verifyLifecycle(t, state, f.rejected); + assert.equal(state.views, state.retained ? 2 : 1); + assert.equal(state.disabled, false); + if (state.retained) assert.equal(f.sdkLoads(), 1); }); -for (const initial of [null, "denied"]) test(`first grant while cached (${initial || "no choice"}) creates one activation view`, { timeout: 45000 }, async t => { - const f = await fixture(t, initial); - assert.equal(f.sdkLoads(), 0); assert.equal(f.events.length, 0); +test("browser GPC remains off across real history restore", { timeout: 45000 }, async t => { + const f = await fixture(t, { choice: "granted", privacy: "gpc" }); + assert.equal(f.sdkLoads(), 0); await f.page.goto("https://away.example/"); - const peer = await f.context.newPage(); await peer.goto(origin + "/choice"); - await peer.evaluate(() => { - const now = Date.now(); localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: "granted", updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() })); - }); - assert.equal(f.sdkLoads(), 0, "a frozen document does not start its first SDK"); const state = await f.restore(); verifyLifecycle(t, state, f.rejected); - assert.equal(state.views, 1); - if (sdk) { - for (let i = 0; i < 70 && f.events.filter(event => event.en === "page_view").length < 1; i++) await delay(100); - await delay(1200); - assert.equal(f.events.filter(event => event.en === "page_view").length, 1); - assert.equal(f.sdkLoads(), 1); - } + assert.equal(state.views, 0); assert.equal(f.sdkLoads(), 0); + assert.equal(f.events.length, 0); }); -test("a first grant in an ordinary background tab is not deferred like a cached document", async t => { - const f = await fixture(t, null); +test("ordinary background tabs remain public regardless of peer choice writes", async t => { + const f = await fixture(t); const peer = await f.context.newPage(); await peer.goto(origin + "/choice"); await peer.bringToFront(); - await peer.evaluate(() => { - const now = Date.now(); localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: "granted", updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() })); - }); - await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + await peer.evaluate(() => localStorage.setItem("openclaw.analytics.consent", "denied")); + await f.page.bringToFront(); const state = await f.state(); - assert.equal(state.views, 1); + assert.equal(state.views, 1); assert.equal(state.disabled, false); assert.equal(state.lifecycle.some(event => event.persisted), false); - await f.page.bringToFront(); - assert.equal((await f.state()).views, 1); }); diff --git a/scripts/docs-site/analytics-consent.mjs b/scripts/docs-site/analytics-consent.mjs index 3c71a6dc9d..11696b3ce7 100644 --- a/scripts/docs-site/analytics-consent.mjs +++ b/scripts/docs-site/analytics-consent.mjs @@ -1,180 +1,31 @@ -export function analyticsConsentHtml() { - return ``; -} - -// Serialized into the browser shell. The public renderer supplies no controls; -// keep persisted-choice validation and lifecycle handling without new grants. +// Serialized into the browser shell. Public measurement follows browser privacy +// signals and the runtime's public-page gate; it has no saved-choice or geography +// dependency. Advertising consent remains denied in createDocsAnalytics. export function initDocsAnalyticsConsent(analytics, onChange = () => {}) { - if (location.origin !== "https://docs.openclaw.ai" || !document.querySelector(".main[data-analytics-path]")) return; - const policyVersion = "2026-10-02.v2"; - const storageKey = "openclaw.analytics.consent"; - const lifetime = 180 * 24 * 60 * 60 * 1000; - const panel = document.querySelector("[data-analytics-consent]"); - const launcher = document.querySelector("[data-analytics-choices]"); - const allow = panel?.querySelector("[data-analytics-allow]"); - const deny = panel?.querySelector("[data-analytics-deny]"); - let region = "unknown"; - let regionReady = false; - let storageFailure = false; - let explicit = null; - let incompatiblePolicy = false; - let dismissed = false; - let preferences = false; - let noticeShown = false; - let returnFocus = null; - let expiryTimer; + if (location.origin !== "https://docs.openclaw.ai") return; let initialDecision = true; let pageHidden = false; const privacySignal = () => navigator.globalPrivacyControl === true || navigator.doNotTrack === "1" || window.doNotTrack === "1"; - function parseChoice(raw) { - if (!raw) return null; - try { - const value = JSON.parse(raw); - const updated = Date.parse(value.updated_at); - const expires = Date.parse(value.expires_at); - const now = Date.now(); - if (value.schema_version !== 1 || value.policy_version !== policyVersion || !["granted", "denied"].includes(value.analytics) - || typeof value.updated_at !== "string" || typeof value.expires_at !== "string" - || !Number.isFinite(updated) || !Number.isFinite(expires) || updated > now || expires <= now || expires <= updated || expires - updated > lifetime - || new Date(updated).toISOString() !== value.updated_at || new Date(expires).toISOString() !== value.expires_at) return null; - return value; - } catch { return null; } - } - function readChoice() { - try { - const raw = localStorage.getItem(storageKey); - explicit = parseChoice(raw); - incompatiblePolicy = false; - if (raw) { - try { incompatiblePolicy = JSON.parse(raw)?.policy_version !== policyVersion; } - catch { incompatiblePolicy = true; } - } - } - catch { storageFailure = true; explicit = null; } - } - function scheduleExpiry() { - clearTimeout(expiryTimer); - if (!explicit) return; - // Browser delays are signed 32-bit milliseconds. Re-read and reschedule - // long-lived choices rather than overflowing a 180-day timeout. - const delay = Math.min(2147483647, Math.max(0, Date.parse(explicit.expires_at) - Date.now())); - expiryTimer = setTimeout(() => { readChoice(); update(); }, delay); - } - function canStore() { - try { - const probe = storageKey + ".probe"; - localStorage.setItem(probe, "1"); - const ok = localStorage.getItem(probe) === "1"; - localStorage.removeItem(probe); - return ok; - } catch { return false; } - } - function granted() { - if (privacySignal() || storageFailure) return false; - // No public notice or choice controls are rendered. Only an existing valid - // explicit grant permits collection; geography can never grant it. - return explicit?.analytics === "granted"; - } function update() { - if (panel && launcher) { - const signal = privacySignal(); - const autoDefault = !signal && !storageFailure && !incompatiblePolicy && !explicit && region === "notice_opt_out"; - const notice = !preferences && autoDefault; - panel.hidden = !(preferences || (autoDefault && !noticeShown) || (!dismissed && !explicit && (regionReady || signal || storageFailure))); - if (autoDefault && !panel.hidden && !pageHidden) noticeShown = true; - panel.querySelector("[data-analytics-consent-title]").textContent = notice ? "Google Analytics on this site" : "Optional Google Analytics"; - panel.querySelector("[data-analytics-consent-body]").textContent = autoDefault - ? "Google Analytics is on to help us understand page visits, interactions, and performance. We do not use it for advertising. You can turn it off or change your choice anytime." - : "With your permission, we use Google Analytics to understand page visits, interactions, and performance on this site. We do not use it for advertising. You can change your choice anytime."; - panel.querySelector("[data-analytics-consent-status]").textContent = storageFailure - ? "Google Analytics is off in this tab, but your choice could not be saved. It may not carry over to another tab or visit." - : signal ? "Google Analytics is off because your browser sends a privacy signal." - : granted() ? "Google Analytics is on." : "Google Analytics is off."; - allow.textContent = notice ? "Keep Google Analytics on" : "Allow Google Analytics"; - deny.textContent = notice ? "Turn Google Analytics off" : "Decline Google Analytics"; - allow.disabled = signal || storageFailure; - deny.disabled = storageFailure; - launcher.setAttribute("aria-expanded", String(!panel.hidden)); - } - // The public shell permits only a still-valid saved explicit grant. - const allowed = granted(); - const eligibleFrom = initialDecision && allowed && explicit?.analytics === "granted" && Date.parse(explicit.updated_at) <= performance.timeOrigin ? 0 : performance.now(); - // A cached/departing document can receive a queued storage grant before - // pageshow. Defer positive activation until the restore owner prepares it. - // Ordinary background tabs have no pagehide and continue to update normally. + const allowed = !privacySignal(); + const eligibleFrom = initialDecision && allowed ? 0 : performance.now(); + // A cached/departing document must not initialize the SDK before its restore + // owner is ready. Ordinary background tabs have no pagehide boundary. if (!allowed || !pageHidden) analytics.setConsent(allowed, eligibleFrom); initialDecision = false; - scheduleExpiry(); onChange(); } - function close() { - dismissed = true; preferences = false; - update(); - if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true }); - returnFocus = null; - } - function choose(analyticsChoice) { - if (privacySignal() && analyticsChoice === "granted") return; - // Close the runtime gate even if saving the requested denial fails. - if (analyticsChoice === "denied") analytics.setConsent(false); - const now = Date.now(); - const value = { schema_version: 1, policy_version: policyVersion, analytics: analyticsChoice, updated_at: new Date(now).toISOString(), expires_at: new Date(now + lifetime).toISOString() }; - try { - const serialized = JSON.stringify(value); - localStorage.setItem(storageKey, serialized); - if (localStorage.getItem(storageKey) !== serialized) throw new Error("Preference unavailable"); - explicit = value; storageFailure = false; incompatiblePolicy = false; - close(); - } catch { - storageFailure = true; - update(); - } - } - if (panel && launcher) { - launcher.addEventListener("click", () => { - returnFocus = document.activeElement; - preferences = true; - update(); - (allow.disabled ? panel.querySelector("[data-analytics-consent-close]") : allow).focus(); - }); - allow.addEventListener("click", () => choose("granted")); - deny.addEventListener("click", () => choose("denied")); - panel.querySelector("[data-analytics-consent-close]").addEventListener("click", close); - panel.addEventListener("keydown", event => { if (event.key === "Escape") { event.stopPropagation(); close(); } }); - } - addEventListener("storage", event => { if (event.key === storageKey || event.key === null) { readChoice(); update(); } }); - addEventListener("focus", () => { readChoice(); update(); }); + addEventListener("focus", update); addEventListener("pagehide", event => { if (event.isTrusted) pageHidden = true; }, true); - // Installed before the native SDK: cached tabs must refresh a changed/expired - // choice before any native restore callback. The same pageview owner dedupes - // a following popstate; ordinary initial pageshow never forces another view. + // Register before SDK startup: privacy signals are refreshed before native + // restore callbacks, and the existing pageview owner dedupes later popstate. addEventListener("pageshow", event => { if (event.isTrusted) pageHidden = false; const restored = event.persisted && event.isTrusted; if (restored) analytics.prepareRestore(); - readChoice(); update(); + update(); if (restored) { analytics.pageView(); onChange(); } }, true); - document.addEventListener("visibilitychange", () => { readChoice(); update(); }); - readChoice(); - storageFailure ||= !canStore(); + document.addEventListener("visibilitychange", update); update(); - if (!panel || !launcher) return; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - fetch("/api/analytics-consent", { credentials: "omit", cache: "no-store", signal: controller.signal }) - .then(async response => { - if (!response.ok) return; - const value = await response.json(); - if (value?.schema_version === 1 && value.policy_version === policyVersion && ["opt_in", "notice_opt_out", "unknown"].includes(value.region_class)) region = value.region_class; - }) - .catch(() => {}) - .finally(() => { clearTimeout(timeout); regionReady = true; update(); }); } diff --git a/scripts/docs-site/analytics-consent.test.mjs b/scripts/docs-site/analytics-consent.test.mjs index 9e964fe76e..849b2e54bc 100644 --- a/scripts/docs-site/analytics-consent.test.mjs +++ b/scripts/docs-site/analytics-consent.test.mjs @@ -2,9 +2,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import { before, after, test } from "node:test"; import { chromium } from "playwright"; -import { analyticsConsentHtml } from "./analytics-consent.mjs"; import { siteJs } from "./site-js.mjs"; -import { siteCss } from "./site-css.mjs"; import { webVitalsAssetName, webVitalsRuntime } from "./web-vitals-runtime.mjs"; const origin = "https://docs.openclaw.ai"; @@ -13,320 +11,148 @@ const key = "openclaw.analytics.consent"; let browser; before(async () => { browser = await chromium.launch({ headless: true }); }); after(async () => { await browser?.close(); }); - -function saved(analytics, overrides = {}) { - const now = Date.now(); - return { schema_version: 1, policy_version: "2026-10-02.v2", analytics, updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString(), ...overrides }; +const saved = analytics => JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics, updated_at: "2026-10-02T00:00:00.000Z", expires_at: "2027-01-01T00:00:00.000Z" }); +async function deliveredViews(f, expected) { + // The native SDK batches transport after the synchronous pageview owner has + // committed. Observe delivery without synthesizing a flush event. + const deadline = Date.now() + 7000; + while (f.events.filter(event => event.en === "page_view").length < expected && Date.now() < deadline) await f.page.waitForTimeout(100); + assert.equal(f.events.filter(event => event.en === "page_view").length, expected); } -async function openConsent(t, options = {}) { - const context = await browser.newContext({ serviceWorkers: "block", viewport: options.viewport || { width: 1280, height: 800 } }); + +async function openSite(t, options = {}) { + const context = await browser.newContext({ serviceWorkers: "block" }); context.setDefaultTimeout(10000); - t.after(async () => { releaseRegion?.(); await context.close(); }); - const events = [], controls = [], sdkRequests = [], outbound = []; let releaseRegion; const regionWait = new Promise(resolve => { releaseRegion = resolve; }); - t.after(() => releaseRegion()); + t.after(async () => { releaseRegion(); await context.close(); }); + const events = [], controls = [], sdkRequests = [], regionRequests = []; + const siteOrigin = options.origin || origin; await context.route("**/*", async route => { const request = route.request(), url = new URL(request.url()); if (url.hostname === "www.googletagmanager.com" && url.pathname === "/gtag/js") { - sdkRequests.push(url.href); - return route.fulfill({ contentType: "text/javascript", body: sdk }); + sdkRequests.push(url.href); return route.fulfill({ contentType: "text/javascript", body: sdk }); } if (/\/(?:g\/)?collect$/.test(url.pathname)) { - for (const line of (request.postData() || "").split("\n")) { - const fields = Object.fromEntries(new URLSearchParams([url.search.slice(1), line].filter(Boolean).join("&"))); - (fields.en ? events : controls).push({ ...fields, receivedAt: Date.now() }); - } + for (const line of (request.postData() || "").split(/\r?\n/)) events.push({ ...Object.fromEntries(new URLSearchParams([url.search.slice(1), line].filter(Boolean).join("&"))), receivedAt: Date.now() }); return route.fulfill({ status: 204 }); } - if (url.origin !== origin) { outbound.push(url.href); return route.abort(); } + if (url.origin !== siteOrigin) { controls.push(url.href); return route.abort(); } if (url.pathname === "/api/analytics-consent") { - if (options.delayRegion) await regionWait; - if (options.regionError) return route.fulfill({ status: 503 }); - return route.fulfill({ contentType: "application/json", body: JSON.stringify({ schema_version: 1, policy_version: options.policyVersion || "2026-10-02.v2", region_class: options.region || "unknown" }) }); + regionRequests.push(url.href); + // Neither an unavailable nor arbitrarily late legacy endpoint can gate startup. + if (options.region === "slow") await regionWait; + return route.fulfill({ status: 503 }); } if (url.pathname === "/assets/" + webVitalsAssetName) return route.fulfill({ contentType: "text/javascript", body: webVitalsRuntime }); - if (url.pathname === "/form-complete") return route.fulfill({ contentType: "text/plain", body: "Accepted fixture submission" }); - if (url.pathname === "/assets/example.pdf") return route.fulfill({ contentType: "application/pdf", headers: { "Content-Disposition": "attachment; filename=example.pdf" }, body: "%PDF-1.4\n%%EOF\n" }); - if (url.pathname !== "/" && url.pathname !== "/second") return route.fulfill({ status: 404 }); - return route.fulfill({ contentType: "text/html", body: `Public ${url.pathname}

Public documentation

Next pageCatalogPublic referenceDownload
${analyticsConsentHtml()}` }); + const eligible = options.eligible !== false && url.pathname !== "/private"; + return route.fulfill({ contentType: "text/html", body: `Public docs

Public docs

Next pageHome
` }); }); - await context.addInitScript(({ key, choice, privacy, storageFailure, theme }) => { - // Seed once: same-origin form iframes/new tabs must never overwrite a later - // user denial with this fixture's starting preference. - if (choice !== undefined && localStorage.getItem(key) === null) localStorage.setItem(key, typeof choice === "string" ? choice : JSON.stringify(choice)); - localStorage.setItem("theme", theme || "dark"); - document.cookie = "baseline=keep; path=/"; - window.__gpc = privacy === "gpc"; + await context.addInitScript(({ key, choice, privacy, failedStorage }) => { + if (choice !== undefined) localStorage.setItem(key, choice); + window.__gpc = privacy === "gpc"; window.__dnt = privacy === "dnt"; + if (privacy === "window-dnt") window.doNotTrack = "1"; Object.defineProperty(navigator, "globalPrivacyControl", { get: () => window.__gpc }); - if (privacy === "dnt") Object.defineProperty(navigator, "doNotTrack", { value: "1" }); - if (storageFailure) { - const original = Storage.prototype[storageFailure]; - Storage.prototype[storageFailure] = function(name, ...args) { - if (name.startsWith(key)) throw new Error("Storage unavailable"); + Object.defineProperty(navigator, "doNotTrack", { get: () => window.__dnt ? "1" : "0" }); + window.__choiceAccess = []; + for (const method of ["getItem", "setItem", "removeItem"]) { + const original = Storage.prototype[method]; + Storage.prototype[method] = function(name, ...args) { + if (name.startsWith(key)) { window.__choiceAccess.push(method); if (failedStorage) throw new Error("Old choice storage unavailable"); } return original.call(this, name, ...args); }; } - }, { key, choice: options.choice, privacy: options.privacy, storageFailure: options.storageFailure, theme: options.theme }); + }, { key, choice: options.choice, privacy: options.privacy, failedStorage: options.failedStorage }); const page = await context.newPage(); - if (options.clock) await page.clock.install(); - await page.goto(origin); - await page.waitForFunction(() => document.querySelector("[data-analytics-consent-status]").textContent.length > 0); - const queue = () => page.evaluate(() => (window.dataLayer || []).filter(entry => entry[0] === "event").map(entry => ({ name: entry[1], ...entry[2] }))); - return { page, context, queue, events, controls, sdkRequests, outbound, releaseRegion }; + await page.goto(siteOrigin + (options.path || "/")); + await page.waitForFunction(() => document.readyState === "complete"); + const views = () => page.evaluate(() => (window.dataLayer || []).filter(entry => entry[1] === "page_view").map(entry => entry[2])); + return { page, context, events, controls, sdkRequests, regionRequests, views }; } -for (const region of ["opt_in", "unknown"]) test(`${region} waits for explicit choice and sends only the current page`, async t => { - const f = await openConsent(t, { region }); - await f.page.getByRole("heading", { name: "Optional Google Analytics", exact: true }).waitFor(); - assert.equal(f.sdkRequests.length, 0); - assert.equal(f.events.length + f.controls.length, 0); - assert.equal((await f.context.cookies()).filter(cookie => cookie.name.startsWith("_ga")).length, 0); - await f.page.getByRole("link", { name: "Next page" }).click(); - await f.page.waitForURL("**/second"); - assert.equal(f.sdkRequests.length, 0); - await f.page.getByRole("button", { name: "Allow Google Analytics", exact: true }).click(); - await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); - assert.deepEqual((await f.queue()).filter(event => event.name === "page_view").map(event => event.page_location), [origin + "/second"]); +for (const [name, options] of [ + ["no record", {}], ["old denial", { choice: saved("denied") }], ["old grant", { choice: saved("granted") }], + ["malformed record", { choice: "malformed" }], ["expired record", { choice: JSON.stringify({ ...JSON.parse(saved("granted")), updated_at: "2020-01-01T00:00:00.000Z", expires_at: "2020-02-01T00:00:00.000Z" }) }], + ["blocked old storage", { choice: saved("denied"), failedStorage: true }], + ["missing region endpoint", { region: "missing" }], ["unresponsive region endpoint", { region: "slow" }], +]) test(`${name}: public startup ignores old choices and region, with one view per committed navigation`, async t => { + const f = await openSite(t, options); + assert.equal(f.sdkRequests.length, 1); + assert.equal((await f.views()).length, 1); + await f.page.getByRole("link", { name: "Next page", exact: true }).click(); + await f.page.locator('.main[data-analytics-path="/second"]').waitFor(); + assert.deepEqual((await f.views()).map(view => view.page_location), [origin + "/", origin + "/second"]); + assert.deepEqual(await f.page.evaluate(() => window.__choiceAccess), []); + assert.deepEqual(f.regionRequests, []); + assert.equal(await f.page.locator("[data-analytics-consent],[data-analytics-choices]").count(), 0); const commands = await f.page.evaluate(() => window.dataLayer.filter(entry => ["consent", "config"].includes(entry[0])).map(entry => Array.from(entry))); assert.deepEqual(commands[0], ["consent", "default", { analytics_storage: "denied", ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied" }]); assert.deepEqual(commands[1], ["consent", "update", { analytics_storage: "granted" }]); - assert.equal(commands[2][0], "config"); -}); - -test("notice/opt-out never grants without a valid saved explicit choice", async t => { - const f = await openConsent(t, { region: "notice_opt_out", delayRegion: true }); - assert.equal(f.sdkRequests.length, 0); - f.releaseRegion(); - await f.page.getByRole("heading", { name: "Google Analytics on this site", exact: true }).waitFor(); - assert.equal(await f.page.evaluate(key => localStorage.getItem(key), key), null); - assert.equal(f.sdkRequests.length, 0); - assert.equal((await f.queue()).filter(event => event.name === "page_view").length, 0); -}); - -test("synthetic pageshow notifications never invent restored navigation views", async t => { - const f = await openConsent(t, { choice: saved("granted") }); - await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); - await f.page.evaluate(() => { - window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); - window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true })); - }); - assert.equal((await f.queue()).filter(event => event.name === "page_view").length, 1); -}); - -test("a late regional default cannot override an explicit decline", async t => { - const f = await openConsent(t, { region: "notice_opt_out", delayRegion: true }); - await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); - await f.page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); - f.releaseRegion(); - await f.page.waitForTimeout(150); - assert.equal(f.sdkRequests.length, 0); - assert.equal((await f.queue()).length, 0); - assert.equal(JSON.parse(await f.page.evaluate(key => localStorage.getItem(key), key)).analytics, "denied"); -}); - -test("a late region response cannot create consent after dismissal", async t => { - const f = await openConsent(t, { region: "notice_opt_out", delayRegion: true }); - await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); - await f.page.getByRole("button", { name: "Close Google Analytics choices", exact: true }).click(); - assert.equal(f.sdkRequests.length, 0); - f.releaseRegion(); - await f.page.getByRole("heading", { name: "Google Analytics on this site", exact: true }).waitFor(); - assert.equal(f.sdkRequests.length, 0); - assert.equal((await f.queue()).length, 0); -}); - -test("region timeout leaves analytics off until an explicit choice", async t => { - const f = await openConsent(t, { region: "notice_opt_out", delayRegion: true }); - await f.page.getByRole("heading", { name: "Optional Google Analytics", exact: true }).waitFor(); - assert.equal(f.sdkRequests.length, 0); - f.releaseRegion(); - await f.page.waitForTimeout(100); - assert.equal(f.sdkRequests.length, 0); -}); - -for (const [name, options] of [ - ["stored denial", { choice: saved("denied"), region: "notice_opt_out" }], - ["expired choice", { choice: saved("granted", { updated_at: "2020-01-01T00:00:00.000Z", expires_at: "2020-02-01T00:00:00.000Z" }) }], - ["future choice", { choice: saved("granted", { updated_at: "2099-01-01T00:00:00.000Z", expires_at: "2099-02-01T00:00:00.000Z" }) }], - ["old-v1 choice", { choice: saved("granted", { policy_version: "2026-10-02.v1" }), region: "notice_opt_out" }], - ["old-v1 denial", { choice: saved("denied", { policy_version: "2026-10-02.v1" }), region: "notice_opt_out" }], - ["wrong-version choice", { choice: saved("granted", { policy_version: "old" }), region: "notice_opt_out" }], - ["malformed choice", { choice: "malformed" }], - ["wrong-version region", { region: "notice_opt_out", policyVersion: "old" }], - ["failed region", { regionError: true }], - ["blocked reads", { region: "notice_opt_out", storageFailure: "getItem" }], - ["blocked writes", { region: "notice_opt_out", storageFailure: "setItem" }], - ["GPC", { choice: saved("granted"), privacy: "gpc", region: "notice_opt_out" }], - ["DNT", { choice: saved("granted"), privacy: "dnt", region: "notice_opt_out" }], -]) test(`${name} leaves the Google gate closed`, async t => { - const f = await openConsent(t, options); - await f.page.waitForTimeout(150); - assert.equal(f.sdkRequests.length, 0); - assert.equal((await f.queue()).length, 0); - assert.equal(f.events.length + f.controls.length, 0); -}); - -test("revocation stops native events and retains unrelated baseline cookies", async t => { - const f = await openConsent(t, { choice: saved("granted") }); - await f.page.evaluate(() => { - window.__noReload = true; - window.__eventCaptures = []; - const gtag = window.gtag; - // Observe entry timing without changing arguments, SDK, or transport. - window.gtag = function(...args) { - if (args[0] === "event") window.__eventCaptures.push({ event: args[1], content_id: args[2]?.content_id, at: Date.now(), denied: window["ga-disable-G-3SK7X2YLSJ"] === true }); - return gtag.apply(this, args); - }; - }); - await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); - if (sdk) await f.page.waitForTimeout(1600); - const before = f.events.length; - const cookies = (await f.context.cookies()).map(({ name, domain, path }) => ({ name, domain, path })); - if (sdk) t.diagnostic(`Observed cookie scopes: ${JSON.stringify(cookies)}`); - await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); - const allowedCaptureAt = await f.page.evaluate(() => { window.gtag("event", "select_content", { content_type: "public_fixture", content_id: "allowed_pending" }); return Date.now(); }); - await f.page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); - assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); - const revokedAt = await f.page.evaluate(() => Date.now()); - const afterDecline = f.events.length; - const deniedAttemptAt = await f.page.evaluate(() => Date.now()); - await f.page.evaluate(() => { document.querySelector("#public-form").id = "DENIED_FORM_MARKER"; }); - await f.page.getByRole("textbox", { name: "Topic" }).fill("PRIVATE_REVOKED_VALUE"); - await f.page.getByRole("button", { name: "Submit", exact: true }).click(); - await f.page.evaluate(() => { - const link = document.createElement("a"); - link.href = "https://private.example/DENIED_URL_MARKER?q=PRIVATE_REVOKED_VALUE"; - link.target = "_blank"; - link.textContent = "Denied reference"; - document.querySelector(".doc").append(link); - }); - const deniedPopup = f.context.waitForEvent("page"); - await f.page.getByRole("link", { name: "Denied reference", exact: true }).click(); - await (await deniedPopup).close(); - await f.page.evaluate(() => { window.gtag("event", "select_content", { content_id: "DENIED_EVENT_MARKER" }); window.scrollTo(0, document.documentElement.scrollHeight); }); - await f.page.waitForTimeout(2100); + assert.equal(commands.find(entry => entry[0] === "config")[2].allow_google_signals, false); + assert.equal(commands.find(entry => entry[0] === "config")[2].allow_ad_personalization_signals, false); if (sdk) { - t.diagnostic(`Capture/denial chronology (ms relative to applied denial): allowed_pending=${allowedCaptureAt - revokedAt}, denied_actions=${deniedAttemptAt - revokedAt}; transport=${JSON.stringify(f.events.slice(before).map(event => ({ event: event.en, content_id: event["ep.content_id"], relative_ms: event.receivedAt - revokedAt })))}`); - const captures = await f.page.evaluate(() => window.__eventCaptures); - assert.ok(captures.filter(capture => capture.content_id !== "DENIED_EVENT_MARKER").every(capture => !capture.denied), "the app generates no new optional event while denied"); - t.diagnostic(`Custom capture chronology: ${JSON.stringify(captures.map(capture => ({ ...capture, at: capture.at - revokedAt })))}`); - assert.equal((await f.context.cookies()).filter(cookie => ["_ga", "_ga_3SK7X2YLSJ"].includes(cookie.name)).length, 0); + await deliveredViews(f, 2); + assert.deepEqual(f.events.filter(event => event.en === "page_view").map(event => event.dl), [origin + "/", origin + "/second"]); + t.diagnostic(JSON.stringify(f.events.filter(event => event.en === "page_view").map(({ dl, receivedAt }) => ({ dl, receivedAt })))); } - assert.ok((await f.context.cookies()).some(cookie => cookie.name === "baseline")); - assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true, "denied native actions cannot grant analytics"); - await f.page.getByRole("link", { name: "Next page" }).click(); - await f.page.waitForURL("**/second"); - await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); - await f.page.getByRole("button", { name: "Allow Google Analytics", exact: true }).click(); - await f.page.waitForTimeout(2100); - assert.doesNotMatch(JSON.stringify([...f.events, ...f.controls]), /PRIVATE_REVOKED_VALUE|DENIED_FORM_MARKER|DENIED_EVENT_MARKER|DENIED_URL_MARKER/); - assert.equal(await f.page.evaluate(() => window.__noReload), true, "consent changes preserve the document and its drafts"); - assert.deepEqual((await f.queue()).filter(event => event.name === "page_view").map(event => event.page_location), [origin + "/", origin + "/second"]); - t.diagnostic(`Google control requests observed separately: ${f.controls.length}`); -}); - -test("a successful denial propagates to other same-origin tabs", async t => { - const f = await openConsent(t, { choice: saved("granted") }); - const peer = await f.context.newPage(); - await peer.goto(origin); - await peer.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); - await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); - await f.page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); - await peer.waitForFunction(() => window["ga-disable-G-3SK7X2YLSJ"] === true); - await peer.evaluate(() => window.gtag("event", "select_content", { content_id: "DENIED_PEER_MARKER" })); - await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); - await f.page.getByRole("button", { name: "Allow Google Analytics", exact: true }).click(); - await peer.waitForFunction(() => window["ga-disable-G-3SK7X2YLSJ"] === false); - await peer.waitForTimeout(2100); - assert.doesNotMatch(JSON.stringify([...f.events, ...f.controls]), /DENIED_PEER_MARKER/); }); -test("failed denial persistence remains denied without reloading or claiming a saved choice", async t => { - const f = await openConsent(t, { choice: saved("granted") }); - await f.page.getByRole("textbox", { name: "Topic" }).fill("UNSAVED_DRAFT_VALUE"); - await f.page.evaluate(key => { - window.__noReload = true; - const original = Storage.prototype.setItem; - Storage.prototype.setItem = function(name, ...values) { if (name === key) throw new Error("Storage unavailable"); return original.call(this, name, ...values); }; - }, key); - await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); - await f.page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); - assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); - assert.equal(await f.page.getByRole("textbox", { name: "Topic" }).inputValue(), "UNSAVED_DRAFT_VALUE"); - assert.equal(await f.page.evaluate(() => window.__noReload), true); - assert.equal(JSON.parse(await f.page.evaluate(key => localStorage.getItem(key), key)).analytics, "granted", "the old record was not overwritten"); - await f.page.getByText("Google Analytics is off in this tab, but your choice could not be saved. It may not carry over to another tab or visit.").waitFor(); +for (const [name, options] of [ + ["GPC", { privacy: "gpc", choice: saved("granted") }], ["DNT", { privacy: "dnt" }], + ["window DNT", { privacy: "window-dnt" }], + ["private", { path: "/private" }], ["preview/build-disabled metadata", { eligible: false }], + ["alias", { origin: "https://documentation.openclaw.ai" }], ["local", { origin: "http://localhost" }], +]) test(`${name} remains off regardless of old records`, async t => { + const f = await openSite(t, options); + assert.deepEqual(await f.views(), []); assert.equal(f.sdkRequests.length, 0); + assert.equal(f.events.length + f.controls.length, 0); assert.equal(f.regionRequests.length, 0); + assert.deepEqual(await f.page.evaluate(() => window.__choiceAccess), []); }); -test("a newly enabled browser privacy signal overrides a stored allow", async t => { - const f = await openConsent(t, { choice: saved("granted") }); - await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); - await f.page.evaluate(() => { window.__gpc = true; window.dispatchEvent(new Event("focus")); }); - assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); - await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); - assert.equal(await f.page.getByRole("button", { name: "Allow Google Analytics", exact: true }).isEnabled(), false); - await f.page.getByText("Google Analytics is off because your browser sends a privacy signal.").waitFor(); +test("old choice changes never control collection or duplicate the current view", async t => { + const f = await openSite(t); + const peer = await f.context.newPage(); await peer.goto(origin + "/private"); + await peer.evaluate(key => localStorage.setItem(key, "old denial"), key); + await f.page.bringToFront(); + await f.page.evaluate(() => { dispatchEvent(new Event("focus")); dispatchEvent(new PageTransitionEvent("pageshow")); }); + assert.equal((await f.views()).length, 1); + assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), false); + assert.deepEqual(await f.page.evaluate(() => window.__choiceAccess), []); }); -test("an active opt-in tab expires its grant without reload or focus changes", async t => { - const f = await openConsent(t, { choice: saved("granted", { expires_at: new Date(Date.now() + 10000).toISOString() }), region: "opt_in", clock: true }); - await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); - await f.page.evaluate(() => { window.__noReload = true; }); - await f.page.clock.runFor(10500); +test("public-private-public navigation excludes the private interval without replay", async t => { + const f = await openSite(t); + await f.page.getByRole("link", { name: "Private route", exact: true }).click(); + await f.page.waitForURL("**/private"); assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); - assert.equal(await f.page.evaluate(() => window.__noReload), true); - await f.page.getByRole("heading", { name: "Optional Google Analytics", exact: true }).waitFor(); - const commands = await f.page.evaluate(() => window.dataLayer.filter(entry => entry[0] === "consent").map(entry => Array.from(entry))); - assert.deepEqual(commands.at(-1), ["consent", "update", { analytics_storage: "denied" }]); + assert.equal((await f.views()).length, 1); + await f.page.getByRole("link", { name: "Next page", exact: true }).click(); + await f.page.locator('.main[data-analytics-path="/second"]').waitFor(); + assert.deepEqual((await f.views()).map(view => view.page_location), [origin + "/", origin + "/second"]); + if (sdk) { await deliveredViews(f, 2); assert.doesNotMatch(JSON.stringify(f.events), /\/private/); } }); -test("180-day choice expiry reschedules safely past the browser timeout maximum", async t => { - const f = await openConsent(t, { choice: saved("granted"), region: "opt_in", clock: true }); - await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); - let remaining = await f.page.evaluate(key => Date.parse(JSON.parse(localStorage.getItem(key)).expires_at) - Date.now(), key); - // Playwright's own fast-forward call also has a signed-32-bit bound. - while (remaining > 2000) { - await f.page.clock.fastForward(Math.min(2000000000, remaining - 1000)); - assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), false, "rescheduling does not expire a still-valid choice"); - remaining = await f.page.evaluate(key => Date.parse(JSON.parse(localStorage.getItem(key)).expires_at) - Date.now(), key); - } - await f.page.clock.runFor(1500); - assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true, "expiry fires in the active tab at its deadline"); +test("a document starting private can measure its later committed public page", async t => { + const f = await openSite(t, { path: "/private" }); + assert.equal(f.sdkRequests.length, 0); + await f.page.getByRole("link", { name: "Next page", exact: true }).click(); + await f.page.locator('.main[data-analytics-path="/second"]').waitFor(); + assert.deepEqual((await f.views()).map(view => view.page_location), [origin + "/second"]); + assert.equal(f.sdkRequests.length, 1); + if (sdk) { await deliveredViews(f, 1); assert.equal(f.events.find(event => event.en === "page_view").dl, origin + "/second"); } }); -test("the real SDK retains native enhanced events and native cross-domain identity", { skip: !sdk && "needs the coordinator-approved SDK" }, async t => { - const f = await openConsent(t, { choice: saved("granted") }); - for (let i = 0; i < 40 && !f.events.some(event => event.en === "page_view"); i++) await f.page.waitForTimeout(100); - await f.page.getByRole("textbox", { name: "Topic" }).fill("PRIVATE_PUBLIC_FORM_VALUE"); - await f.page.getByRole("button", { name: "Submit", exact: true }).click(); - await f.page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); - await f.page.waitForTimeout(1200); - let popup = f.context.waitForEvent("page"); - await f.page.getByRole("link", { name: "Public reference", exact: true }).click(); - await (await popup).close(); - const download = f.page.waitForEvent("download"); - await f.page.getByRole("link", { name: "Download", exact: true }).click(); - await download; - popup = f.context.waitForEvent("page"); - await f.page.getByRole("link", { name: "Catalog", exact: true }).click(); - await (await popup).close(); - await f.page.waitForTimeout(2200); - const names = new Set(f.events.map(event => event.en)); - for (const name of ["page_view", "scroll", "click", "file_download", "form_start", "form_submit"]) assert.ok(names.has(name), `native ${name}`); - const decorated = f.outbound.find(url => url.startsWith("https://clawhub.ai/")); - assert.ok(new URL(decorated).searchParams.get("_gl"), "native linker decorates the cross-domain catalog journey"); - const docsCookie = (await f.context.cookies(origin)).find(cookie => cookie.name === "_ga"); - const aiCookie = (await f.context.cookies("https://openclaw.ai/")).find(cookie => cookie.name === "_ga"); - assert.ok(docsCookie && aiCookie && docsCookie.value === aiCookie.value, "native parent-domain identity reaches ai/docs/community"); - assert.doesNotMatch(JSON.stringify([...f.events, ...f.controls]), /PRIVATE_PUBLIC_FORM_VALUE/); - await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); - await f.page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); - await f.page.getByRole("link", { name: "Catalog", exact: true }).evaluate(link => { link.href = "https://clawhub.ai/catalog/public-after-denial"; }); - popup = f.context.waitForEvent("page"); - await f.page.getByRole("link", { name: "Catalog", exact: true }).click(); - await (await popup).close(); - const deniedJourney = f.outbound.find(url => url.startsWith("https://clawhub.ai/catalog/public-after-denial")); - assert.equal(new URL(deniedJourney).searchParams.has("_gl"), false, "a new denied journey does not forward analytics identity"); - t.diagnostic(`Native SDK event families: ${[...names].sort().join(", ")}; control requests: ${f.controls.length}`); +test("browser privacy changes stop fresh collection without a reload or denied replay", async t => { + const f = await openSite(t); + await f.page.evaluate(() => { window.__retained = true; window.__gpc = true; dispatchEvent(new Event("focus")); }); + assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); + await f.page.getByRole("link", { name: "Next page", exact: true }).click(); + await f.page.locator('.main[data-analytics-path="/second"]').waitFor(); + assert.equal((await f.views()).length, 1); + await f.page.evaluate(() => { window.gtag("event", "select_content", { content_id: "DENIED_SENTINEL" }); }); + await f.page.waitForTimeout(150); + await f.page.evaluate(() => { window.__gpc = false; dispatchEvent(new Event("focus")); }); + assert.equal(await f.page.evaluate(() => window.__retained), true); + assert.deepEqual((await f.views()).map(view => view.page_location), [origin + "/", origin + "/second"]); + if (sdk) { await deliveredViews(f, 2); assert.doesNotMatch(JSON.stringify([...f.events, ...f.controls]), /DENIED_SENTINEL/); } }); diff --git a/scripts/docs-site/analytics-events.mjs b/scripts/docs-site/analytics-events.mjs index ff56578fad..56e1191afc 100644 --- a/scripts/docs-site/analytics-events.mjs +++ b/scripts/docs-site/analytics-events.mjs @@ -186,7 +186,7 @@ export function createDocsAnalyticsEvents(analytics) { } } readingTimer = setInterval(() => { - if (!analytics.context() || document.visibilityState !== "visible" || document.querySelector(".search-modal.open,.docs-chat.open,[data-analytics-consent]:not([hidden])")) return; + if (!analytics.context() || document.visibilityState !== "visible" || document.querySelector(".search-modal.open,.docs-chat.open")) return; visibleSeconds++; for (const engagement_seconds of [30, 60]) { if (visibleSeconds >= engagement_seconds && !readingMilestones.has(engagement_seconds) diff --git a/scripts/docs-site/analytics.test.mjs b/scripts/docs-site/analytics.test.mjs index ec26e1b42e..a18da530aa 100644 --- a/scripts/docs-site/analytics.test.mjs +++ b/scripts/docs-site/analytics.test.mjs @@ -63,8 +63,6 @@ test("native GA4 owns one sanitized view per committed docs navigation", { timeo t.after(async () => { releasePrivate?.(); await browser.close(); }); const context = await browser.newContext({ serviceWorkers: "block" }); await context.addInitScript(() => { - const now = Date.now(); - localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: "granted", updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() })); }); context.setDefaultTimeout(10_000); const requests = [];