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: `