From 73c739e413dcd41778a5150485022c5a789b0ef8 Mon Sep 17 00:00:00 2001 From: Chelsea Kelly-Reif <3114598+ChelseaKR@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:21:24 -0700 Subject: [PATCH 1/2] feat(site): add google analytics 4 to the pages, with a privacy page Owner decision 2026-09-17: GA4 on every public site, with privacy copy changed to match. src/perimeter/analytics.py holds the measurement ID and the loader; render.page puts the loader and a footer opt-out on every page, and the build now also writes privacy.html. An empty ID removes all of it; the JSON artifacts never carry any. The loader does nothing unless served from chelseakr.github.io under /perimeter/ (so the file:// pages both accessibility gates read never reach Google), under Global Privacy Control or Do Not Track, or after the footer opt-out (localStorage perimeter:analytics-opt-out). Google signals and ad personalisation are off; Consent Mode v2 denies the ad signals everywhere and analytics storage in the EEA, UK and CH. The README's "these pages ship no script and this project takes no telemetry" and "no script shipped" now describe the loader. tests/test_analytics.py executes it in Node and deletes each guard as a negative control, asserting the deletion landed. --- CHANGELOG.md | 17 + README.md | 28 +- site/dins.html | 67 ++++ site/index.html | 67 ++++ site/perimeters.html | 67 ++++ site/privacy.html | 311 +++++++++++++++++ src/perimeter/analytics.py | 209 +++++++++++ src/perimeter/cli.py | 11 +- src/perimeter/render.py | 81 ++++- tests/test_analytics.py | 441 ++++++++++++++++++++++++ tools/a11y.mjs | 5 +- tools/a11y_browser/playwright.config.ts | 8 +- 12 files changed, 1299 insertions(+), 13 deletions(-) create mode 100644 site/privacy.html create mode 100644 src/perimeter/analytics.py create mode 100644 tests/test_analytics.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a737b2..369d7fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project a ## [Unreleased] +### Added, Google Analytics 4 on the pages, with a privacy page + +- **Owner decision 2026-09-17: GA4 on every public site, with privacy copy changed to + match.** `src/perimeter/analytics.py` holds the measurement ID (`GA4_MEASUREMENT_ID`, + `G-8PD9WL8LN0`) and the loader, and `render.page` puts both the loader and a footer + "Opt out of analytics" control on every page. The build now also writes + `privacy.html`. An empty ID removes all of it, and the JSON artifacts never carry any. +- **Guarded the same way on every page.** The loader does nothing off + `chelseakr.github.io` under `/perimeter/`, under Global Privacy Control or Do Not Track, + or after an opt-out (localStorage `perimeter:analytics-opt-out`). Google signals and ad + personalisation are off, and Consent Mode v2 denies the advertising signals everywhere + and analytics storage in the EEA, the UK and Switzerland. +- **Claims changed to match.** The README's Observability row said the pages ship no + script and the project takes no telemetry, and its Performance row said no script is + shipped; both now describe the loader. `tests/test_analytics.py` executes the loader in + Node and deletes each guard as a negative control, asserting the deletion landed. + ### Fixed, five fields published a zero nobody had ruled on, and the registry could not say so - **A reviewed decision and an unexamined field published the same thing.** `NOOFCARSONPROPERTY`, diff --git a/README.md b/README.md index 35b0140..cc0ede9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ and [damage inspection coverage](https://chelseakr.github.io/perimeter/dins.html `.github/workflows/pages.yml` publishes `site/` from `main`, running `html-validate` and axe-core over the exact bytes it is about to serve rather than over a rebuild of them. -Or run it yourself. This builds the same three pages from the committed fixtures, needs no +Or run it yourself. This builds the same pages from the committed fixtures, needs no network and no CAL FIRE download, and takes about a minute: ```sh @@ -246,7 +246,7 @@ Gated, in CI: pass: `tools/a11y.mjs` fails on any undecided rule that is not declared in it with a reason and with where the rule's subject is checked instead, and it prints every undecided rule on every run, passing or failing. Three are declared and land on all - three pages (`color-contrast`, `landmark-one-main`, `page-has-heading-one`); the latter + four pages (`color-contrast`, `landmark-one-main`, `page-has-heading-one`); the latter two are decided from the markup in `tests/test_pages_html.py` instead. `tests/test_a11y_gate.py` runs the gate against pages that should fail it. - The same rule sets again, in Chromium, where nothing is undecidable. That run declares @@ -304,6 +304,25 @@ should fail it, so the gate is known to be able to fail. `is_fixture: true` and publishes `null` for every acquisition fact, so fixture output cannot pass itself off as a measurement of the real files. +### Analytics on the pages + +The pages run Google Analytics 4 (owner decision 2026-09-17: GA4 on every public site, +with privacy copy changed to match), and `privacy.html`, linked from every footer, says +what that means for a visitor. The measurement ID is `GA4_MEASUREMENT_ID` in +`src/perimeter/analytics.py`; setting it to `""` and rebuilding removes the loader, the +footer's opt-out control and every reference to Google from every page. The JSON artifacts +never carry any of it. The loader loads nothing unless the page is served from +`chelseakr.github.io` under `/perimeter/`, so a local build, the `file://` pages the +accessibility gates read, and CI never report to the property. It also loads nothing when +the browser sends Global Privacy Control or Do Not Track, or after the visitor uses the +footer's "Opt out of analytics" button, remembered in local storage as +`perimeter:analytics-opt-out` (a key that names this project, because every +`chelseakr.github.io` site shares one origin). Google signals and ad personalisation are +off, the advertising consent signals are denied everywhere, and analytics cookies are +denied in the EEA, the UK and Switzerland. `tests/test_analytics.py` runs the committed +loader in Node against each of those cases and deletes each guard in turn to prove the test +notices. + ## Scope Coverage measurement only. This project does not model fire risk, does not track @@ -323,6 +342,7 @@ here is how much of each published field is actually filled in, and what the bla | `src/perimeter/coverage.py` | The two reports | | `src/perimeter/artifacts.py` | Deterministic JSON | | `src/perimeter/render.py` | The static pages | +| `src/perimeter/analytics.py` | The Google Analytics 4 loader and the footer's opt-out, and the one place the measurement ID goes | | `src/perimeter/acquire.py` | The only code that touches the network. Run by hand, never in CI | | `src/perimeter/survey.py` | Inventory a retrieval's candidate markers before a build refuses one. Declares nothing | | `tools/a11y.mjs` | axe-core over the built pages in a headless DOM; an undecided rule is not a pass | @@ -364,7 +384,7 @@ build time. Nothing in CI can check it against the source. | Code Quality | Applies: uv, ruff, mypy `--strict`, pytest with branch coverage at 100% over `src/` with nothing omitted, against a 90% floor. `make lock-check` runs `uv lock --check`, because CQ-09's prescribed `uv sync --frozen` exits 0 on lockfile drift (measured 2026-08-15) | | Security & Supply-Chain | Applies: semgrep, gitleaks, pip-audit, `npm audit`, CodeQL over actions/python/javascript, every action SHA-pinned, `permissions: contents: read` at the top of every workflow, `persist-credentials: false` on every checkout. Not met: no SBOM, no OpenSSF Scorecard workflow, no `osv-scanner` alongside pip-audit (SEC-11, SEC-13), no scheduled trufflehog run (SEC-19), and Dependabot **security updates** are disabled, so an advisory raises an alert and opens no pull request: the four `fast-uri` advisories that had `verify` red from 2026-09-03 were all raised as alerts and all cleared by a hand-resolved lockfile bump. Alerts themselves are on, and were enabled 2026-09-05; this row said they were disabled until 2026-09-06 | | CI/CD | Applies (not met). `main` has no ruleset and no branch protection, so the gates report and block nothing. The `protect-main` profile is committed at `.github/rulesets/main.json` and deliberately not applied; applying it is a live repository setting | -| Observability | Applies (Tier B+C), which is what the manifest records and what OBS section 0 asks a repo with two surfaces to state. Tier C is the library and CLI writing to stdout: OTel is out of scope with no network surface, which section 10 allows a Tier C surface to declare. Tier B is the published Pages site. Not met, and the B half is the larger gap: section 8 asks a Tier B frontend for a Core Web Vitals RUM beacon, which means shipping a script that reports readers of a civic-data page back to somebody, and these pages ship no script and this project takes no telemetry; that refusal is a position, not an oversight, and the standard has no N/A for it. Also not met: the Lighthouse-CI lab gate on LCP, INP and CLS, which does not need a beacon and is the half that could be built; no `docs/ROADMAP.md` carrying the tier declaration section 0 requires; and no operations runbook | +| Observability | Applies (Tier B+C), which is what the manifest records and what OBS section 0 asks a repo with two surfaces to state. Tier C is the library and CLI writing to stdout: OTel is out of scope with no network surface, which section 10 allows a Tier C surface to declare. Tier B is the published Pages site. Not met, and the B half is the larger gap: section 8 asks a Tier B frontend for a Core Web Vitals RUM beacon, and none is built. The pages do now run Google Analytics 4 (owner decision 2026-09-17: GA4 on every public site), which counts visits and is described on `privacy.html`; it is not configured as a RUM beacon, nothing here reads Web Vitals out of it, and nothing is gated on it. Also not met: the Lighthouse-CI lab gate on LCP, INP and CLS, which does not need a beacon and is the half that could be built; no `docs/ROADMAP.md` carrying the tier declaration section 0 requires; and no operations runbook | | Accessibility | Applies: html-validate and axe-core over the built pages in CI, in jsdom and again in Chromium, plus SC 1.4.10 Reflow at 320 by 256, contrast measured arithmetically over both palettes, and the same structural floor asserted from Python. An axe rule that comes back undecided fails the jsdom gate unless it is declared with a reason and with where it is checked instead; the browser run declares nothing and fails on any undecided rule. Nothing is suppressed anywhere, so there is no `waivers.yml` and A11Y-06 and A11Y-09 are met rather than waived. Not met: no ACR, and the manual checks README names under "What still needs a person" are unverified. Measured 2026-08-27: the browser run found `scrollable-region-focusable`, serious, on both measurement pages, which the jsdom run cannot see; the scroll containers are now named sections with `tabindex="0"` | | Internationalization | Applies (not met). Civic data presented to the public is in scope per I18N section 1, and these pages are English only with no catalog and no `docs/I18N.md` declaration | | AI Evaluation | N/A (no model, no LLM, no generated text anywhere in the pipeline or the pages) | @@ -372,7 +392,7 @@ build time. Nothing in CI can check it against the source. | Documentation | Applies: README, `PROVENANCE.md`, `docs/MARKERS.md`, `CONTRIBUTING.md`, `SECURITY.md`, `CHANGELOG.md`, `CITATION.cff`, a `docs/adr/` log, this table, and a `.standards-version` pin that `tests/test_standards_conformance.py` reads | | Release & Versioning | Applies (not met). Version `0.1.0` in `pyproject.toml` and `CITATION.cff`, no tag cut, no signed tag, no release workflow, no published artifact. The distribution name is `perimeter-wildfire` rather than `perimeter`: the bare name on PyPI belongs to YunoJuno's Django access-control middleware, so a release under it was never possible, and the repository carried a name that could not have accepted an upload. `perimeter-wildfire` was free when chosen (2026-09-07); free is not reserved, and nothing has been uploaded. That is now a gate rather than a sentence: `tests/test_release_claims.py` reads `git tag --list` and fails if the declared version is neither tagged nor disclosed as untagged in this row or the status line, if `CITATION.cff` restates a different version, or if `date-released` appears without a tag naming it. `CITATION.cff` carried `date-released: 2026-08-07` against no release until this row was made enforceable | | Responsible-Tech Framework | Applies: unofficial framing on every served page, no claim about any agency's infrastructure or security posture, no address, parcel number or assessed value republished, and an acquisition path that stops rather than routing around an access control. Not met: no dated ethics, transparency or residual-risk artifacts | -| Performance | Applies (not met). Three static pages, no script shipped, no web font; that is a good starting position and it is not a measurement. No budget recorded and no Lighthouse run | +| Performance | Applies (not met). Four static pages and no web font. The one script is the inline Google Analytics 4 loader, which appends gtag.js asynchronously on the production host and blocks nothing. That is a good starting position and it is not a measurement. No budget recorded and no Lighthouse run | | Incident Response | Applies: `SECURITY.md` routes reports to GitHub private vulnerability reporting with a 72-hour acknowledgment SLA. Not met: no severity convention, no secret-leak runbook, no committed-postmortem requirement | | Data Governance | Applies (L1): openly licensed public civic data, republished only as counts, handled defensively above the tier because the DINS file carries site addresses and parcel numbers (`data/raw/` gitignored, fixtures hand-written rather than sampled, no identifying field republished). A data card per source under `docs/data/` carries the seven rows section 1 requires, including the refresh cadence, the staleness SLA and the tier, which were the gap this row recorded (DG-01). DG-04 is a gate: `tests/test_data_cards.py` fails the build when a retrieval is older than the SLA its card states, and the clock lives there rather than in the artifacts, which have none. DG-03 is `require_columns` and `FieldSpec.classify`, which refuse a file per row rather than validating the first one. Not met: DG-02 is satisfied at the file level and not the record level, since no record is republished and every count's artifact names the source, version, retrieval date and hash; and DG-19 has nothing to link, because there has been one retrieval | | AI Development Measurement | Applies (not met). No baseline and no outcome metrics recorded for this repository's development stream | diff --git a/site/dins.html b/site/dins.html index eba81fa..b425ad1 100644 --- a/site/dins.html +++ b/site/dins.html @@ -20,6 +20,70 @@ + + + + + +
+
+ + +
+

Privacy

+
+

These pages count visits with Google Analytics 4, a service of Google LLC in the +United States. Nothing else on them tracks you, and the JSON artifacts under +data/ carry no tracking of any kind.

+

What Google Analytics records

+

For each page you open: the page address and the page you came from, the time, your +browser, device and screen size, your language, and a rough location that Google works out +from your IP address. Google Analytics 4 does not store the IP address itself. By default it +also records scrolling and clicks on links that leave this site.

+

Cookies

+

Outside the places listed below, Google Analytics sets two cookies on +chelseakr.github.io, named _ga and _ga_ followed by an ID. They let +it tell a returning browser from a new one, and last up to two years. In the European +Economic Area, the United Kingdom and Switzerland it sets no analytics cookies. There, +Google still receives a cookieless ping for each page.

+

Advertising features are off

+

Google signals and ad personalisation are both turned off, and the advertising storage, +ad user data and ad personalisation consent signals are denied everywhere. Google keeps the +event data for 14 months. See +Google's privacy policy.

+

Opting out

+ +

Hosting

+

GitHub Pages serves this site. Like any web host, GitHub receives each request, +including your IP address; see the +GitHub +General Privacy Statement.

+ +
+ +
+ + diff --git a/src/perimeter/analytics.py b/src/perimeter/analytics.py new file mode 100644 index 0000000..38cee10 --- /dev/null +++ b/src/perimeter/analytics.py @@ -0,0 +1,209 @@ +"""Google Analytics 4 on the published pages, and the guards around it. + +Owner decision 2026-09-17: GA4 on every public site, with the privacy copy changed to match. +This module is the one place that decision lives. ``perimeter.render.page`` asks it for +the loader that goes in every page's ```` and for the footer's privacy line, and +``privacy.html`` says what a build actually ships because it is rendered from +:data:`GA4_MEASUREMENT_ID` too. + +The measurement ID is public, since every page that loads GA hands it to the browser, so it +is committed here as configuration rather than kept as a secret. Empty means the build emits +no analytics at all: no `` +""" + + +def measurement_id(value: str | None) -> str | None: + """None for an unset or blank ID, the ID itself when well formed. + + Anything else raises rather than being written into a script: a typo should fail the + build, not publish a broken tag. + """ + if value is None or not value.strip(): + return None + value = value.strip() + if not MEASUREMENT_ID_RE.fullmatch(value): + raise ValueError(f"not a GA4 measurement ID (expected G-XXXXXXXXXX): {value!r}") + return value + + +def head_snippet(ga4_id: str | None) -> str: + """The loader for one page's ````, or "" when no ID is set.""" + mid = measurement_id(ga4_id) + if mid is None: + return "" + replacements = { + "__OPT_OUT_KEY__": json.dumps(GA4_OPT_OUT_KEY), + "__GA_DISABLE__": json.dumps(f"ga-disable-{mid}"), + "__HOST__": json.dumps(GA4_HOST), + "__PATH__": json.dumps(GA4_PATH), + "__DENIED_REGIONS__": json.dumps(list(ANALYTICS_DENIED_REGIONS)), + "__ID__": json.dumps(mid), + "__GTAG_SRC__": json.dumps(f"{GTAG_JS_URL}?id={mid}"), + **{token: json.dumps(message) for token, message in OPT_OUT_MESSAGES.items()}, + } + snippet = _TEMPLATE + for token, value in replacements.items(): + snippet = snippet.replace(token, value) + return snippet + + +def footer_note(ga4_id: str | None) -> str: + """The footer's privacy line, true for the build it is in.""" + if measurement_id(ga4_id) is None: + return ( + '

This site runs no analytics and sets no cookies. ' + 'Privacy.

' + ) + return ( + # "Google Analytics" without the "4": the footer is prose on every measurement + # page, and tests/test_pages_html.py holds every number in prose to a count. + '

This site counts visits with Google Analytics, advertising ' + "features off, and does not load it when your browser sends Global Privacy Control or " + 'Do Not Track. Privacy.\n' + '

' + ) diff --git a/src/perimeter/cli.py b/src/perimeter/cli.py index d1e91e1..ef358e3 100644 --- a/src/perimeter/cli.py +++ b/src/perimeter/cli.py @@ -12,7 +12,13 @@ from perimeter import schema_export from perimeter.artifacts import build -from perimeter.render import SOCIAL_CARD, dins_page, index_page, perimeters_page +from perimeter.render import ( + SOCIAL_CARD, + dins_page, + index_page, + perimeters_page, + privacy_page, +) from perimeter.sources import DINS, FRAP ASSETS = Path(__file__).resolve().parents[2] / "assets" @@ -34,7 +40,7 @@ def build_site( out_dir: Path, is_fixture: bool, ) -> list[Path]: - """Write both JSON artifacts and all three pages, and return what was written.""" + """Write both JSON artifacts and all four pages, and return what was written.""" perimeters, inspections = build( perimeters_source=perimeters_source, dins_source=dins_source, @@ -46,6 +52,7 @@ def build_site( "index.html": index_page(perimeters, inspections, is_fixture=is_fixture), "perimeters.html": perimeters_page(perimeters, is_fixture=is_fixture), "dins.html": dins_page(inspections, is_fixture=is_fixture), + "privacy.html": privacy_page(is_fixture=is_fixture), } written = [ out_dir / "data" / "perimeters-coverage.json", diff --git a/src/perimeter/render.py b/src/perimeter/render.py index d5431f3..2113d23 100644 --- a/src/perimeter/render.py +++ b/src/perimeter/render.py @@ -19,6 +19,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass +from perimeter import analytics from perimeter.cells import present_tenths_of_percent from perimeter.coverage import ( CohortCoverage, @@ -58,7 +59,12 @@ # Keyed by `active`, the value each page already uses to mark its own nav entry, so a # canonical and the highlighted tab cannot disagree about which page this is. The index # canonicalises to the directory form, which is the URL Pages serves it at. -CANONICAL_PATH = {"index": "", "perimeters": "perimeters.html", "dins": "dins.html"} +CANONICAL_PATH = { + "index": "", + "perimeters": "perimeters.html", + "dins": "dins.html", + "privacy": "privacy.html", +} DISCLAIMER = ( "Unofficial. Not affiliated with or endorsed by CAL FIRE, FRAP, or any California " @@ -326,6 +332,7 @@ def tokens(palette: Mapping[str, str], *, indent: str = " ") -> str: footer.page { border-top: 1px solid var(--rule); margin-top: 4rem; padding-top: 1.6rem; font-size: .84rem; color: var(--ink-3); } footer.page p { max-width: 44rem; } +.link-button { font: inherit; color: var(--accent); background: none; border: 0; padding: 0; text-decoration: underline; cursor: pointer; } .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1.4rem; margin: 2rem 0; } .card { border: 1px solid var(--rule); background: var(--surface-raised); padding: 1.3rem 1.4rem 1.5rem; } .card h3 { margin-top: 0; color: var(--ink); font-size: 1.08rem; } @@ -600,6 +607,7 @@ def page( body: str, active: str, is_fixture: bool, + ga4_id: str | None = analytics.GA4_MEASUREMENT_ID, ) -> str: fixture_banner = ( '

Fixture build. These numbers describe the committed test ' @@ -640,7 +648,7 @@ def page( - +{analytics.head_snippet(ga4_id)} @@ -668,6 +676,7 @@ def page( DINS) say what every key means, including which of the three counts is a recorded value, which is a recorded unknown, and which is an empty cell.

+{analytics.footer_note(ga4_id)} @@ -1411,3 +1420,71 @@ def index_page( active="index", is_fixture=is_fixture, ) + + +_HOSTING = """

Hosting

+

GitHub Pages serves this site. Like any web host, GitHub receives each request, +including your IP address; see the +GitHub +General Privacy Statement.

""" + + +def _privacy_body(ga4_id: str | None) -> str: + """What the privacy page says, which depends on whether the build has an ID.""" + if analytics.measurement_id(ga4_id) is None: + return ( + "

This site runs no analytics, loads no third-party script and sets no " + f"cookies.

\n{_HOSTING}" + ) + return f"""

These pages count visits with Google Analytics 4, a service of Google LLC in the +United States. Nothing else on them tracks you, and the JSON artifacts under +data/ carry no tracking of any kind.

+

What Google Analytics records

+

For each page you open: the page address and the page you came from, the time, your +browser, device and screen size, your language, and a rough location that Google works out +from your IP address. Google Analytics 4 does not store the IP address itself. By default it +also records scrolling and clicks on links that leave this site.

+

Cookies

+

Outside the places listed below, Google Analytics sets two cookies on +chelseakr.github.io, named _ga and _ga_ followed by an ID. They let +it tell a returning browser from a new one, and last up to two years. In the European +Economic Area, the United Kingdom and Switzerland it sets no analytics cookies. There, +Google still receives a cookieless ping for each page.

+

Advertising features are off

+

Google signals and ad personalisation are both turned off, and the advertising storage, +ad user data and ad personalisation consent signals are denied everywhere. Google keeps the +event data for {esc(analytics.GA4_DATA_RETENTION)}. See +Google's privacy policy.

+

Opting out

+ +{_HOSTING}""" + + +def privacy_page( + *, is_fixture: bool, ga4_id: str | None = analytics.GA4_MEASUREMENT_ID +) -> str: + """``privacy.html``: what this site collects, true for the build it is in.""" + body = f""" +
+

Privacy

+
+{_privacy_body(ga4_id)} +""" + return page( + title="Perimeter | Privacy", + description="What this site collects about visitors, and how to opt out.", + body=body, + active="privacy", + is_fixture=is_fixture, + ga4_id=ga4_id, + ) diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..96a65cb --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,441 @@ +"""Google Analytics 4 on the published pages, and the guards around it. + +Owner decision 2026-09-17: GA4 on every public site, with the privacy copy changed to match. +:mod:`perimeter.analytics` owns the loader. This module holds it to three things. + +**The build.** No ID means no analytics on any page: no script, no reference to Google, no +opt-out control, and copy that says so. A malformed ID fails the build. With the committed +ID, every committed page carries exactly one loader, the footer control and a link to the +privacy page, and the JSON artifacts carry nothing. + +**The loader, executed.** The script is lifted out of each committed page and run in Node +against a stubbed ``window``, ``navigator``, ``document`` and ``localStorage``. Off the +production host or outside this project's path, under Global Privacy Control, under any of +the Do Not Track spellings, or after the footer opt-out, it creates no ``dataLayer`` and +requests nothing. Otherwise it sets both Consent Mode defaults before ``config``, turns +Google signals and ad personalisation off, and appends gtag.js once. + +**Negative controls.** Each guard is deleted from the script in turn. Every control first +asserts that the deletion landed (the guard occurred exactly once before and not at all +after), then asserts that GA now loads in the case the guard exists for. A control whose +sabotage silently no-ops would otherwise read as a pass. + +Node is on every GitHub-hosted runner. Locally the executed tests skip when it is missing; +under CI they fail instead, so a runner without Node cannot turn them into a green skip. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +from pathlib import Path +from typing import Any, Final + +import pytest + +from perimeter import analytics, render + +SITE_DIR: Final = Path(__file__).resolve().parents[1] / "site" +INDEX: Final = SITE_DIR / "index.html" +PRIVACY: Final = SITE_DIR / "privacy.html" +PAGES: Final = tuple( + SITE_DIR / name + for name in ("index.html", "perimeters.html", "dins.html", "privacy.html") +) + +ID: Final = "G-8PD9WL8LN0" +KEY: Final = "perimeter:analytics-opt-out" +GTAG_SRC: Final = f"https://www.googletagmanager.com/gtag/js?id={ID}" +DENIED_REGIONS: Final = [ + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", + "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", + "IS", "LI", "NO", "GB", "CH", +] # fmt: skip + +PRODUCTION: Final = {"hostname": "chelseakr.github.io", "pathname": "/perimeter/"} + +GUARDS: Final = { + "host": 'if (w.location.hostname !== "chelseakr.github.io") return;', + "path": 'if (w.location.pathname.indexOf("/perimeter/") !== 0) return;', + "gpc": "if (n.globalPrivacyControl === true) return;", + "dnt": 'if (dnt === "1" || dnt === "yes") return;', + "opt-out": "if (optedOut()) return;", +} +"""The guards, exactly as the committed script spells them. The negative controls delete +each one, and the occurrence count is how they prove the deletion landed.""" + +HARNESS: Final = r""" +const vm = require("vm"); +const fs = require("fs"); +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; +for (const sc of input.scenarios) { + const appended = []; + const listeners = {}; + const store = Object.assign({}, sc.storage || {}); + const status = { textContent: "" }; + const button = { + hidden: false, textContent: "Opt out of analytics", handlers: {}, + addEventListener(type, fn) { this.handlers[type] = fn; }, + }; + const box = { + hidden: true, + querySelector(sel) { + return sel === "button" ? button : sel === "[role=status]" ? status : null; + }, + }; + const document = { + head: { appendChild(el) { appended.push(el); } }, + createElement(tag) { return { tag: tag }; }, + addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }, + querySelector(sel) { return sel === "[data-analytics-choice]" ? box : null; }, + }; + const window = { + location: { + hostname: sc.hostname, pathname: sc.pathname, protocol: sc.protocol || "https:", + }, + doNotTrack: sc.windowDnt, + }; + if (sc.storageThrows) { + Object.defineProperty(window, "localStorage", { + get() { throw new Error("SecurityError"); }, + }); + } else { + window.localStorage = { + getItem(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; }, + setItem(k, v) { store[k] = String(v); }, + removeItem(k) { delete store[k]; }, + }; + } + const navigator = { + globalPrivacyControl: sc.gpc, doNotTrack: sc.dnt, msDoNotTrack: sc.msDnt, + }; + const ctx = vm.createContext({ + window: window, navigator: navigator, document: document, Date: Date, + }); + vm.runInContext(input.script, ctx); + (listeners.DOMContentLoaded || []).forEach((fn) => fn()); + const control = [{ + label: button.textContent, hidden: button.hidden, boxHidden: box.hidden, + status: status.textContent, + }]; + for (let i = 0; i < (sc.clicks || 0); i++) { + button.handlers.click(); + control.push({ + label: button.textContent, hidden: button.hidden, status: status.textContent, + flag: Object.prototype.hasOwnProperty.call(store, input.key) ? store[input.key] : null, + disabled: window["ga-disable-" + input.id], + }); + } + out[sc.name] = { + dataLayer: window.dataLayer === undefined ? null + : window.dataLayer.map( + (args) => Array.from(args).map((a) => (a instanceof Date ? "" : a)), + ), + scripts: appended.map((el) => ({ tag: el.tag, src: el.src, async: el.async })), + control: control, + }; +} +process.stdout.write(JSON.stringify(out)); +""" + +Scenario = dict[str, Any] +Result = dict[str, Any] + + +def loader(page: Path) -> str: + """The inline script in a committed page's ````, exactly as published.""" + head = page.read_text(encoding="utf-8").split("", 1)[0] + scripts = re.findall(r"", head, re.DOTALL) + assert len(scripts) == 1, ( + f"{page.name}: expected one inline script, found {len(scripts)}" + ) + return str(scripts[0]) + + +def run(script: str, scenarios: list[Scenario], tmp_path: Path) -> dict[str, Result]: + """Execute the loader once per scenario and report what it did.""" + node = shutil.which("node") + if node is None: + if os.environ.get("CI"): + pytest.fail("Node is required in CI to execute the GA4 loader") + pytest.skip("node is not installed; CI runs these") + harness = tmp_path / "harness.js" + harness.write_text(HARNESS, encoding="utf-8") + payload = json.dumps( + {"script": script, "scenarios": scenarios, "key": KEY, "id": ID} + ) + done = subprocess.run( # noqa: S603 -- fixed argv, no shell, input is this test's own JSON + [node, str(harness)], + input=payload, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert done.returncode == 0, f"node harness failed: {done.stderr}" + results: dict[str, Result] = json.loads(done.stdout) + assert set(results) == {s["name"] for s in scenarios} + return results + + +def scenario(name: str, **overrides: object) -> Scenario: + return {"name": name, **PRODUCTION, **overrides} + + +def loads_ga(result: Result) -> bool: + return result["dataLayer"] is not None or bool(result["scripts"]) + + +# --- the build --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("empty", ["", " ", None]) +def test_no_id_means_no_analytics_on_any_page(empty: str | None) -> None: + pages = { + "chrome": render.page( + title="t", + description="d", + body="

t

", + active="index", + is_fixture=False, + ga4_id=empty, + ), + "privacy": render.privacy_page(is_fixture=False, ga4_id=empty), + } + for name, page in pages.items(): + assert " None: + for bad in ("UA-12345-1", "G-abc123", 'G-ABC"};alert(1);//', "G-", "8PD9WL8LN0"): + with pytest.raises(ValueError, match="not a GA4 measurement ID"): + analytics.head_snippet(bad) + assert analytics.measurement_id(f" {ID} ") == ID + + +def test_each_committed_page_carries_one_loader_with_the_committed_id() -> None: + assert analytics.GA4_MEASUREMENT_ID == ID + for page in PAGES: + source = page.read_text(encoding="utf-8") + body = source.split("", 1)[1] + assert source.count(" None: + for page in PAGES: + footer = page.read_text(encoding="utf-8").split('