diff --git "a/.claude/Screenshot 2026-07-29 at 10.36.56\342\200\257AM.png" "b/.claude/Screenshot 2026-07-29 at 10.36.56\342\200\257AM.png" deleted file mode 100644 index acf097f..0000000 Binary files "a/.claude/Screenshot 2026-07-29 at 10.36.56\342\200\257AM.png" and /dev/null differ diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..102ad16 --- /dev/null +++ b/.flake8 @@ -0,0 +1,27 @@ +[flake8] +# Copied from dash-documentation-boilerplate (the network template). +# +# Line length is not policed: this repo's comments carry a lot of explanation +# and reflowing them to 79 columns would make them harder to read, not easier. +max-line-length = 120 +extend-ignore = E203, W503, E501 +exclude = + .git, + .venv, + __pycache__, + node_modules, + vendor, + dist, + build, + .idea, + dash_leaflet2, + docs/*/, +per-file-ignores = + # run.py imports Dash and the lib modules after `load_dotenv()`, which has + # to run first — the CLERK_* keys and CROSS_APP_WEBHOOK_SECRET must be in + # the environment before Dash construction imports anything that reads + # them. The appshell import at the bottom needs the page registry to be + # populated, so it cannot move to the top either. + run.py: E402 + # usage.py is the compiled-package harness, same import-order constraint. + usage.py: E402 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d02cdc7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,49 @@ +# Version drift is the network's chronic disease — satellites were still +# running a 2.0-era artifact the day 2.3.4 shipped, and nothing about a stale +# host looks broken from the outside. This is the standing fix. +# +# Copied from dash-documentation-boilerplate. The `dash-network` group is the +# point: a package release lands as ONE reviewable pull request per repo +# instead of five, which is the difference between a rollout and a chore. +# +# npm is here and not in the template because this repo also builds the +# dash-leaflet2 component bundle from src/ts. +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + dash-network: + patterns: + - "dash*" + - "plotly*" + - "markdown2dash" + + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + build-toolchain: + patterns: + - "*" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: monthly + groups: + actions: + patterns: + - "*" + + - package-ecosystem: docker + directory: "/" + schedule: + interval: monthly diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..c74dc11 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,118 @@ +name: CD + +# Deploys leaflet.2plot.dev, then checks the live site. +# +# The deploy step POSTs to a Render deploy hook held in the +# RENDER_DEPLOY_HOOK_URL secret. Without that secret the step is skipped and +# the workflow goes straight to verification — which is the situation this repo +# is in today: render.yaml sets `autoDeploy: true`, so Render is already +# building from GitHub on its own. Adding the secret later moves the trigger +# here without changing anything else. +on: + push: + branches: [main] + workflow_dispatch: + inputs: + target_url: + description: Site to verify (skips the deploy when set to another host) + required: false + type: string + +permissions: + contents: read + +concurrency: + group: cd-production + cancel-in-progress: false + +env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + SITE_URL: ${{ inputs.target_url || 'https://leaflet.2plot.dev' }} + +jobs: + test: + name: ci + uses: ./.github/workflows/ci.yml + + deploy: + name: deploy to render + needs: [test] + runs-on: ubuntu-latest + # Long enough for the wait loop below (a 120s settle plus up to 40 × 15s) + # and no longer. Without it the job inherits GitHub's six-hour default, + # which is how a platform that never comes back healthy holds the + # `cd-production` concurrency group all day. + timeout-minutes: 20 + environment: + name: production + url: https://leaflet.2plot.dev + outputs: + deployed: ${{ steps.hook.outputs.deployed }} + steps: + - name: Trigger the Render deploy hook + id: hook + env: + HOOK: ${{ secrets.RENDER_DEPLOY_HOOK_URL }} + run: | + if [ -z "$HOOK" ]; then + echo "::notice::RENDER_DEPLOY_HOOK_URL is not set. Skipping the deploy trigger and verifying whatever is currently live." + echo "deployed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + curl -fsS -X POST "$HOOK" > /dev/null + echo "deployed=true" >> "$GITHUB_OUTPUT" + + - name: Wait for the new build to serve traffic + if: steps.hook.outputs.deployed == 'true' + run: | + # Render swaps instances rather than restarting in place, so the old + # build answers /healthz throughout. Waiting for a 200 proves + # nothing; give the build time, then require SUSTAINED health. + # + # This site is on Render's free tier (render.yaml), which also sleeps + # after ~15 minutes idle — so a single 200 can just as easily be a + # cold start as a finished deploy. + sleep 120 + ok=0 + for _ in $(seq 1 40); do + if curl -fsS "$SITE_URL/healthz" > /dev/null; then + ok=$((ok + 1)) + [ "$ok" -ge 5 ] && break + else + ok=0 + fi + sleep 15 + done + if [ "$ok" -lt 5 ]; then + echo "::error::$SITE_URL never became reliably healthy" + exit 1 + fi + + verify: + name: verify the live site + needs: [deploy] + if: always() && needs.deploy.result != 'cancelled' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # The network battery first: it is the same script, with the same check + # names, that CI ran against the container this deploy shipped. A name + # that passed in CI and fails here isolates the fault to the deploy. + - name: Network smoke battery + run: python scripts/network_smoke.py --base-url "$SITE_URL" + + # Then the satellite-specific checks the battery does not make: every + # canonical, every crawler body, and every peer llms.txt in the + # directory actually resolving. Peer failures warn; this host's fail. + - name: Smoke-test the deployment + run: python scripts/smoke_live.py "$SITE_URL" + + - name: Report + if: failure() + run: | + echo "::error::Live verification failed for $SITE_URL. Every failure these check for is silent in production: a site identity that fell back to a framework default, a stale dash-improve-my-llms artifact, a canonical on the wrong host, a page serving the JavaScript stub, a missing network directory, and dead peer llms.txt links." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2c0a83..983e7a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,34 +1,181 @@ +# CI for leaflet.2plot.dev AND for the dash-leaflet2 PyPI package. +# +# This repo is unusual among the 2plot satellites: it ships two artifacts from +# one tree. The network's CI baseline (copied from +# dash-documentation-boilerplate, which 2plot.ai and 2plot.dev also run) covers +# the documentation site; the `package*` jobs below are this repo's own and +# cover the wheel. Both have to stay green. +# +# The network baseline, and why each piece is here: +# +# * least-privilege `permissions` and a cancel-in-progress `concurrency` +# group, so a workflow cannot write more than it reads and a rapid second +# push does not race the first; +# * an explicit `timeout-minutes` on every job — the default is six hours, +# which is how a hung `curl` burns a day of runner minutes unnoticed; +# * `actionlint`, because an invalid workflow file is the one defect CI +# structurally cannot report: the run dies before a job exists to fail; +# * the real Docker image, built with a buildx GHA cache, then BOOTED, then +# probed by the same battery that runs against production; +# * version fingerprints asserted INSIDE the image, because pip metadata is +# invisible from the outside and a stale artifact serves quietly; +# * a secretless in-process pytest suite — no CLERK_*, no +# CROSS_APP_WEBHOOK_SECRET — because the fail-closed behaviour is only +# provable when nothing is configured; +# * an advisory pip-audit. + name: CI -# Every push and PR runs the smoke suite against the full supported Dash range. -# pyproject declares `dash>=4.1`; this is what keeps that claim honest, and it -# is the same harness scripts/compat_matrix.py runs locally. +# Deliberately NOT `push: branches: [main]`. cd.yml runs on that push and its +# first job `uses:` this workflow, so a push to main would otherwise start two +# runs of it — which then contend for the `ci-${{ github.ref }}` concurrency +# group below and cancel each other. The work still gets done, but every push +# leaves a `cancelled` CI run next to the green CD one, which reads as a +# failure at a glance. +# +# So: pull requests get their own CI, and `main` is owned by CD. There is no +# coverage gap — CD cannot deploy without this workflow passing first. on: - push: - branches: [main] pull_request: workflow_dispatch: + # Called by cd.yml so a deploy can never ship something the matrix rejected. + workflow_call: + +# Read-only. Nothing here publishes, comments or tags; the deploy lives in +# cd.yml behind a `production` environment, and the PyPI release in release.yml. +permissions: + contents: read concurrency: group: ci-${{ github.ref }} cancel-in-progress: true -# TWO different Python floors, which is what an earlier version of this file -# got wrong by testing the docs site on 3.9: -# -# * the PACKAGE needs >= 3.9 — `dash` 4.4.1 itself requires 3.9, and that is -# what `requires-python` in pyproject.toml promises. Verified by the -# `package` job, which installs the wheel with nothing but Dash. -# * the DOCS SITE needs >= 3.10 — `python-frontmatter` 1.3 imports -# `typing.TypeGuard`, which is 3.10+. Nothing we can do about it here, and -# it does not constrain the package at all. -# -# So the smoke job never runs below 3.10, and the package job never installs -# the docs requirements. +env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + FORCE_COLOR: "1" + # Never let a CI run inherit production behaviour: the satellite reporter + # keys off CROSS_APP_WEBHOOK_SECRET, which is absent here by design. + APP_ENV: ci + jobs: - smoke: + lint: + name: lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install flake8 + - name: flake8 + run: flake8 lib components pages tests scripts run.py usage.py + + # The workflows lint themselves. This is not belt-and-braces: an invalid + # workflow file is the one defect CI structurally cannot report, because + # the run dies before a job exists to fail. A double-quoted string inside + # a ${{ }} expression is a LEX error that invalidates the whole file, and + # it surfaces only as `conclusion: failure` with zero jobs and nothing to + # click. actionlint catches it in a second, with the column underlined. + - name: actionlint + run: | + bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.7/scripts/download-actionlint.bash) 1.7.7 + ./actionlint -color + + test: + name: pytest · ${{ matrix.backend }} · py${{ matrix.python }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + # Flask is what production runs (the Dockerfile sets DASH_BACKEND); + # FastAPI is run.py's local default, so both need coverage. The + # before_request ordering that makes bot_hits countable is a WSGI + # concern, which is exactly why Flask cannot be the only backend here. + python: ["3.12"] + backend: [flask, fastapi] + include: + # The docs site's Python floor and ceiling, on the default backend. + # 3.10 is the floor: python-frontmatter 1.3 imports typing.TypeGuard. + - python: "3.10" + backend: flask + - python: "3.13" + backend: flask + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + + - name: Install the app + run: | + pip install -r requirements.txt + # markdown2dash 0.1.2 declares gunicorn<22 against the CVE-driven + # gunicorn>=23 floor. Same two-command install as the Dockerfile. + pip install --no-deps markdown2dash==0.1.2 + # Dash's own extras are required for the ASGI backends: a bare + # `fastapi` install is not enough for dash.backends._fastapi to + # import. httpx backs starlette's TestClient. + if [ "${{ matrix.backend }}" != "flask" ]; then + pip install "dash[${{ matrix.backend }}]" httpx + pip install "dash-improve-my-llms[${{ matrix.backend }}]>=2.3.4" + fi + pip install pytest + + - name: Confirm the pinned dependency versions + run: | + python - <<'PY' + import dash, dash_improve_my_llms as pkg, gunicorn + + def parts(v): + return tuple(int(x) for x in v.split(".")[:3] if x.isdigit()) + + # The docs site pins 4.4.1; the PACKAGE floor (dash>=4.1) is proven + # separately by the `package-python-range` job below. + assert parts(dash.__version__)[:2] >= (4, 1), dash.__version__ + # 2.3.4 is the network standard: below it `resolve_site_title` does + # not exist and this site's published identity degrades to app.title. + assert parts(pkg.__version__) >= (2, 3, 4), pkg.__version__ + # 21.x carried two request-smuggling CVEs (CVE-2024-6827, + # CVE-2024-1135). markdown2dash's spurious <22 pin must not win. + assert parts(gunicorn.__version__)[:2] >= (23, 0), gunicorn.__version__ + print(f"dash {dash.__version__}, dash-improve-my-llms " + f"{pkg.__version__}, gunicorn {gunicorn.__version__}") + PY + + # No CLERK_*, no CROSS_APP_WEBHOOK_SECRET, no SESSION_SECRET here ON + # PURPOSE. tests/conftest.py pins them empty and the fail-closed checks + # depend on that posture; a secret injected here would make the suite + # pass for the wrong reason. + - name: Test suite (${{ matrix.backend }}, zero secrets) + env: + DASH_BACKEND: ${{ matrix.backend }} + run: pytest tests -q + + - name: Boot under a production server + if: matrix.backend == 'flask' + run: | + gunicorn run:server -b 127.0.0.1:8050 --daemon --access-logfile - --error-logfile - + for _ in $(seq 1 30); do + curl -sf http://127.0.0.1:8050/healthz && break + sleep 1 + done + # A page that renders under the test client can still fail under a + # real WSGI worker — different import path, different working + # directory, no test-client conveniences. + curl -sf http://127.0.0.1:8050/ > /dev/null + curl -sf http://127.0.0.1:8050/pointer-events > /dev/null + # The battery, against the same server a satellite deploys. + python3 scripts/network_smoke.py --base-url http://127.0.0.1:8050 + + docs-compat: name: Docs · Dash ${{ matrix.dash }} · Python ${{ matrix.python }} runs-on: ubuntu-latest + timeout-minutes: 25 strategy: fail-fast: false matrix: @@ -71,6 +218,7 @@ jobs: run: | grep -v 'COMPAT-MATRIX: dash' requirements.txt > /tmp/reqs.txt python -m pip install -r /tmp/reqs.txt + python -m pip install --no-deps markdown2dash==0.1.2 - name: Report the resolved Dash version # A silent upgrade here would make the whole matrix meaningless. @@ -91,9 +239,93 @@ jobs: path: smoke-*.json if-no-files-found: ignore + docker: + name: docker image · boot · battery + runs-on: ubuntu-latest + timeout-minutes: 25 + needs: [test] + steps: + - uses: actions/checkout@v4 + + # The same build Render runs. This is where a dependency-resolution + # failure surfaces — at CI time, not deploy time, where the only signal + # is a dashboard log while the old image keeps serving. + - uses: docker/setup-buildx-action@v3 + - name: Build the production image + uses: docker/build-push-action@v6 + with: + context: . + tags: dash-leaflet2-docs:ci + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + + # pip metadata is invisible from outside a running host, so the versions + # are asserted here, inside the artifact that actually ships. + - name: Version fingerprints inside the image + run: | + docker run --rm dash-leaflet2-docs:ci python -c " + from importlib.metadata import version + + def parts(v): + return tuple(int(x) for x in v.split('.')[:3] if x.isdigit()) + + v = version('dash') + print('dash', v) + assert parts(v)[:2] >= (4, 1), f'expected dash >=4.1, image has {v}' + + v = version('dash-improve-my-llms') + print('dash-improve-my-llms', v) + assert parts(v) >= (2, 3, 4), f'expected >=2.3.4 (resolve_site_title), image has {v}' + + # markdown2dash installs with --no-deps to dodge its gunicorn<22 + # pin; this assert is what proves the dodge kept working. 21.x + # carried two request-smuggling CVEs (CVE-2024-6827, CVE-2024-1135). + v = version('gunicorn') + print('gunicorn', v) + assert parts(v)[:2] >= (23, 0), f'expected gunicorn>=23, image has {v}' + + # ...and that skipping its dependency graph did not skip the package. + import markdown2dash # noqa: F401 + print('markdown2dash importable') + " + + # Boot with no secrets: Clerk falls open (dev mode) and the reporter + # stays dormant. What this catches is any import-time or preload crash — + # the class of failure where the platform loops the worker and the deploy + # never goes live. + - name: Boot the container and wait for /healthz + run: | + docker run -d --name docs -p 8050:8050 dash-leaflet2-docs:ci + for i in $(seq 1 60); do + if curl -sf http://127.0.0.1:8050/healthz > /dev/null; then + echo "healthy after ~$((i*2))s" + exit 0 + fi + if [ "$(docker inspect -f '{{.State.Running}}' docs)" != "true" ]; then + echo "container exited during boot:" + docker logs docs + exit 1 + fi + sleep 2 + done + echo "never became healthy; last logs:" + docker logs --tail 100 docs + exit 1 + + # The SAME script CD runs against https://leaflet.2plot.dev, so a failure + # in CI and a failure in production read identically. + - name: Smoke battery against the booted container + run: python3 scripts/network_smoke.py --base-url http://127.0.0.1:8050 + + - name: Container logs (for the record) + if: always() + run: docker logs --tail 40 docs 2>/dev/null || true + package: name: Build + verify the wheel runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 with: @@ -161,6 +393,7 @@ jobs: name: Package · Python ${{ matrix.python }} needs: package runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -209,3 +442,22 @@ jobs: print(f"dash={dash.__version__} dl2={dl2.__version__} " f"components={len(dl2.__all__)} OK") PY + + pip-audit: + name: pip-audit (advisory) + runs-on: ubuntu-latest + timeout-minutes: 10 + # Advisory on purpose. A CVE in a transitive dependency of a docs site is + # worth knowing about the day it lands, and worth nobody's broken build at + # 2am. The report is the value; flip this off once the baseline is quiet. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install pip-audit + # Skip local vendor/ paths — pip-audit can only assess PyPI dists. + - run: | + grep -v '^\./vendor/' requirements.txt > /tmp/req-pypi.txt + pip-audit -r /tmp/req-pypi.txt --skip-editable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dda3d3f..9dc0afa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,10 +22,23 @@ on: type: boolean default: true +# Read-only by default; the two jobs that need more ask for it themselves +# (`id-token: write` to publish, `contents: write` to cut the GitHub Release). +permissions: + contents: read + +# Never let two releases race. NOT cancel-in-progress: a half-cancelled +# publish is the one state worth avoiding here, because a version can be +# uploaded to PyPI exactly once and is not replaceable afterwards. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: verify: name: Verify the tag runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -52,6 +65,7 @@ jobs: name: Build distributions needs: verify runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -66,6 +80,12 @@ jobs: python -m pip install --upgrade pip grep -v 'COMPAT-MATRIX: dash' requirements.txt > /tmp/reqs.txt python -m pip install "dash[fastapi]" -r /tmp/reqs.txt + # markdown2dash is NOT in requirements.txt: it declares gunicorn<22 + # against the CVE-driven gunicorn>=23 floor, so it installs without + # its dependency graph. pages/markdown.py imports it, so the smoke + # test below cannot even reach a page without this line. Same pair as + # the Dockerfile and ci.yml. + python -m pip install --no-deps markdown2dash==0.1.2 python scripts/smoke_test.py - name: Build @@ -83,6 +103,7 @@ jobs: name: Publish to PyPI needs: build runs-on: ubuntu-latest + timeout-minutes: 15 # The environment name must match the pending publisher configured on PyPI. # Add a required reviewer on this environment in repo settings if you want # a human approval gate between the tag and the upload. @@ -114,6 +135,7 @@ jobs: needs: publish if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write steps: diff --git a/.gitignore b/.gitignore index e026c03..932d1e8 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ visitor_analytics.json .DS_Store .idea/ .vscode/ +# Claude Code's per-project working directory: local settings, scratch writes +# and screenshots. Nothing here is needed to build, run or deploy the project. +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd90d0..064b259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,116 @@ Nothing yet. --- +## [0.2.1] — 2026-07-31 + +Brings this satellite onto the **2plot network standard** that 2plot.ai (root), +2plot.dev (hub) and `dash-documentation-boilerplate` (the template) now ship. +No `dl2.*` component changed; everything here is the documentation site, its +analytics and its CI. + +### Fixed + +- **Every page shipped an empty `og:image`.** Dash emits `og:image` and + `twitter:image` for each page and leaves them `content=""` when it can find + no image, which unfurls as a *blank* preview card on Facebook, Twitter/X, + Slack, Discord and LinkedIn — strictly worse than declaring no image at all. + `register_page(image_url=...)` now supplies the real absolute URL, served + from the 2plot CDN so a sleeping free-tier container never costs a preview. + `templates/index.html` deliberately declares only the auxiliaries Dash omits + (`og:image:width` / `height` / `alt` / `type` / `secure_url`, + `twitter:image:alt`), so it cannot duplicate the URL. +- **The web app manifest could never have offered an install.** Its `name` and + `short_name` were empty strings — which disqualifies a manifest outright — + and its icon `src` paths pointed at `/android-chrome-192x192.png` at the site + root, where nothing is served; the files live under `/assets/favicon_io/`. + Nothing linked to it either. Fixed, linked, and joined by + `apple-touch-icon` (iOS ignores the manifest and uses that for Add to Home + Screen) and the `msapplication-*` tiles. +- **Crawler traffic was never counted.** The per-request tracker was a Flask + `before_request` handler registered *after* `add_llms_routes`, and + dash-improve-my-llms' bot middleware answers every crawler with prerendered + HTML — which short-circuits the remaining `before_request` handlers. No + crawler request ever reached the ledger, so this site reported + `bot_hits: 0` to 2plot.ai structurally, for every day it has been live, + with nothing visibly broken. The tracker now wraps the WSGI/ASGI callable + instead (`_wsgi_tracker` / `_asgi_tracker`), which sits outside the whole + application and cannot be short-circuited. Registration order was not a + usable fix: Flask runs `before_request` handlers first-registered-first, + while Starlette makes the last-added middleware outermost, so no single + ordering is correct on all three backends. +- **The ad fetch and the traffic rollup polluted the hub's ledgers.** Both + server-to-server calls left as `python-requests/2.x`, which 2plot.dev and + 2plot.ai classify as a bot — so every docs page view here inflated the + hub's `bot_hits`. Both now send the network's internal-traffic User-Agent. +- **A control-board toggle could rename the site.** `apply_llms_state` + re-registers a page's metadata whenever a visibility verdict changes, using + the name the markdown loader recorded — `"Home"` for this site's root. One + flip of the home page's llms.txt switch would have overwritten the site + brand at runtime, silently degrading the published identity to a generic + word. `lib.page_visibility.published_name` now pins the root to + `SITE_BRAND`. +- **gunicorn was pinned under a security floor.** `gunicorn>=21.2,<22` was + holding the production server on a line carrying two HTTP request-smuggling + CVEs (CVE-2024-6827, CVE-2024-1135), because `markdown2dash` 0.1.2 declares + `gunicorn<22`. markdown2dash is now installed with `--no-deps` (its real + dependencies moved into `requirements.txt`, carrying its own version ranges) + and the floor is `gunicorn>=23.0.0`, asserted inside the built image by CI. + +### Added + +- **Explicit site identity.** `lib.constants.SITE_BRAND` — + *"dash-leaflet2 — Leaflet 2 maps for Dash"* — is now the one string on every + surface: `Dash(title=)`, `register_page_metadata(path="/")`, the home + markdown's H1 and the README. This matters because the home page is + registered as `"Home"`, which `resolve_site_title` skips as generic; without + the explicit registration the site published a framework fallback. +- **`scripts/network_smoke.py`** — the network's named-check battery, run + against the CI container and against production with identical check names. + Proves identity, the agent-facing document surfaces, the robots fingerprint, + hidden-page 404s and content negotiation. +- **`scripts/smoke_live.py`** — post-deploy checks: every canonical, every + crawler body, and every peer `llms.txt` in the directory. Peer failures warn + rather than fail, because gating a deploy on somebody else's certificate is + shared fate. +- **`tests/`** — a secretless in-process suite (80 tests) covering site + identity, the internal-traffic contract in both directions, the agent and + crawler surfaces, the social card and manifest, and *the smoke scripts + themselves*, so a battery that has rotted into a silent pass fails here + first. +- Two live battery checks for the surfaces above — `social_card_is_shareable` + (the image is declared once, is not empty, and actually resolves) and + `installable_as_an_app` (the manifest is linked, named, and its icons + resolve). Both fail invisibly in production otherwise: nobody sees their own + link previews, and no browser explains why it declined to offer an install. +- **`.github/workflows/cd.yml`** — deploy plus live verification, waiting for + five consecutive healthy responses after a 120s settle rather than a single + 200 (Render swaps instances, so the old build answers throughout). +- **`.github/dependabot.yml`** — weekly pip with a `dash-network` group, + weekly npm, monthly actions and Docker. + +### Changed + +- **`dash-improve-my-llms>=2.3.4`** (from 2.3.3), the network floor: 2.3.4 adds + `resolve_site_title`, without which the `/llms.txt` H1 and the llms viewer's + brand chip fall back to `app.title`. +- **CI on the network baseline**: `permissions: contents: read`, + `timeout-minutes` on every job, an `actionlint` step (an invalid workflow + file is the one defect CI structurally cannot report), a real Docker + build → boot → battery job with buildx GHA caching, version fingerprints + asserted inside the image, and an advisory `pip-audit`. CI now runs on + pull requests and `workflow_call` only — `main` belongs to CD, which calls + it. The existing wheel and Dash-compatibility jobs are unchanged. +- **The home page** is no longer the generated scaffold: it opens with the site + brand and describes what the library actually is. +- `templates/index.html` no longer publishes `pip-install-python.com` as this + site's Organization URL, author URL or footer link — it is not a 2plot + network host. Those now point at https://github.com/2plotai. +- The README's assets are served from `cdn.2plot.ai` rather than + `raw.githubusercontent.com`, so they render on PyPI (where the README is the + long description) as well as on GitHub. + +--- + ## [0.2.0] — 2026-07-28 First public release: the project splits into a private R&D checkout and this diff --git a/Dockerfile b/Dockerfile index 82127c2..8cb00f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,12 @@ WORKDIR /app COPY requirements.txt ./ COPY vendor/ ./vendor/ RUN pip install --no-cache-dir -r requirements.txt +# markdown2dash pins gunicorn<22, against the CVE-driven gunicorn>=23 floor in +# requirements.txt (CVE-2024-6827, CVE-2024-1135 — request smuggling). Its real +# dependencies are all in requirements.txt already, so it installs without its +# dependency graph. Same pair in .github/workflows/ci.yml; CI asserts the +# resolved gunicorn version inside this image. +RUN pip install --no-cache-dir --no-deps markdown2dash==0.1.2 # Copy the application. run.py resolves templates/, dash_leaflet2/, docs/, # assets/, components/, lib/ and pages/ relative to the working directory, so it diff --git a/README.md b/README.md index e233e82..bdfa687 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,19 @@
+ + 2plot.ai + + +
+
+ +dash-leaflet2 + # dash-leaflet2 -**Leaflet 2-native mapping components for [Plotly Dash](https://dash.plotly.com) 4.** +**dash-leaflet2 — Leaflet 2 maps for Dash** + +Leaflet 2-native mapping components for [Plotly Dash](https://dash.plotly.com) 4. No react-leaflet · unified Pointer Events · `BlanketOverlay` canvas/WebGL layers · ES6-class subclassing · `ResizeObserver` sizing · map rotation · liquid-glass theme · full Dash callback interoperability. @@ -18,7 +29,15 @@ No react-leaflet · unified Pointer Events · `BlanketOverlay` canvas/WebGL laye
-_Maintained by **[Pip Install Python LLC](https://pip-install-python.com)**._ + + dash-leaflet2 running live at leaflet.2plot.dev + + +_Live at **[leaflet.2plot.dev](https://leaflet.2plot.dev)** — every map on the docs site is a running Dash app._ + +
+ +_Maintained by **[Pip Install Python LLC](https://github.com/2plotai)**._
@@ -304,15 +323,15 @@ Come build with us: dash-leaflet2 is one of several tools built and maintained by **Pip Install Python LLC**: -| Project | What it is | -|--------------------------------------------------------------------------|-----------------------------------------------------------------| -| 📚 **[Pip Install Python](https://pip-install-python.com)** | Open-source documentation index for the Python & Dash ecosystem | -| 🎞️ **[dash-nle-timeline](https://pypi.org/project/dash-nle-timeline/)** | Frame-accurate NLE timeline & scene compositor for Dash | -| 🔀 **[PiratesBargain.com](https://piratesbargain.com)** | E-commerce / digital commerce | -| 🧠 **[ai-agent.buzz](https://ai-agent.buzz)** | Infinite AI canvas | -| 🎬 **[2plot.media](https://2plot.media)** | Videography application | +| Project | What it is | +|---------------------------------------------------------------|-----------------------------------------------------------------| +| 📚 **[Pip Install Python](https://2plot.dev)** | Open-source documentation index for the Python & Dash ecosystem | +| 🔀 **[2plot.ai](https://2plot.ai)** | Frame-accurate NLE timeline & scene compositor for Dash | +| 🛍️ **[PiratesBargain.com](https://piratesbargain.com/shop)** | E-commerce / digital commerce | +| 🧠 **[ai-agent.buzz](https://ai-agent.buzz)** | Infinite AI canvas | +| 🎬 **[2plot.media](https://2plot.media)** | Videography application | ## License -MIT — see [LICENSE](LICENSE). Built by **[Pip Install Python LLC](https://pip-install-python.com)** +MIT — see [LICENSE](LICENSE). Built by **[Pip Install Python LLC](https://github.com/2plotai)** to bring a generation-ahead mapping stack into the Dash framework. diff --git a/RELEASING.md b/RELEASING.md index 1a77f17..414a6ee 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,7 +10,7 @@ them in any order, but this is the order that fails cheapest: | 3 | Docs | `https://leaflet.2plot.dev` (Render) | redeploy the previous commit | The one irreversible step is PyPI. A filename can never be reused, even after -deletion, so a bad `0.2.0` costs you `0.2.1` forever. Everything below is +deletion, so a bad `0.2.1` costs you `0.2.2` forever. Everything below is arranged so the irreversible step happens last, after the reversible ones have already proven the artifact. @@ -25,7 +25,7 @@ These are the checks nobody can do for you later. python scripts/smoke_test.py # expect 70/70 # 2. Version consistency, packaging leaks, stale bundle -python scripts/check_release.py --version 0.2.0 +python scripts/check_release.py --version 0.2.1 # 3. The support claim, actually measured (needs network, ~15 min) python scripts/compat_matrix.py # writes COMPATIBILITY.md @@ -170,8 +170,8 @@ TestPyPI needs its own pending publisher (same form, on test.pypi.org). ### 2.4 Tag and publish ```bash -git tag -a v0.2.0 -m "dash-leaflet2 0.2.0 — first public release" -git push origin v0.2.0 +git tag -a v0.2.1 -m "dash-leaflet2 0.2.1 — first public release" +git push origin v0.2.1 ``` `release.yml` then: asserts the tag matches `pyproject.toml`, re-runs @@ -246,7 +246,7 @@ of a bare slug. The ad network's `/admin/ad-board` keys off `AD_APP_ID` ### 3.5 Post-deploy checklist -1. `GET /healthz` → `{"ok": true, "app": "leaflet", "version": "0.2.0", "reporting": true}`. +1. `GET /healthz` → `{"ok": true, "app": "leaflet", "version": "0.2.1", "reporting": true}`. `reporting: false` means `CROSS_APP_WEBHOOK_SECRET` is missing. 2. `/llms.txt`, `/robots.txt`, `/sitemap.xml` all 200, and sitemap URLs use `leaflet.2plot.dev` (i.e. `DASH_LEAFLET2_BASE_URL` took effect). diff --git a/assets/favicon_io/android-chrome-192x192.png b/assets/favicon_io/android-chrome-192x192.png new file mode 100644 index 0000000..88dab0a Binary files /dev/null and b/assets/favicon_io/android-chrome-192x192.png differ diff --git a/assets/favicon_io/android-chrome-512x512.png b/assets/favicon_io/android-chrome-512x512.png new file mode 100644 index 0000000..40a752e Binary files /dev/null and b/assets/favicon_io/android-chrome-512x512.png differ diff --git a/assets/favicon_io/apple-touch-icon.png b/assets/favicon_io/apple-touch-icon.png new file mode 100644 index 0000000..c6a720a Binary files /dev/null and b/assets/favicon_io/apple-touch-icon.png differ diff --git a/assets/favicon_io/favicon-16x16.png b/assets/favicon_io/favicon-16x16.png new file mode 100644 index 0000000..161b2ed Binary files /dev/null and b/assets/favicon_io/favicon-16x16.png differ diff --git a/assets/favicon_io/favicon-32x32.png b/assets/favicon_io/favicon-32x32.png new file mode 100644 index 0000000..b3ba2e5 Binary files /dev/null and b/assets/favicon_io/favicon-32x32.png differ diff --git a/assets/favicon_io/favicon.ico b/assets/favicon_io/favicon.ico new file mode 100644 index 0000000..8f14613 Binary files /dev/null and b/assets/favicon_io/favicon.ico differ diff --git a/assets/favicon_io/site.webmanifest b/assets/favicon_io/site.webmanifest new file mode 100644 index 0000000..21f2850 --- /dev/null +++ b/assets/favicon_io/site.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "dash-leaflet2 — Leaflet 2 maps for Dash", + "short_name": "dash-leaflet2", + "description": "Leaflet 2 (alpha) mapping components for Plotly Dash 4, without react-leaflet.", + "icons": [ + { "src": "/assets/favicon_io/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/assets/favicon_io/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/assets/favicon_io/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "theme_color": "#2f9e44", + "background_color": "#ffffff", + "display": "standalone", + "start_url": "/" +} diff --git a/components/appshell.py b/components/appshell.py index d194851..473a75f 100644 --- a/components/appshell.py +++ b/components/appshell.py @@ -57,7 +57,7 @@ def create_appshell(data): # Border Radius System "radius": { "xs": "0.25rem", # 4px - "sm": "0.375rem", # 6px + "sm": "0.375rem", # 6px "md": "0.5rem", # 8px "lg": "0.75rem", # 12px "xl": "1rem", # 16px @@ -311,4 +311,4 @@ def create_appshell(data): Output("desktop-navbar-toggle", "opened"), Input("url", "pathname"), State("desktop-navbar-collapsed", "data"), -) \ No newline at end of file +) diff --git a/dash_leaflet2/package-info.json b/dash_leaflet2/package-info.json index dac2a12..3ceeac8 100644 --- a/dash_leaflet2/package-info.json +++ b/dash_leaflet2/package-info.json @@ -1,6 +1,6 @@ { "name": "dash-leaflet2", - "version": "0.2.0", + "version": "0.2.1", "description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.", "main": "src/ts/index.ts", "repository": { diff --git a/docs/home/home.md b/docs/home/home.md index 74c97a3..4265b3b 100644 --- a/docs/home/home.md +++ b/docs/home/home.md @@ -1,6 +1,6 @@ --- name: "Home" -description: "prove Leaflet 2 alpha renders inside Dash 4." +description: "Leaflet 2 (alpha) mapping components for Plotly Dash 4, without react-leaflet." endpoint: "/" package: dash-leaflet2 category: "Start here" @@ -11,9 +11,28 @@ icon: "tabler:home" .. toc:: +# dash-leaflet2 — Leaflet 2 maps for Dash + +> **`dash-leaflet2`** wraps **Leaflet 2 core directly** — no react-leaflet — and +> ships it as real Dash components. By [Pip Install Python](https://github.com/2plotai). + ### Overview -This page demonstrates Home. +`dash-leaflet` is frozen on react-leaflet, which has no Leaflet 2 line, so it +cannot move past Leaflet 1.9. This library skips that abstraction entirely and +drives Leaflet 2 core itself, which is what puts v2's headline features inside +reach of a Python callback: + +- **Unified Pointer Events** — one event model for mouse, touch and stylus, with + `pointerType`, `pressure` and `tiltX` / `tiltY` reaching your callbacks +- **`BlanketOverlay` canvas / WebGL layers** — your own renderer across the + whole viewport, instead of the DOM layer system +- **ES6-class subclassing** — extend a Leaflet 2 class and mount the result +- **`ResizeObserver` sizing** — no grey tiles for a map born in a hidden tab +- **Map rotation** — `bearing` as a first-class, two-way prop + +The demo below is the whole claim in one page: Leaflet `2.0.0-alpha.1`, +rendering inside Dash 4, with no JavaScript build step. ### Live demo diff --git a/github_assets/leaflet2plotdevtakeflight.gif b/github_assets/leaflet2plotdevtakeflight.gif new file mode 100644 index 0000000..7e9c263 Binary files /dev/null and b/github_assets/leaflet2plotdevtakeflight.gif differ diff --git a/github_assets/light_mode_2plot.png b/github_assets/light_mode_2plot.png new file mode 100644 index 0000000..70e8cfb Binary files /dev/null and b/github_assets/light_mode_2plot.png differ diff --git a/lib/ad_client.py b/lib/ad_client.py index b7de275..834355c 100644 --- a/lib/ad_client.py +++ b/lib/ad_client.py @@ -26,6 +26,12 @@ Failure behaviour: if the ad server is unreachable the slot simply stays hidden, and a 60s circuit breaker stops retrying so an outage never adds the HTTP timeout to every page view. + +Analytics: the server-to-server fetch sends the network's internal-traffic +User-Agent (``lib/constants.INTERNAL_UA``) so 2plot.dev does not count this +app's ad requests as visits to itself. The click beacon deliberately does +not — it is fired by the reader's own browser, which cannot set a +User-Agent anyway, and a click IS a real person. """ from __future__ import annotations @@ -63,11 +69,20 @@ def fetch_ad(page: str) -> dict | None: with _breaker_lock: if time.time() - _last_failure < _COOLDOWN: return None + from lib.constants import internal_ua + try: resp = _session.get( f"{AD_SERVER_URL}/api/ad-network/serve", params={"app": APP_ID, "page": page}, timeout=_TIMEOUT, + # The highest-volume outbound call this app makes — one per docs + # page view, server-to-server. Without the internal-traffic token + # every one of them reached 2plot.dev as `python-requests/2.x`, + # which its tracker classifies as a bot: this satellite's readers + # were being counted as crawler traffic on the hub. See + # lib/constants.INTERNAL_UA. + headers={"User-Agent": internal_ua("ad-client")}, ) if resp.status_code == 200 and resp.content: return resp.json() diff --git a/lib/constants.py b/lib/constants.py index cc6ea0d..a5a6030 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -4,15 +4,112 @@ PRIMARY_COLOR = "green" # Keep in step with pyproject.toml and package.json when cutting a release. -APP_VERSION = "0.2.0" +APP_VERSION = "0.2.1" LEAFLET_VERSION = "2.0.0-alpha.1" -SITE_TITLE = "dash-leaflet2 — Leaflet 2 on Dash 4" +# --------------------------------------------------------------------------- +# Site identity — one string, every surface +# --------------------------------------------------------------------------- +# The network standard (2plot.ai, 2plot.dev and the documentation boilerplate +# all ship it): a site states what it is, in the same words, on every surface +# an agent or a reader can reach. The surfaces this brand has to reach, and +# what serves each: +# +# Dash(title=SITE_BRAND) -> , and the fallback identity +# register_page_metadata(path="/", -> the /llms.txt H1 and the llms +# name=SITE_BRAND) viewer's brand chip, both via +# dash-improve-my-llms 2.3.4's +# `resolve_site_title` +# docs/home/home.md's opening `# ` -> the home page's own prose +# +# tests/test_site_identity.py pins all of them to this constant, because the +# failure is silent: `resolve_site_title` SKIPS generic candidates ("Home", +# "Index", Dash's default "Dash") rather than publishing them, so a site that +# never states its identity falls through to whatever is left and nothing +# looks broken. This host was one candidate away from that — docs/home/home.md +# is registered as "Home", which is on the generic list. +# +# Naming rules, from the network standard: +# - the PACKAGE NAME belongs in the description, not in the brand; +# - "Pip Install Python" is the byline (who made it), never the site name. +SITE_BRAND = "dash-leaflet2 — Leaflet 2 maps for Dash" + +SITE_DESCRIPTION = ( + "dash-leaflet2 — Leaflet 2 (alpha) mapping components for Plotly Dash 4. " + "Wraps Leaflet 2 core directly instead of react-leaflet, exposing unified " + "Pointer Events, BlanketOverlay canvas/WebGL layers, ES6-class " + "subclassing, ResizeObserver sizing and map rotation as Dash components. " + "By Pip Install Python." +) # Public origin, used for canonical URLs, the sitemap and llms.txt. Override per # deployment; the default is the 2plot network subdomain this site ships to. BASE_URL = os.environ.get("DASH_LEAFLET2_BASE_URL", "https://leaflet.2plot.dev").rstrip("/") +# --------------------------------------------------------------------------- +# The social card +# --------------------------------------------------------------------------- +# Served from the 2plot CDN rather than this app's own /assets, deliberately: +# a link preview is fetched by Facebook, Twitter/X, Slack, Discord and +# LinkedIn — none of which wait for a free-tier container to wake from sleep. +# The CDN answers immediately whether or not this site is cold. +# +# THE BUG THIS FIXES: Dash builds `og:image` and `twitter:image` for every page +# from `register_page(image=...)` / `image_url=...`, and emits `content=""` +# when it finds neither (dash/_pages.py). This site had no brand image, so +# every page shipped an EMPTY og:image — which unfurls worse than having no +# tag at all, because scrapers treat the empty value as the declared image and +# render a blank card. `image_url` takes an absolute URL and wins over the +# assets-derived one, so passing it at `register_page` time fixes every page at +# the source instead of fighting tag order inside templates/index.html. +# +# 1280x515 (2.49:1) rather than the 1.91:1 the card specs ask for, so previews +# centre-crop roughly 100px off the top and bottom. The wordmark sits in the +# middle band and survives the crop. +OG_IMAGE_URL = "https://cdn.2plot.ai/github_assets/leaflet.2plot.dev.png" +OG_IMAGE_WIDTH = 1280 +OG_IMAGE_HEIGHT = 515 +OG_IMAGE_ALT = "dash-leaflet2 — Leaflet 2 maps for Dash, at leaflet.2plot.dev" + +# --------------------------------------------------------------------------- +# The network's internal-traffic contract +# --------------------------------------------------------------------------- +# The analytics point of truth is https://2plot.ai/docs/satellite-analytics +# ("Internal traffic"): any request whose User-Agent contains +# INTERNAL_UA_TOKEN is 2plot network machinery talking to itself — the hub's +# hourly health sweep, CI smoke batteries, the 4x-daily heartbeat, this app's +# own server-to-server calls to the hub. It is counted NOWHERE. +# +# Two halves, and both are required for the contract to hold: +# +# inbound — every tracker drops a token-carrying request at WRITE time, +# before device detection and before bot classification, so it +# never reaches the ledger the rollup is built from; +# outbound — every call this host makes to another network host sends +# INTERNAL_UA, so the far side can apply the same rule. +# +# The outbound half is the one that was missing here. lib/ad_client.py fetched +# a campaign from 2plot.dev on EVERY docs page view, arriving as +# `python-requests/2.x` — which the hub's own tracker classifies as a bot, so +# this satellite's readers were inflating 2plot.dev's bot_hits. The signed +# rollup POST in lib/satellite_analytics.py had the same shape. +# +# The token string must stay byte-identical across the network; it mirrors +# 2plotai/lib/constants.py, pip-docs+/lib/constants.py and the boilerplate's. +INTERNAL_UA_TOKEN = "2plot-internal" +INTERNAL_UA = "2plot-internal/1.0 (+https://2plot.ai/docs/satellite-analytics)" + + +def internal_ua(caller: str = "") -> str: + """``INTERNAL_UA`` with a caller suffix, e.g. ``"ad-client"``. + + The suffix is for reading logs on the far side; only the token matters to + the contract, and it stays intact whatever the suffix says. + """ + caller = (caller or "").strip() + return f"{INTERNAL_UA} {caller}" if caller else INTERNAL_UA + + # 2plot network links, surfaced in the README and the docs footer/header. GITHUB_URL = "https://github.com/pip-install-python/dash-leaflet2" DISCORD_URL = "https://discord.gg/WEnZR35mrK" diff --git a/lib/directives/kwargs.py b/lib/directives/kwargs.py index bab7fa5..aade195 100644 --- a/lib/directives/kwargs.py +++ b/lib/directives/kwargs.py @@ -2,6 +2,7 @@ import inspect from markdown2dash.src.directives.kwargs import Kwargs as KwargsBase + def convert_docstring_to_dict(docstring): """Convert numpy style parameter docstring to a list of dicts with keys name, type, description""" @@ -21,6 +22,7 @@ def convert_docstring_to_dict(docstring): return params + class Kwargs(KwargsBase): def hook(self, md, state): @@ -64,6 +66,6 @@ def hook(self, md, state): else: # If no proper docstring, use component's __init__ signature attrs["kwargs"] = [] - except (ModuleNotFoundError, AttributeError, Exception) as e: + except (ModuleNotFoundError, AttributeError, Exception): # If import fails, just skip kwargs generation - attrs["kwargs"] = [] \ No newline at end of file + attrs["kwargs"] = [] diff --git a/lib/directives/source.py b/lib/directives/source.py index 76a06dd..3452270 100644 --- a/lib/directives/source.py +++ b/lib/directives/source.py @@ -29,4 +29,8 @@ def render(self, renderer, title: str, content: str, **options) -> Component: "icon": mapping[extension]["icon"], } ) - return dmc.CodeHighlightTabs(code=code, defaultExpanded=defaultExpanded=="true", withExpandButton=withExpandedButton=='true') + return dmc.CodeHighlightTabs( + code=code, + defaultExpanded=defaultExpanded == "true", + withExpandButton=withExpandedButton == "true", + ) diff --git a/lib/network_directory.py b/lib/network_directory.py index 3680990..f1244ab 100644 --- a/lib/network_directory.py +++ b/lib/network_directory.py @@ -91,16 +91,16 @@ "url": "https://flows.2plot.dev", "description": "Node-graph editors built on React Flow.", }, -# { -# "name": "dash-pannellum", -# "url": "https://pannellum.2plot.dev", -# "description": "360° panorama and virtual-tour viewer.", -# }, -# { -# "name": "dash-emoji-mart", -# "url": "https://emojimart.2plot.dev", -# "description": "Emoji picker component.", -# }, + # { + # "name": "dash-pannellum", + # "url": "https://pannellum.2plot.dev", + # "description": "360° panorama and virtual-tour viewer.", + # }, + # { + # "name": "dash-emoji-mart", + # "url": "https://emojimart.2plot.dev", + # "description": "Emoji picker component.", + # }, { "name": "dash-email", "url": "https://email.2plot.dev", @@ -110,8 +110,8 @@ AFFILIATED: List[Dict[str, str]] = [ { - "name": "Pip Install Python", - "url": "https://pip-install-python.com", + "name": "2plot.ai", + "url": "https://2plot.ai", "description": "The original component documentation site.", }, { diff --git a/lib/page_visibility.py b/lib/page_visibility.py index db14fb7..79f3346 100644 --- a/lib/page_visibility.py +++ b/lib/page_visibility.py @@ -176,6 +176,28 @@ def register_llms_doc(path: str, name: str, description: str, doc: str) -> None: apply_llms_state(path) +def published_name(path: str, name: str) -> str: + """The name this path publishes to agents — SITE_BRAND at the root. + + The home page's registered `name` is not a nav label to + dash-improve-my-llms: 2.3.4 resolves it through `resolve_site_title` into + the /llms.txt H1, og:title and the viewer's brand chip. This site's home + page is registered as "Home", which `resolve_site_title` SKIPS as generic, + so publishing it would drop the site's identity to whatever candidate is + left. + + This function is why the substitution lives here rather than only in + run.py. `apply_llms_state` re-registers the entry EVERY time a + control-board toggle changes a verdict — so a single flip of "/" would + otherwise overwrite run.py's SITE_BRAND with "Home" at runtime, with + nothing logged and nothing visibly broken. The nav keeps "Home"; only the + published identity changes. + """ + from lib.constants import SITE_BRAND + + return SITE_BRAND if path == "/" else name + + def apply_llms_state(path: str) -> None: """Re-register this page's llms.txt body to match the current verdict.""" entry = _llms_docs.get(path) @@ -186,6 +208,7 @@ def apply_llms_state(path: str) -> None: from dash_improve_my_llms import register_page_metadata except Exception: # optional dependency — nothing to sync return + name = published_name(path, name) body = doc if llms_accessible(path) else ( f"# {name}\n\n> This page is not publicly available.\n" ) diff --git a/lib/satellite_analytics.py b/lib/satellite_analytics.py index 1fdc411..202dfe5 100644 --- a/lib/satellite_analytics.py +++ b/lib/satellite_analytics.py @@ -185,10 +185,37 @@ def visitor_key(ip: str | None, user_agent: str | None) -> str: # Ledger # -------------------------------------------------------------------------- +def is_internal(user_agent: str | None) -> bool: + """Whether this request is 2plot machinery talking to itself. + + The network's internal-traffic contract, matched case-insensitively: + https://2plot.ai/docs/satellite-analytics ("Internal traffic"). + """ + from lib.constants import INTERNAL_UA_TOKEN + + return INTERNAL_UA_TOKEN in (user_agent or "").lower() + + def track(path: str | None, user_agent: str | None, ip: str | None = None, country: str | None = None) -> None: """Append one page view. Never raises.""" try: + # --- The network's internal-traffic contract, applied at WRITE time -- + # Anything carrying INTERNAL_UA_TOKEN is the hub's health sweep, a CI + # smoke battery, the 4x-daily heartbeat or a sibling app — counted + # NOWHERE. This has to run BEFORE `is_bot` below, not after: the + # battery's crawler-shaped probes deliberately send a Googlebot token + # so the target exercises its bot path, and a drop made after + # classification would file every one of them under `bot_hits`. + # + # It also has to run at write time rather than at rollup time — the + # ledger is what the hub's numbers are built from, and a hit that + # reaches disk is a hit somebody eventually counts. + if is_internal(user_agent): + return + # `should_skip` covers `/healthz` (the hub sweeps it hourly and + # Render's own probe hits it constantly), `/api/`, static assets and + # Dash plumbing — see `_SKIP`. if not enabled() or should_skip(path): return path = path.split('?', 1)[0][:_MAX_PATH] @@ -359,6 +386,8 @@ def post_signed(route: str, payload: dict): """ import requests + from lib.constants import internal_ua + secret = _secret() body = json.dumps(payload, separators=(",", ":")).encode() ts = str(int(time.time())) @@ -368,7 +397,13 @@ def post_signed(route: str, payload: dict): f"{HUB_URL}{route}", data=body, headers={"Content-Type": "application/json", "X-AI-Canvas-Timestamp": ts, - "X-AI-Canvas-Signature": sig}, + "X-AI-Canvas-Signature": sig, + # The outbound half of the internal-traffic contract. Without + # it this rollup reaches 2plot.ai as `python-requests/2.x`, + # which the hub's own tracker classifies as a bot — so the act + # of reporting this satellite's traffic would inflate the + # hub's. See lib/constants.INTERNAL_UA. + "User-Agent": internal_ua("satellite-analytics")}, timeout=POST_TIMEOUT_S, ) @@ -621,6 +656,70 @@ def _pageview_path(raw: bytes) -> str | None: return path if isinstance(path, str) and path.startswith("/") else None +# --------------------------------------------------------------------------- +# Per-request tracking, installed OUTSIDE the framework's handler chain +# --------------------------------------------------------------------------- +# These wrap the WSGI/ASGI callable rather than registering a `before_request` +# handler, and that is the whole point. +# +# Flask (and Quart) stop running `before_request` handlers the moment one +# returns a response. dash-improve-my-llms installs `_bot_middleware`, which +# does exactly that for every crawler — it answers with prerendered HTML. As a +# `before_request`, this tracker therefore never saw a single crawler request, +# and this satellite reported `bot_hits: 0` to 2plot.ai structurally, for every +# day it was live, with nothing visibly broken anywhere. +# +# Registration order looked like the fix and is not: it cannot be right on all +# three backends at once. Flask runs `before_request` handlers in registration +# order (tracker must go FIRST), while Starlette's `add_middleware` makes the +# LAST-added middleware outermost (tracker must go LAST). One call site cannot +# satisfy both, and any rule written down here is one refactor from silently +# inverting. +# +# A WSGI/ASGI wrapper sits outside the entire application, so it observes every +# request whatever any handler does and in whatever order anything was +# registered. tests/test_internal_traffic.py pins the resulting counts on every +# backend CI runs. + +def _wsgi_tracker(wsgi_app): + """Wrap a WSGI app (Flask) so every request is counted.""" + + def middleware(environ, start_response): + try: + headers = { + key[5:].replace("_", "-").lower(): value + for key, value in environ.items() + if key.startswith("HTTP_") + } + _track_from(headers, environ.get("PATH_INFO", ""), + environ.get("REMOTE_ADDR")) + except Exception: # noqa: BLE001 — analytics never breaks a request + logger.debug("wsgi track failed", exc_info=True) + return wsgi_app(environ, start_response) + + return middleware + + +def _asgi_tracker(asgi_app): + """Wrap an ASGI app (Quart) so every request is counted.""" + + async def middleware(scope, receive, send): + if scope.get("type") == "http": + try: + headers = { + key.decode("latin-1").lower(): value.decode("latin-1") + for key, value in scope.get("headers") or [] + } + client = scope.get("client") + _track_from(headers, scope.get("path", ""), + client[0] if client else None) + except Exception: # noqa: BLE001 + logger.debug("asgi track failed", exc_info=True) + return await asgi_app(scope, receive, send) + + return middleware + + def register_routes(app, backend: str) -> None: """Install per-request tracking, ``/healthz`` and ``/api/pageview``. @@ -633,6 +732,14 @@ def register_routes(app, backend: str) -> None: if backend == "fastapi": from starlette.responses import JSONResponse + # Starlette builds its middleware stack at startup and makes the + # last-added middleware outermost, so `@server.middleware("http")` is + # the one branch whose visibility depends on when `register()` is + # called relative to add_llms_routes. Wrapping `build_middleware_stack` + # is not public API; instead this stays a plain http middleware and + # run.py keeps the analytics call AFTER add_llms_routes — the order + # that makes it outermost here. Flask and Quart below are wrapped at + # the WSGI/ASGI boundary and do not care about order at all. @server.middleware("http") async def _satellite_track(request: Request, call_next): # pragma: no cover try: @@ -659,9 +766,8 @@ async def _pageview(request: Request): # pragma: no cover elif backend == "quart": from quart import jsonify, request - @server.before_request - async def _satellite_track(): # pragma: no cover - _track_from(dict(request.headers), request.path, request.remote_addr) + # ASGI-level, NOT `before_request` — see `_asgi_tracker`. + server.asgi_app = _asgi_tracker(server.asgi_app) @server.get("/healthz") async def _healthz(): # pragma: no cover @@ -677,9 +783,9 @@ async def _pageview(): # pragma: no cover else: from flask import jsonify, request - @server.before_request - def _satellite_track(): - _track_from(dict(request.headers), request.path, request.remote_addr) + # WSGI-level, NOT `before_request` — see `_wsgi_tracker`. This is the + # branch production runs (the Dockerfile sets DASH_BACKEND=flask). + server.wsgi_app = _wsgi_tracker(server.wsgi_app) @server.get("/healthz") def _healthz(): diff --git a/package-info.json b/package-info.json index 4a32206..0bb1bec 100644 --- a/package-info.json +++ b/package-info.json @@ -1,6 +1,6 @@ { "name": "dash-leaflet2", - "version": "0.2.0", + "version": "0.2.1", "description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.", "main": "src/ts/index.ts", "repository": { diff --git a/package.json b/package.json index dac2a12..3ceeac8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dash-leaflet2", - "version": "0.2.0", + "version": "0.2.1", "description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.", "main": "src/ts/index.ts", "repository": { diff --git a/pages/control_board.py b/pages/control_board.py index 42db49e..9890700 100644 --- a/pages/control_board.py +++ b/pages/control_board.py @@ -25,7 +25,7 @@ from dash_iconify import DashIconify from lib.auth import admin_access_open, clerk_enabled, current_user, is_admin_user -from lib.constants import PAGE_TITLE_PREFIX +from lib.constants import OG_IMAGE_URL, PAGE_TITLE_PREFIX from lib.page_visibility import ( TIERS, controllable_pages, @@ -42,6 +42,10 @@ name="Control Board", title=PAGE_TITLE_PREFIX + "Control Board", description="Admin control board for page visibility and llms.txt exposure.", + # Not for sharing — this page is marked hidden and Disallowed — but Dash + # emits an empty og:image without it, and "every page" should mean every + # page. See lib.constants.OG_IMAGE_URL. + image_url=OG_IMAGE_URL, ) _TIER_COLORS = {"public": "teal", "auth": "blue", "admin": "grape", "hidden": "gray"} diff --git a/pages/markdown.py b/pages/markdown.py index 09a420d..d4ab575 100644 --- a/pages/markdown.py +++ b/pages/markdown.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from lib.ad_client import inject_ad_into_aside -from lib.constants import PAGE_TITLE_PREFIX, NAME_CONTENT_MAP +from lib.constants import OG_IMAGE_URL, PAGE_TITLE_PREFIX, NAME_CONTENT_MAP from lib.directives.kwargs import Kwargs from lib.directives.llms_copy import LlmsCopy from lib.directives.source import SC @@ -139,6 +139,12 @@ def _build_llms_doc(name: str, description: str, expanded_markdown: str, path: s name=metadata.name, title=PAGE_TITLE_PREFIX + metadata.name, description=metadata.description, + # The social card. Without this Dash emits `og:image content=""` and + # `twitter:image content=""` on every page — an empty image unfurls as + # a blank card, which is worse than declaring no image at all. An + # absolute `image_url` also beats Dash's assets-derived path, which + # would be relative and therefore useless to a scraper. + image_url=OG_IMAGE_URL, layout=gated_layout(metadata.endpoint, metadata.name, layout), category=metadata.category, icon=metadata.icon, diff --git a/pyproject.toml b/pyproject.toml index ad1e4f9..cedc2dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dash-leaflet2" -version = "0.2.0" +version = "0.2.1" description = "Leaflet 2-native Dash components — a from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4." readme = "README.md" # 3.9, not 3.8. Dash 4.4.1 itself requires >=3.9, so a 3.8 user would be diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..7ec8cdf --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = tests +# tests/ is on sys.path so `from conftest import ...` works in every module. +pythonpath = . tests +addopts = -q --strict-markers +filterwarnings = + ignore::DeprecationWarning diff --git a/requirements.txt b/requirements.txt index b14b622..0e27a45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,13 +28,34 @@ dash[fastapi]==4.4.1 # COMPAT-MATRIX: dash # site needs Python >= 3.10. The dash_leaflet2 PACKAGE needs only >= 3.9. dash_mantine_components==2.7.0 dash-iconify==0.1.2 -markdown2dash +# markdown2dash 0.1.2 declares `gunicorn>=21.2.0,<22.0.0` — a markdown parser +# pinning a WSGI server, and directly against the CVE-driven gunicorn>=23 floor +# below. pip cannot resolve both, so markdown2dash is NOT on this list and is +# installed on its own line WITHOUT its dependency graph: +# +# pip install -r requirements.txt +# pip install --no-deps markdown2dash==0.1.2 +# +# The Dockerfile and CI both do exactly that pair. Its real runtime +# dependencies are listed here instead (dash, dash-iconify and +# dash-mantine-components are already pinned above), carrying markdown2dash's +# OWN declared ranges rather than bare names — with --no-deps there is no +# resolver left to catch an incompatible upgrade, so a future `mistune` 4.0 +# would otherwise install silently and break the parser at import time. +docutils!=0.21 +jsonpath>=0.82,<0.83 +mistune>=3.0.1,<4.0.0 python-frontmatter -# 2.3.3, not 2.0: the upgrade alone fixes the assign-vs-merge home-page stub -# (2.2.0 merge semantics), directive leakage into agent markdown (2.3.3), and -# the OAI/Anthropic robots taxonomy (2.3.2/2.3.3). The [flask] extra is just +# 2.3.4 is the 2plot network standard floor, not a nice-to-have: it adds +# `resolve_site_title`, which is what stops a generic home-page name ("Home") +# or the Dash default becoming this site's published identity in the /llms.txt +# H1 and the llms viewer's brand chip. Below it, both fall back silently. +# +# Earlier steps on the same line: 2.2.0's merge semantics fixed the assign-vs- +# merge home-page stub, 2.3.3 stopped directive leakage into agent markdown and +# settled the OAI/Anthropic robots taxonomy. The [flask] extra is just # flask>=2.0, already present via Dash. -dash-improve-my-llms[flask]>=2.3.3 +dash-improve-my-llms[flask]>=2.3.4 python-dotenv>=1.0 pydantic>=2.0 requests>=2.31 @@ -83,7 +104,14 @@ clerk-backend-api>=5.0.0,<6 # --- Deployment ------------------------------------------------------------ # gunicorn serves the Flask (WSGI) backend in the Dockerfile. The FastAPI # backend is ASGI and needs uvicorn instead — already pulled in by dash[fastapi]. -gunicorn>=21.2,<22 +# +# gunicorn fronts every Flask deployment in this network, so the floor is the +# network's security baseline rather than a convenience: 21.x carried two HTTP +# request-smuggling CVEs (CVE-2024-6827, CVE-2024-1135), both fixed in 23.0. +# This is why markdown2dash is installed with --no-deps above — CI asserts the +# resolved version inside the built image, so a transitive pin can never drag +# the production server back under the floor without failing the build. +gunicorn>=23.0.0 # --- Development / testing (optional, not installed by default) ------------- # build — `python -m build --wheel` to produce the PyPI artifact diff --git a/run.py b/run.py index b07389d..9821533 100644 --- a/run.py +++ b/run.py @@ -51,7 +51,13 @@ from lib import auth, network_directory, satellite_analytics from lib.backend import get_backend_info, resolve_backend -from lib.constants import APP_VERSION, BASE_URL, LEAFLET_VERSION, SITE_TITLE +from lib.constants import ( + APP_VERSION, + BASE_URL, + LEAFLET_VERSION, + SITE_BRAND, + SITE_DESCRIPTION, +) # ---------------------------------------------------------------------------- # Pluggable backend (Dash 4.1+). FastAPI default so WebSocket / background @@ -110,7 +116,7 @@ suppress_callback_exceptions=True, prevent_initial_callbacks=True, update_title=None, - title=SITE_TITLE, + title=SITE_BRAND, index_string=open("templates/index.html").read(), ) @@ -162,13 +168,18 @@ disallowed_paths=["/admin/"], ) +# The home page's registered `name` is this site's published identity, not a +# nav label: dash-improve-my-llms 2.3.4 resolves it through `resolve_site_title` +# into the /llms.txt H1, og:title and the llms viewer's brand chip. It must be +# SITE_BRAND and nothing else — docs/home/home.md registers the page as "Home", +# which `resolve_site_title` SKIPS as generic, so without this call the site +# would fall through to `app.title` and, on a pre-2.3.4 artifact, to a bare +# "Dash". `register_page_metadata` MERGES (2.2.0+), so this refines the entry +# the markdown loader created without touching the prose it registered. register_page_metadata( path="/", - name="dash-leaflet2", - description=( - "Leaflet 2 (alpha) on Dash 4 — a generation-ahead mapping component " - "library that wraps Leaflet 2 core directly, without react-leaflet." - ), + name=SITE_BRAND, + description=SITE_DESCRIPTION, ) # ---------------------------------------------------------------------------- @@ -217,6 +228,19 @@ # https://2plot.ai/api/satellite/traffic. Dormant without # CROSS_APP_WEBHOOK_SECRET — /healthz is served either way, which is what # render.yaml's healthCheckPath points at. +# +# THIS STAYS AFTER add_llms_routes, and the reason is worth keeping: on the +# FastAPI backend the tracker is a Starlette http middleware, and Starlette +# makes the LAST-added middleware the outermost one. Registered first, it +# would sit inside `_bot_middleware` — which answers every crawler with +# prerendered HTML — and no crawler request would ever be counted. +# +# Flask and Quart have the opposite rule (`before_request` handlers run in +# registration order, and the first to return a response wins), so no single +# ordering can be correct for all three. They are therefore wrapped at the +# WSGI/ASGI boundary instead and do not depend on this line's position at all +# — see `_wsgi_tracker` in lib/satellite_analytics.py, which is what fixed the +# structural `bot_hits: 0` this satellite had been reporting. # ---------------------------------------------------------------------------- satellite_analytics.register(app, BACKEND) diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index 117fb3d..ec93750 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -44,6 +44,9 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from lib.constants import internal_ua # noqa: E402 (needs PROJECT_ROOT on the path) WORK_DIR = PROJECT_ROOT / ".compat" # The support claim is `dash>=4.1`. These are the rungs we actually test: @@ -220,7 +223,7 @@ def smoke(py: Path, version: str, backend: str) -> dict: def browser_leg(py: Path, version: str, backend: str, port: int) -> dict: """Optional: boot run.py for real and collect browser console errors.""" try: - from playwright.sync_api import sync_playwright # noqa: F401 + from playwright.sync_api import sync_playwright except ImportError: return {"skipped": "playwright not installed in the driving interpreter"} @@ -233,17 +236,21 @@ def browser_leg(py: Path, version: str, backend: str, port: int) -> dict: import urllib.request base = f"http://127.0.0.1:{port}" + # The readiness probe carries the internal-traffic token like every + # other 2plot battery: the app under test boots with + # SATELLITE_ANALYTICS_DRY_RUN=1, so without it this poll would append + # up to sixty phantom visits to its ledger before a page is measured. + probe = urllib.request.Request( + base, headers={"User-Agent": internal_ua("compat-matrix")}) for _ in range(60): # wait for the server to answer try: - urllib.request.urlopen(base, timeout=1) + urllib.request.urlopen(probe, timeout=1) break except Exception: time.sleep(1) else: return {"error": "server never came up"} - from playwright.sync_api import sync_playwright - pages_json = json.loads( subprocess.run( [str(py), "-c", diff --git a/scripts/network_smoke.py b/scripts/network_smoke.py new file mode 100644 index 0000000..96881a8 --- /dev/null +++ b/scripts/network_smoke.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Smoke battery for a 2plot satellite — CI container and production alike. + +One script, two seats, the SAME named checks either way, so a failure in CI +and a failure against production read identically: + + CI container python scripts/network_smoke.py --base-url http://localhost:8050 + Production python scripts/network_smoke.py --base-url https://leaflet.2plot.dev + +Stdlib-only on purpose: CI runs it from the host against the booted container +with a bare `python3`, before anything is pip-installed. + +Copied from dash-documentation-boilerplate (the network template); only the +block marked "per-site" below differs. If a check outside that block is wrong, +it is wrong on twenty hosts — fix it there and re-sync. + +What a satellite is to the network is what the battery proves: that it states +its identity, that its agent-facing document surfaces are real, that it runs +the intended dash-improve-my-llms artifact, and that no owner-only surface +leaks. A satellite holds no key material, so unlike the hub's copy of this +script there is no agent-key API to fail closed — the corresponding check +here is that this host's llms.txt points *back* at the hub that does. + +Every UA this script sends carries the internal-traffic token (the analytics +point of truth — https://2plot.ai/docs/satellite-analytics, "Internal +traffic"): a battery must never register as a visitor or a "bot" in any +network ledger. Even the deliberately crawler-shaped probe appends the token +— the target still exercises its bot path, but its analytics know the caller +is machinery. + +Exit code: 1 if any check fails, else 0. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +TIMEOUT = 30 +try: + from lib.constants import INTERNAL_UA as _INTERNAL_UA +except Exception: # running outside a repo checkout — keep the token intact + _INTERNAL_UA = "2plot-internal/1.0 (+https://2plot.ai/docs/satellite-analytics)" +UA = _INTERNAL_UA + " network-smoke" +CRAWLER_UA = "Mozilla/5.0 (compatible; Googlebot/2.1) " + _INTERNAL_UA + +# The body dash-improve-my-llms serves when a page has no prose registered. +# Matched in full, deliberately: this app's own <noscript> block legitimately +# says "requires JavaScript", and a substring check on that phrase reports a +# perfectly healthy host as broken. +STUB_MARKER = "This page contains interactive content that requires JavaScript" + +# ---------------------------------------------------------------- per-site -- +# The values a fork changes. Everything below this block is the network +# standard and is copied verbatim. + +# This app's one identity (lib/constants.SITE_BRAND). tests/test_site_identity +# asserts every local surface carries it; this pins the DEPLOYED artifact to +# it, which is the half no unit test can reach. +SITE_H1 = "# dash-leaflet2 — Leaflet 2 maps for Dash" + +# The container port. Matches the Dockerfile's EXPOSE / PORT and run.py. +DEFAULT_BASE_URL = "http://localhost:8050" + +# A real documentation page, used to prove `/<page>/llms.txt` works at all. +# `/pointer-events` is a core Leaflet 2 page and is not going anywhere. +SAMPLE_PAGE = "/pointer-events" + +# Owner-only surfaces that must 404 their llms.txt to an anonymous reader. +# `/admin/control-board` is this app's one hidden page (run.py calls +# `mark_hidden` on it); `/admin` is the canary for the next one added. +HIDDEN_DOC_PATHS = ( + "/admin/control-board/llms.txt", + "/admin/llms.txt", +) + +# The hub one level up the chain. A satellite's llms.txt must name it — that +# is what lets an agent walk from any leaf to the network root. +HUB_URL = "https://2plot.dev" + +# The social card. Dash emits `og:image` on every page and leaves it EMPTY +# when it can find no image, which renders a blank preview on every platform +# and is invisible from inside the app — nobody sees their own unfurls. The +# URL is served from the 2plot CDN so a sleeping free-tier container never +# costs a preview. Keep in step with lib/constants.OG_IMAGE_URL. +OG_IMAGE_URL = "https://cdn.2plot.ai/github_assets/leaflet.2plot.dev.png" + +# --- robots.txt fingerprint, and this site's DELIBERATE divergence ----------- +# pip metadata is invisible from outside a running host, so the robots.txt +# crawler split is how a live host is proven to run the intended package. +# +# Most satellites run `block_ai_training=True`, whose 2.3.3 fingerprint is +# `ClaudeBot -> Disallow: /`. THIS SITE RUNS `block_ai_training=False` ON +# PURPOSE (see run.py's RobotsConfig): for MIT-licensed component +# documentation, being in the training corpus is how a model recommends this +# library to somebody who never visits the site. Under that config the package +# emits no ClaudeBot stanza at all — training crawlers fall under `*`. +# +# So the fingerprint checked here is the AI-search allowlist, which 2.3.2 +# (OAI-SearchBot) and 2.3.3 (Claude-User, Claude-SearchBot) introduced and +# which both configs emit — plus an explicit assertion that ClaudeBot carries +# no Disallow, which is what turns the divergence from drift into a decision. +ROBOTS_ALLOWED = ( + ("OAI-SearchBot", "2.3.2"), + ("Claude-User", "2.3.3"), + ("Claude-SearchBot", "2.3.3"), + ("ChatGPT-User", "2.3.2"), + ("PerplexityBot", "2.3.2"), +) +BLOCK_AI_TRAINING = False + +# --------------------------------------------------------------------------- + +PASS, FAIL, WARN, SKIP = "pass", "FAIL", "warn", "skip" +_RESULTS: list[tuple[str, str, str]] = [] # (name, verdict, detail) + + +class SmokeFailure(Exception): + pass + + +def fetch(url: str, ua: str = UA, method: str = "GET", + body: bytes | None = None, headers: dict | None = None, + timeout: int = TIMEOUT, retries: int = 3): + """(status, headers, text) — HTTP errors are results, not exceptions; + network errors raise AFTER retries. + + Response headers come back lower-cased: gunicorn sends `content-type`, + proxies often re-case it — callers must not care. + """ + last_exc: Exception | None = None + for attempt in range(retries): + if attempt: + time.sleep(2 * attempt) + req = urllib.request.Request(url, data=body, method=method) + req.add_header("User-Agent", ua) + for k, v in (headers or {}).items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return (r.status, {k.lower(): v for k, v in r.headers.items()}, + r.read().decode("utf-8", "replace")) + except urllib.error.HTTPError as e: + return (e.code, {k.lower(): v for k, v in e.headers.items()}, + e.read().decode("utf-8", "replace")) + except Exception as exc: # timeout, reset, truncated read, … + last_exc = exc + raise last_exc + + +def record(name: str, verdict: str, detail: str = "") -> None: + _RESULTS.append((name, verdict, detail)) + print(f"[{verdict:>4}] {name}" + (f" — {detail}" if detail else ""), flush=True) + if verdict == WARN and os.getenv("GITHUB_ACTIONS"): + print(f"::warning title=network-smoke {name}::{detail}", flush=True) + + +def check(name: str, fn) -> None: + try: + fn() + record(name, PASS) + except SmokeFailure as exc: + record(name, FAIL, str(exc)) + except Exception as exc: # network/parse error → still a failure + record(name, FAIL, f"{type(exc).__name__}: {exc}") + + +def expect(cond: bool, msg: str) -> None: + if not cond: + raise SmokeFailure(msg) + + +# ------------------------------------------------------------- the battery -- + +def satellite_checks(base: str) -> None: + get = lambda path, **kw: fetch(base + path, **kw) # noqa: E731 + + def healthz_ok(): + status, _, text = get("/healthz") + expect(status == 200, f"/healthz {status}") + expect(json.loads(text).get("ok") is True, f"unexpected body {text[:120]!r}") + + def llms_txt_identity(): + # The check this whole standard exists for. The H1 is what an agent + # fetching /llms.txt cold reads as the name of this site, and a + # pre-2.3.4 artifact publishes `app.title` (or a bare "Dash") there + # with nothing else looking wrong. + status, headers, text = get("/llms.txt") + expect(status == 200, f"/llms.txt {status}") + ct = headers.get("content-type", "") + expect(ct.startswith("text/markdown"), f"content-type {ct!r}") + first = text.splitlines()[0] if text else "" + expect(first == SITE_H1, f"H1 {first!r} — identity regression?") + expect("## Pages" in text, "page index section missing") + expect("## Network" in text, "cross-host directory missing") + + def llms_txt_names_the_hub(): + _status, _, text = get("/llms.txt") + expect(HUB_URL in text, f"the directory does not name {HUB_URL}") + + def page_llms_nav(): + status, _, text = get(f"{SAMPLE_PAGE}/llms.txt") + expect(status == 200, f"{SAMPLE_PAGE}/llms.txt {status}") + expect("/llms.txt" in text, "llms_nav header missing — page doc is a dead end") + + def hidden_pages_404(): + for path in HIDDEN_DOC_PATHS: + status, _, _ = get(path) + expect(status == 404, f"{path} {status} (owner surface leaked)") + + def robots_artifact_fingerprint(): + status, _, text = get("/robots.txt") + expect(status == 200, f"/robots.txt {status}") + lines = [ln.strip() for ln in text.splitlines()] + + def rule(agent): + marker = f"User-agent: {agent}" + expect(marker in lines, f"{marker} stanza missing") + return lines[lines.index(marker) + 1] + + for agent, since in ROBOTS_ALLOWED: + got = rule(agent) + expect(got == "Allow: /", + f"{agent} -> {got!r}, expected 'Allow: /': pre-{since} artifact") + + # The divergence, pinned. If someone flips `block_ai_training` in + # run.py without meaning to, this is the check that says so. + if not BLOCK_AI_TRAINING: + expect("User-agent: ClaudeBot" not in lines, + "a ClaudeBot stanza appeared — block_ai_training flipped to " + "True? This site allows AI training on purpose (run.py)") + else: # pragma: no cover - the other posture, kept so a flip is one line + expect(rule("ClaudeBot") == "Disallow: /", + "ClaudeBot is not blocked despite block_ai_training=True") + + expect(any(ln.startswith("Sitemap:") for ln in lines), "Sitemap line missing") + + def sitemap_absolute_and_on_this_host(): + status, _, text = get("/sitemap.xml") + expect(status == 200, f"/sitemap.xml {status}") + expect("<loc>https://" in text or "<loc>http://" in text, + "no absolute <loc> URLs") + for path in HIDDEN_DOC_PATHS: + leaked = path.rsplit("/llms.txt", 1)[0] + expect(leaked not in text, f"hidden path {leaked} leaked into sitemap") + + def crawler_gets_prose(): + # The prerender. A crawler that receives the JavaScript stub indexes + # nothing, and the page looks perfect in a browser the whole time. + status, _, text = get("/", ua=CRAWLER_UA) + expect(status == 200, f"/ {status}") + expect("<title>" in text, "crawler HTML has no <title>") + expect(STUB_MARKER not in text, + "the home page served the JavaScript stub to a crawler") + expect('rel="canonical"' in text, "no canonical tag for a crawler") + + def agents_and_browsers_get_different_types(): + # One URL, two audiences, and a `Vary` that stops a CDN mixing them. + status, md_headers, md = get(f"{SAMPLE_PAGE}/llms.txt") + expect(status == 200, f"{SAMPLE_PAGE}/llms.txt {status}") + expect(md_headers.get("content-type", "").startswith("text/markdown"), + f"agents got {md_headers.get('content-type')!r}") + expect("<!DOCTYPE html>" not in md, "viewer chrome reached an agent") + + _status, html_headers, html = get( + f"{SAMPLE_PAGE}/llms.txt", + headers={"Accept": "text/html,application/xhtml+xml,*/*;q=0.8"}) + expect("text/html" in html_headers.get("content-type", ""), + f"browsers got {html_headers.get('content-type')!r}") + expect("mk-wordmark" in html, "the network wordmark is missing") + + for label, headers in (("markdown", md_headers), ("html", html_headers)): + expect("accept" in headers.get("vary", "").lower(), + f"no Vary: Accept on the {label} variant — a shared cache " + "may serve it to everyone") + + def social_card_is_shareable(): + """The link preview, which nobody on the team ever sees. + + Checked against the deployed host because every part of it can break + without the app noticing: an empty `og:image` (Dash's default when it + finds no image) renders a blank card, and a CDN asset that starts + 404ing takes every preview with it while the site itself looks fine. + """ + status, _, html = get("/") + expect(status == 200, f"/ {status}") + + images = re.findall( + r'<meta[^>]+property="og:image"[^>]*content="([^"]*)"', html) + expect(bool(images), "no og:image tag at all") + expect(all(src.strip() for src in images), + "og:image is EMPTY — the link preview renders a blank card") + expect(len(images) == 1, f"{len(images)} og:image tags — scrapers pick one") + expect(images[0] == OG_IMAGE_URL, f"og:image is {images[0]!r}") + + twitter = re.findall( + r'<meta[^>]+(?:property|name)="twitter:image"[^>]*content="([^"]*)"', html) + expect(bool(twitter) and all(t.strip() for t in twitter), + "twitter:image is missing or empty") + + # The image has to actually exist. A 404 here is the whole card. + img_status, img_headers, _ = fetch(OG_IMAGE_URL) + expect(img_status == 200, f"the og:image URL returns {img_status}") + expect(img_headers.get("content-type", "").startswith("image/"), + f"og:image serves {img_headers.get('content-type')!r}") + + def installable_as_an_app(): + """The manifest, and whether a browser could offer to install this. + + It shipped with empty `name`/`short_name` and icon paths pointing at + the site root, where nothing is served — so the install prompt was + never possible, and nothing anywhere said so. + """ + _status, _, html = get("/") + match = re.search(r'<link[^>]+rel="manifest"[^>]+href="([^"]+)"', html) + expect(bool(match), "no manifest link — the app cannot be installed") + + status, _, body = get(match.group(1)) + expect(status == 200, f"the manifest returns {status}") + manifest = json.loads(body) + expect(bool(manifest.get("name", "").strip()), "manifest name is empty") + expect(bool(manifest.get("short_name", "").strip()), "short_name is empty") + expect(bool(manifest.get("start_url")), "no start_url") + + icons = manifest.get("icons") or [] + expect(bool(icons), "the manifest declares no icons") + for icon in icons: + icon_status, _, _ = get(icon["src"]) + expect(icon_status == 200, + f"manifest icon {icon['src']} returns {icon_status}") + + for name, fn in ( + ("healthz_ok", healthz_ok), + ("llms_txt_identity", llms_txt_identity), + ("llms_txt_names_the_hub", llms_txt_names_the_hub), + ("page_llms_nav", page_llms_nav), + ("hidden_pages_404", hidden_pages_404), + ("robots_artifact_fingerprint", robots_artifact_fingerprint), + ("sitemap_absolute_and_on_this_host", sitemap_absolute_and_on_this_host), + ("crawler_gets_prose", crawler_gets_prose), + ("agents_and_browsers_get_different_types", + agents_and_browsers_get_different_types), + ("social_card_is_shareable", social_card_is_shareable), + ("installable_as_an_app", installable_as_an_app), + ): + check(name, fn) + + +# ------------------------------------------------------------------- main -- + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--base-url", default=DEFAULT_BASE_URL, + help="the satellite under test (default: the CI container)") + args = ap.parse_args() + + base = args.base_url.rstrip("/") + print(f"network-smoke → {base}\n") + satellite_checks(base) + + counts = {v: sum(1 for _, verdict, _ in _RESULTS if verdict == v) + for v in (PASS, FAIL, WARN, SKIP)} + print(f"\n{counts[PASS]} passed, {counts[FAIL]} failed, " + f"{counts[WARN]} warnings, {counts[SKIP]} skipped") + return 1 if counts[FAIL] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke_live.py b/scripts/smoke_live.py new file mode 100644 index 0000000..acbdf03 --- /dev/null +++ b/scripts/smoke_live.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +"""Post-deploy checks against a *live* satellite. + + python scripts/smoke_live.py https://leaflet.2plot.dev + +Everything here fails silently in production if it isn't checked. A wrong +canonical host doesn't error, it deindexes; a stub body doesn't error, it +serves crawlers nothing; a dead peer link doesn't error, it just teaches an +agent that this network's directory isn't worth following. + +Run in CD after every deploy, and by hand against any satellite you're +upgrading. Exit code is the number of failed checks, capped at 125. + +Only the standard library, so it runs anywhere without an install step. +""" + +from __future__ import annotations + +import os +import re +import sys +import ssl +import time +import urllib.error +import urllib.request +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlparse + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Every UA below carries the network's internal-traffic token (the analytics +# point of truth — https://2plot.ai/docs/satellite-analytics, "Internal +# traffic"). A post-deploy battery runs on every push and sweeps every peer in +# the directory; without the token it registers as a burst of visitors, and +# the crawler-shaped probes register as crawler interest. The Googlebot and +# Chrome tokens are still there, so the target exercises exactly the path +# being tested — it just knows the caller is machinery. +try: + from lib.constants import INTERNAL_UA as _INTERNAL_UA +except Exception: # pragma: no cover — running outside a repo checkout + _INTERNAL_UA = "2plot-internal/1.0 (+https://2plot.ai/docs/satellite-analytics)" + +CRAWLER_UA = ( + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) " + + _INTERNAL_UA +) +BROWSER_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 " + _INTERNAL_UA +) +# `/<page>/llms.txt` negotiates on Accept, not on the User-Agent. +BROWSER_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" +STUB_MARKER = "This page contains interactive content that requires JavaScript" +# Rendered chrome, not the bare class name — a Markdown page may legitimately +# discuss `dv-banner` (this network has one that does); it can never contain +# the element. +CHROME = re.compile(r'<[a-z]+ class="dv-banner') +TIMEOUT = 30 +# Attempts per URL for NETWORK-level errors only — see `fetch`. +RETRIES = 3 + + +def _ssl_context() -> ssl.SSLContext: + """Verify certificates via certifi when available. + + macOS Python ships without OS trust-store integration, so bare urllib + fails every https fetch with CERTIFICATE_VERIFY_FAILED — which reads as + "the whole site is down" (every check 0s). Same fix as audit_links.py. + Verification stays ON either way; certifi only supplies the CA bundle. + """ + try: + import certifi + + return ssl.create_default_context(cafile=certifi.where()) + except ImportError: + return ssl.create_default_context() + + +SSL_CONTEXT = _ssl_context() + +failures: List[str] = [] +warnings: List[str] = [] +checks_run = 0 + + +def fetch( + url: str, user_agent: str = BROWSER_UA, accept: Optional[str] = None +) -> Tuple[int, str, Dict[str, str]]: + """Returns (status, body, headers). + + Headers are part of the contract from 2.2.0 on: `/<page>/llms.txt` + content-negotiates, so which *type* came back is the thing being checked, + and `Vary` is what stops a CDN handing cached HTML to the next agent. + + NETWORK-LEVEL failures are retried; HTTP statuses are not. The distinction + matters because this script makes ~17 requests in a burst against a host on + Render's free tier, which sleeps after ~15 minutes idle — so one cold start + or one dropped connection used to surface as `FAIL canonical on /<page>`, + a check that had never actually run. A misdiagnosed failure is worse than a + slow one: it sends you looking at canonical tags that were correct all + along. A real 404 or 500 still fails on the first response, immediately. + """ + headers = {"User-Agent": user_agent} + if accept is not None: + headers["Accept"] = accept + request = urllib.request.Request(url, headers=headers) + last: Exception | None = None + for attempt in range(RETRIES): + if attempt: + time.sleep(2 * attempt) + try: + with urllib.request.urlopen( + request, timeout=TIMEOUT, context=SSL_CONTEXT + ) as response: + body = response.read().decode("utf-8", "replace") + return response.status, body, dict(response.headers) + except urllib.error.HTTPError as exc: + # The STATUS is the answer; the body is a bonus. Reading it can + # itself raise — a peer that 502s mid-body raises IncompleteRead + # here — and an exception escaping `fetch` takes the whole script + # down. That turns one sick peer into a dead CD run, which is + # exactly what the fatal/warn split in `check()` exists to prevent. + try: + body = exc.read().decode("utf-8", "replace") + except Exception: # noqa: BLE001 - truncated or already-closed body + body = "" + return exc.code, body, dict(exc.headers or {}) + except Exception as exc: # noqa: BLE001 - DNS, TLS, timeouts all land here + last = exc + return 0, f"{type(last).__name__}: {last}", {} + + +def header(headers: Dict[str, str], name: str) -> str: + """Case-insensitive header lookup — proxies rewrite the casing.""" + for key, value in headers.items(): + if key.lower() == name.lower(): + return value + return "" + + +def check(name: str, passed: bool, detail: str = "", fatal: bool = True) -> None: + """Record one check. ``fatal=False`` warns instead of failing the deploy. + + The distinction is a policy, not a convenience: **a check about THIS host + is fatal; a check about somebody else's host is a warning.** + + Peer reachability is the only thing in this script that fails on someone + else's infrastructure, and gating a deploy on it is shared fate — one peer + with an expired certificate turns every satellite in the network red, none + of them can ship, and the people who see it learn that red CD means + nothing. The information is still worth having (a directory of dead links + degrades silently and nothing else reports it), so it is surfaced as a + warning and, under Actions, as an annotation on the run summary. + """ + global checks_run + checks_run += 1 + if passed: + print(f" ok {name}") + elif fatal: + print(f" FAIL {name}" + (f" — {detail}" if detail else "")) + failures.append(name) + else: + print(f" warn {name}" + (f" — {detail}" if detail else "")) + warnings.append(f"{name}" + (f" — {detail}" if detail else "")) + if os.getenv("GITHUB_ACTIONS"): + print(f"::warning title=peer unreachable::{name} — {detail}") + + +def main(base: str) -> int: + base = base.rstrip("/") + host = urlparse(base).netloc + print(f"Smoke-testing {base}\n") + + # --- 1. The site is up, and llms.txt is the index it should be --------- + print("Core surfaces") + status, home, _ = fetch(f"{base}/") + check("home page responds 200", status == 200, f"got {status}") + + status, llms, llms_headers = fetch(f"{base}/llms.txt") + check("/llms.txt responds 200", status == 200, f"got {status}") + check("/llms.txt lists pages", "## Pages" in llms or "# " in llms) + check("/llms.txt publishes the network directory", "## Network" in llms) + + status, robots, _ = fetch(f"{base}/robots.txt") + check("/robots.txt responds 200", status == 200, f"got {status}") + check( + "/robots.txt points at this host's sitemap", + f"Sitemap: {base}/sitemap.xml" in robots, + "sitemap line missing or pointing elsewhere", + ) + # The artifact fingerprint. pip metadata is invisible from outside, so + # these robots.txt stanzas are how a live host is proven to run the + # intended dash-improve-my-llms: 2.3.2 introduced the OAI-SearchBot / + # ChatGPT-User / PerplexityBot allowlist, 2.3.3 added Claude-User and + # Claude-SearchBot. + # + # PER-SITE: most satellites also expect `ClaudeBot -> Disallow: /`, the + # 2.3.3 training-crawler split. This host runs `block_ai_training=False` + # ON PURPOSE (run.py's RobotsConfig — for MIT-licensed component docs, + # being in the training corpus is how a model recommends the library), and + # under that config the package emits no ClaudeBot stanza at all. The + # absence is asserted below so a silent flip of that flag is still caught. + robots_lines = robots.splitlines() + + def robots_rule(agent: str) -> str: + marker = f"User-agent: {agent}" + if marker not in robots_lines: + return "(missing)" + idx = robots_lines.index(marker) + following = robots_lines[idx + 1: idx + 2] + return following[0] if following else "(missing)" + + for agent, expected, since in ( + ("OAI-SearchBot", "Allow: /", "2.3.2"), + ("ChatGPT-User", "Allow: /", "2.3.2"), + ("PerplexityBot", "Allow: /", "2.3.2"), + ("Claude-User", "Allow: /", "2.3.3"), + ("Claude-SearchBot", "Allow: /", "2.3.3"), + ): + got = robots_rule(agent) + check( + f"/robots.txt {agent} -> {expected.split(':')[0]} ({since} artifact fingerprint)", + got == expected, + f"got {got}: this host runs a pre-{since} artifact", + ) + + check( + "/robots.txt keeps this site's deliberate open-training posture", + "User-agent: ClaudeBot" not in robots_lines, + "a ClaudeBot stanza appeared — block_ai_training flipped to True?", + ) + + status, sitemap, _ = fetch(f"{base}/sitemap.xml") + check("/sitemap.xml responds 200", status == 200, f"got {status}") + page_urls = re.findall(r"<loc>([^<]+)</loc>", sitemap) + check("/sitemap.xml lists pages", bool(page_urls), "no <loc> entries") + foreign = [u for u in page_urls if urlparse(u).netloc != host] + check("/sitemap.xml stays on this host", not foreign, f"foreign URLs: {foreign[:3]}") + + status, health, _ = fetch(f"{base}/healthz") + check("/healthz responds 200", status == 200, f"got {status}") + + # --- 2. Canonical host — the failure that deindexes a satellite -------- + print("\nCanonical tags") + for url in [f"{base}/"] + page_urls[:8]: + _status, html, _ = fetch(url, CRAWLER_UA) + found = re.findall(r'rel="canonical"\s+href="([^"]*)"', html) + check( + f"canonical on {urlparse(url).path or '/'}", + len(found) == 1 and urlparse(found[0]).netloc == host, + f"got {found}", + ) + + # --- 3. No page serves the JavaScript stub ---------------------------- + print("\nCrawler bodies") + for url in [f"{base}/"] + page_urls[:8]: + _status, html, _ = fetch(url, CRAWLER_UA) + check( + f"real content on {urlparse(url).path or '/'}", + STUB_MARKER not in html, + "served the JavaScript stub", + ) + + # --- 4. Content negotiation on llms.txt ------------------------------- + # Production is where this can break in ways development cannot show: a + # CDN sitting in front of the app is free to ignore `Vary` and serve one + # cached variant to everyone. Chrome leaking into the Markdown makes every + # agent in the network pay tokens for decoration and appears in no + # dashboard; the Markdown leaking into a browser just looks unfinished. + print("\nContent negotiation") + check( + "/llms.txt serves Markdown to a plain request", + not CHROME.search(llms) and "<!DOCTYPE html>" not in llms, + "the viewer chrome reached an agent", + ) + + page_doc = next( + (f"{u.rstrip('/')}/llms.txt" for u in page_urls if urlparse(u).path not in ("", "/")), + f"{base}/llms.txt", + ) + + status, doc, doc_headers = fetch(page_doc) + check(f"{urlparse(page_doc).path} responds 200", status == 200, f"got {status}") + check( + "agents get text/markdown", + "text/markdown" in header(doc_headers, "Content-Type"), + header(doc_headers, "Content-Type") or "no Content-Type", + ) + check( + "agents get no viewer chrome", + not CHROME.search(doc) and "<!DOCTYPE html>" not in doc, + "the viewer chrome reached an agent", + ) + check( + "page document is not a dead end", + f"{base}/llms.txt" in doc, + "no route back to the site index", + ) + + status, view, view_headers = fetch(page_doc, accept=BROWSER_ACCEPT) + check( + "browsers get text/html", + "text/html" in header(view_headers, "Content-Type"), + header(view_headers, "Content-Type") or "no Content-Type", + ) + check("the viewer renders the network wordmark", "mk-wordmark" in view) + check( + "the viewer is noindex", + bool(re.search(r'<meta[^>]+name="robots"[^>]+noindex', view)), + "the rendered view would compete with the page it documents", + ) + + # Both variants, because a cache keys on the request that populated it. + for label, headers in (("markdown", doc_headers), ("html", view_headers)): + check( + f"Vary: Accept on the {label} variant", + "accept" in header(headers, "Vary").lower(), + f"Vary: {header(headers, 'Vary') or '(absent)'} — a shared cache " + "may serve this variant to everyone", + ) + + # --- 5. Every peer in the directory resolves -------------------------- + # A directory of dead links degrades quietly, and nothing else will tell + # you — so this is still worth checking on every deploy. But it is the ONE + # section that tests hosts this deployment does not control, so it warns + # rather than fails. See `check()` for why. That the directory is + # *published at all* is this host's job, so that check stays fatal. + print("\nNetwork directory") + # `[` `]` `(` are excluded, not just whitespace: the 2.2.0 nav block writes + # links as `[https://host/llms.txt](https://host/llms.txt)`, and a class + # that stops only at `)` swallows the label and the opening paren into one + # malformed URL — which then 404s and fails a perfectly good deploy. + peer_docs = sorted(set(re.findall(r"https://[^\s()\[\]\"'<>]+/llms\.txt", llms))) + check("directory lists peer llms.txt URLs", bool(peer_docs), "none found") + for url in peer_docs: + if url.startswith(base): + continue + status, body, headers = fetch(url) + # A 200 is not enough. A Dash app answers its catch-all with the SPA + # shell for *any* unmatched path, so a host that does not serve + # llms.txt at all still returns 200 text/html — and a status-only + # check passes on every one of them. Verified on 2plot.dev, where + # /api/this-endpoint-cannot-exist also returns 200 text/html. + is_html = "text/html" in header(headers, "Content-Type").lower() or ( + body.lstrip()[:15].lower().startswith("<!doctype html") + ) + if status != 200: + check(f"peer reachable: {url}", False, f"got {status}", fatal=False) + else: + check( + f"peer serves a document: {url}", + not is_html, + "200, but HTML — that host's catch-all, not an llms.txt", + fatal=False, + ) + + passed = checks_run - len(failures) - len(warnings) + summary = f"\n{passed}/{checks_run} checks passed" + if warnings: + summary += f", {len(warnings)} warnings (peers — not this deployment)" + print(summary) + + if warnings: + print("\nWarned:") + for name in warnings: + print(f" - {name}") + + if failures: + print("\nFailed:") + for name in failures: + print(f" - {name}") + return min(len(failures), 125) + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(__doc__) + sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/templates/index.html b/templates/index.html index 61944d0..5d5b314 100644 --- a/templates/index.html +++ b/templates/index.html @@ -42,19 +42,72 @@ og:site_name and og:url are genuinely missing from Dash's set, so they belong here. og:url is kept current by the canonical script below. - No og:image is declared: shipping a URL that 404s is worse than having - none, and no brand image exists yet. Dash emits an EMPTY og:image and - twitter:image for the same reason — to fix those properly, add - assets/og-image.png (1200x630) and pass image_url= to register_page in - pages/markdown.py. + THE IMAGE, and why there is still no static og:image tag here. + Every page used to ship `og:image content=""` — Dash emits the tag + unconditionally and leaves it empty when it can find no image, which + unfurls as a BLANK card and is worse than declaring nothing at all. + That is fixed at the source: pages/markdown.py and + pages/control_board.py now pass `image_url=OG_IMAGE_URL` to + register_page, so Dash's own og:image and twitter:image carry the real + absolute URL on every page. + + Adding a second, static og:image here would recreate exactly the + duplicate-tag problem rule 1 above describes. So only the AUXILIARY + tags appear below — dimensions, type, alt, secure_url — none of which + Dash emits, and all of which a scraper wants: og:image:width/height + let Facebook and LinkedIn reserve the layout before the image + downloads, and og:image:alt is the preview's accessible description. ======================================================================== --> <meta property="og:site_name" content="dash-leaflet2"> <meta property="og:url" content="https://leaflet.2plot.dev/"> + <!-- Auxiliary image metadata. The URL itself comes from Dash — see above. + Keep these in step with lib/constants.py OG_IMAGE_*; the values are + asserted by tests/test_social_card.py. --> + <meta property="og:image:secure_url" content="https://cdn.2plot.ai/github_assets/leaflet.2plot.dev.png"> + <meta property="og:image:type" content="image/png"> + <meta property="og:image:width" content="1280"> + <meta property="og:image:height" content="515"> + <meta property="og:image:alt" content="dash-leaflet2 — Leaflet 2 maps for Dash, at leaflet.2plot.dev"> + <meta name="twitter:image:alt" content="dash-leaflet2 — Leaflet 2 maps for Dash, at leaflet.2plot.dev"> + <!-- Mantine green, matching the brand. --> <meta name="theme-color" content="#2f9e44"> + <!-- ======================================================================== + APP ICONS AND INSTALLABILITY + ======================================================================== + The favicon placeholder further down emits ONE rel="icon" link + pointing at assets/favicon_io/favicon.ico (Dash walks the assets tree + recursively, so the subfolder is found). It emits nothing else, so + everything an installable web app needs is declared here. + + Tag syntax is written without angle brackets in these comments on + purpose: a literal element inside a comment is invisible to a browser + but not to a regex, and it shows up as a phantom tag in anything that + scrapes this document. + + The manifest is what turns this into an "Add to Home Screen" / install + offer rather than just a bookmarkable page. It shipped with EMPTY name + and short_name fields and icon paths pointing at /android-chrome-*.png + at the site root, where nothing is served — so no browser could ever + have offered the install. Fixed in assets/favicon_io/site.webmanifest. + + Note there is no service worker: browsers show the install prompt for + a manifest with a start_url, name and a 192px+ icon, which is what is + wanted here. Offline caching a docs site whose content is the point + would mean serving stale documentation, so it is deliberately absent. + ======================================================================== --> + + <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon_io/favicon-32x32.png"> + <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon_io/favicon-16x16.png"> + <link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon_io/apple-touch-icon.png"> + <link rel="manifest" href="/assets/favicon_io/site.webmanifest"> + <meta name="apple-mobile-web-app-title" content="dash-leaflet2"> + <meta name="msapplication-TileColor" content="#2f9e44"> + <meta name="msapplication-TileImage" content="/assets/favicon_io/android-chrome-192x192.png"> + <!-- ======================================================================== CANONICAL URL ======================================================================== @@ -123,17 +176,13 @@ "@type": "Organization", "name": "Pip Install Python LLC", "description": "Open-source components and documentation for the Python and Plotly Dash ecosystem.", - "url": "https://pip-install-python.com", + "url": "https://github.com/2plotai", "sameAs": [ + "https://github.com/2plotai", "https://github.com/pip-install-python", "https://www.youtube.com/@pipinstallpython", "https://www.linkedin.com/in/austin-k-274140161/" - ], - "contactPoint": { - "@type": "ContactPoint", - "email": "austin@pip-install-python.com", - "contactType": "customer support" - } + ] } </script> @@ -151,8 +200,9 @@ "author": { "@type": "Organization", "name": "Pip Install Python LLC", - "url": "https://pip-install-python.com" + "url": "https://github.com/2plotai" }, + "image": "https://cdn.2plot.ai/github_assets/leaflet.2plot.dev.png", "isAccessibleForFree": true, "keywords": "dash, leaflet, leaflet2, maps, gis, geospatial, plotly, python" } @@ -179,10 +229,10 @@ <link rel="dns-prefetch" href="https://server.arcgisonline.com"> <link rel="dns-prefetch" href="https://tile.openstreetmap.org"> - <!-- Required by Dash. The favicon placeholder below serves assets/favicon.ico - when present and Dash's own default otherwise. There is deliberately no - hard-coded <link rel="icon">: the inherited one pointed at a file this - project never shipped and 404'd on every page load. --> + <!-- Required by Dash. The favicon placeholder below serves the favicon.ico + it finds under assets/ when present, and Dash's own default otherwise. + The explicit icon links live in the APP ICONS block above, alongside + the manifest they belong with. --> {%metas%} {%favicon%} {%css%} @@ -241,7 +291,8 @@ <h2>Links</h2> <li><a href="https://pypi.org/project/dash-leaflet2/" style="color: #2f9e44;">PyPI</a></li> <li><a href="https://github.com/pip-install-python/dash-leaflet2" style="color: #2f9e44;">GitHub</a></li> <li><a href="https://discord.gg/WEnZR35mrK" style="color: #2f9e44;">Discord</a></li> - <li><a href="https://pip-install-python.com" style="color: #2f9e44;">Pip Install Python</a></li> + <li><a href="https://leaflet.2plot.dev" style="color: #2f9e44;">Documentation</a></li> + <li><a href="https://github.com/2plotai" style="color: #2f9e44;">Pip Install Python</a></li> </ul> </div> </noscript> diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..727fae7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,246 @@ +"""Shared fixtures — boot the real app once, then interrogate it. + +The suite deliberately exercises `run.py` itself rather than a stripped-down +app assembled for testing. Nearly everything worth catching here lives in the +wiring: registration order, which middleware runs first, whether a page's +prose survived to the response. A test app that re-implements that wiring +tests the re-implementation. + +Backend selection follows `DASH_BACKEND`, so the same suite runs against +Flask, FastAPI and Quart in CI. `client` normalises the three test clients +behind `.get(path, user_agent=...) -> Response`. + +SECRETLESS, AND ORDER MATTERS. The suite runs against the app exactly as CI's +zero-secret container does: no Clerk keys (auth falls open, non-public tiers +still deny), no `CROSS_APP_WEBHOOK_SECRET` (nothing is ever POSTed to the +hub), and the analytics ledger in a temp dir. The zero-secret boot is itself +the first invariant. + +The env block below therefore has to run BEFORE anything imports `run.py`, +because run.py calls `load_dotenv()` at import time and a developer's local +`.env` would otherwise flip the app into a configured posture. `load_dotenv()` +never overrides an existing key, so pinning each secret to `""` here (falsy to +every `os.getenv(...) or None` reader in `lib/`) neutralises the file without +deleting it. In CI there is no `.env` at all and this is belt-and-braces. Same +pattern as 2plotai, pip-docs+ and dash-documentation-boilerplate. +""" + +from __future__ import annotations + +import importlib.util +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +# --- 1. Neutralise every secret (must precede any import of run.py) --------- +SECRET_ENV_KEYS = ( + "CLERK_SECRET_KEY", "CLERK_PUBLISHABLE_KEY", "CLERK_SIGN_IN_URL", + "CLERK_SIGN_UP_URL", "CLERK_FRONTEND_API", "CLERK_WEBHOOK_SECRET", + "CLERK_IS_SATELLITE", "CLERK_SATELLITE_DOMAIN", "SESSION_SECRET", + "FLASK_SECRET_KEY", "CROSS_APP_WEBHOOK_SECRET", "NETWORK_BULLETIN_URL", + "ADMIN_EMAILS", "MUI_PRO_API_KEY", "DATABASE_URL", "AD_DATABASE_URL", +) +for _key in SECRET_ENV_KEYS: + os.environ[_key] = "" + +# --- 2. Keep app state out of the repo -------------------------------------- +# Without this the suite appends its own hits to the checked-out +# satellite_traffic.jsonl, which then shows up in `git status` and, worse, in +# the next rollup a developer's local run happens to send. +_TMP_STATE = tempfile.mkdtemp(prefix="leaflet-tests-") +os.environ["SATELLITE_ANALYTICS_FILE"] = os.path.join(_TMP_STATE, "satellite_traffic.jsonl") +os.environ["PAGE_VISIBILITY_FILE"] = os.path.join(_TMP_STATE, "page_visibility.json") + +# DRY RUN, NOT "dormant". `lib.satellite_analytics.enabled()` is +# `bool(secret) or DRY_RUN`, and with no secret (above) the tracker would +# short-circuit on the very first line of `track()` — every internal-traffic +# assertion would then pass vacuously, proving nothing. Dry-run keeps the whole +# write path live while guaranteeing the reporter never POSTs anywhere. +os.environ["SATELLITE_ANALYTICS_DRY_RUN"] = "1" +# The reporter thread would otherwise start on import and wake up mid-suite. +os.environ["SATELLITE_REPORT_INTERVAL_S"] = "86400" +os.environ.setdefault("APP_ENV", "test") + +BROWSER_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) +CRAWLER_UA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" + +# What a real browser sends. `/<page>/llms.txt` negotiates on this header — +# not on the User-Agent — so it is what separates "a person opened the URL" +# from "an agent fetched it". +BROWSER_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + +# The body dash-improve-my-llms serves when a page has no prose registered. +# Its presence on any page is the failure this whole network cares most about. +STUB_MARKER = "This page contains interactive content that requires JavaScript" + +# A real documentation page, used wherever a test needs one that is not the +# home page. Mirrors scripts/network_smoke.SAMPLE_PAGE. +SAMPLE_PAGE = "/pointer-events" + + +def backend() -> str: + """Whichever backend the app will actually boot on. + + Not `os.environ["DASH_BACKEND"]` directly: run.py calls `load_dotenv()`, + so a local .env can select a backend the bare environment knows nothing + about. Reading the env here instead would hand out a Werkzeug test client + for a FastAPI app, and every test would fail on the client rather than on + the code. + """ + from lib.backend import resolve_backend + + return resolve_backend() + + +@pytest.fixture(scope="session") +def app_module(): + """Import run.py as a module, from the repo root. + + run.py opens 'templates/index.html' by relative path and pages/markdown.py + globs 'docs/**/*.md', so the process CWD has to be the repo root regardless + of where pytest was invoked from. + """ + os.chdir(REPO_ROOT) + spec = importlib.util.spec_from_file_location("runmod", REPO_ROOT / "run.py") + module = importlib.util.module_from_spec(spec) + sys.modules["runmod"] = module + try: + spec.loader.exec_module(module) + except SystemExit: # pragma: no cover - run.py doesn't call sys.exit today + pass + return module + + +@pytest.fixture(scope="session") +def app(app_module): + return app_module.app + + +class Response: + __slots__ = ("status", "text", "headers") + + def __init__(self, status: int, text: str, headers=None) -> None: + self.status = status + self.text = text + # Headers matter from 2.2.0 on: `/<page>/llms.txt` content-negotiates, + # so the *type* of the response is part of the contract and `Vary` is + # what stops a CDN serving cached HTML to the next agent. + # + # Keys are lowercased because the three backends disagree on casing — + # Werkzeug hands back `Content-Type`, httpx `content-type`. A plain + # `headers.get("Content-Type")` therefore passes on Flask and fails on + # FastAPI and Quart, which reads like a backend bug and isn't one. + self.headers = {k.lower(): v for k, v in (headers or {}).items()} + + @property + def ok(self) -> bool: + return self.status == 200 + + def header(self, name: str, default: str = "") -> str: + return self.headers.get(name.lower(), default) + + @property + def content_type(self) -> str: + return self.header("Content-Type") + + def __repr__(self) -> str: # pragma: no cover - assertion output only + return f"<Response {self.status} {self.content_type} {len(self.text)}b>" + + +class Client: + """One synchronous `.get()` across all three backends. + + Quart's test client is async all the way down — both the request and + `get_data()` return coroutines — so it gets driven from a loop owned by + the fixture rather than being awaited by every test. + """ + + def __init__(self, raw, kind: str, loop=None) -> None: + self._raw = raw + self._kind = kind + self._loop = loop + + def get(self, path: str, user_agent: str = BROWSER_UA, accept: str = None) -> Response: + headers = {"User-Agent": user_agent} + if accept is not None: + headers["Accept"] = accept + + if self._kind == "werkzeug": + r = self._raw.get(path, headers=headers) + # errors="replace", not `as_text=True`: the latter decodes strictly + # and raises UnicodeDecodeError on any binary response, so a test + # that merely checks a favicon or a manifest icon RESOLVES would + # blow up on the PNG's first byte. httpx (the FastAPI branch) is + # already lenient; this matches it. + return Response(r.status_code, r.get_data().decode("utf-8", "replace"), + dict(r.headers)) + + if self._kind == "quart": + async def fetch(): + r = await self._raw.get(path, headers=headers) + return r.status_code, await r.get_data(as_text=True), dict(r.headers) + + return Response(*self._loop.run_until_complete(fetch())) + + r = self._raw.get(path, headers=headers) + return Response(r.status_code, r.text, dict(r.headers)) + + +@pytest.fixture(scope="session") +def client(app): + """A test client for whichever backend is under test. + + FastAPI/Quart need the ASGI lifespan to have run: Dash registers its page + catch-all from the startup event, so a client used outside the lifespan + context 404s every non-root URL for reasons that have nothing to do with + the code under test. + """ + kind = backend() + if kind == "flask": + yield Client(app.server.test_client(), "werkzeug") + elif kind == "quart": + import asyncio + + loop = asyncio.new_event_loop() + try: + yield Client(app.server.test_client(), "quart", loop=loop) + finally: + loop.close() + elif kind == "fastapi": + from starlette.testclient import TestClient + + with TestClient(app.server) as raw: + yield Client(raw, "httpx") + else: # pragma: no cover - resolve_backend() rejects anything else + raise RuntimeError(f"unsupported DASH_BACKEND={kind!r}") + + +@pytest.fixture(scope="session") +def tmp_state_dir(): + """Where the app's ledger and claim files live for this run.""" + return _TMP_STATE + + +@pytest.fixture(scope="session") +def pages(app_module): + """Every registered page as (path, name, entry), sorted by path.""" + import dash + + return sorted( + ((entry["path"], entry.get("name", ""), entry) for entry in dash.page_registry.values()), + key=lambda item: item[0], + ) + + +@pytest.fixture(scope="session") +def page_paths(pages): + return [path for path, _name, _entry in pages] diff --git a/tests/test_internal_traffic.py b/tests/test_internal_traffic.py new file mode 100644 index 0000000..957a520 --- /dev/null +++ b/tests/test_internal_traffic.py @@ -0,0 +1,304 @@ +"""The network's internal-traffic contract — the analytics point of truth. + +The rule (https://2plot.ai/docs/satellite-analytics, "Internal traffic"): a +request whose User-Agent contains `2plot-internal` is 2plot machinery talking +to itself — the hub's hourly health sweep, CI smoke batteries, the 4x-daily +heartbeat, cross-app calls — and is counted NOWHERE. Dropped at write time, +before bot classification. `/healthz` is never a visit either. + +Both halves are tested here, because a contract kept on only one side is not +kept at all: + +*inbound* token-carrying requests never reach the ledger, and therefore + never reach `human_hits` / `bot_hits` in the rollup this app POSTs + to 2plot.ai; +*outbound* every call this host makes to another network host sends + `INTERNAL_UA`, so the far side can apply the same rule. That half + was missing: the ad client fetched a campaign from 2plot.dev on + every single docs page view, arriving as `python-requests/2.x`, + and the hub counted this satellite's readers as its own bots. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import pytest + +from conftest import BROWSER_UA, CRAWLER_UA, SAMPLE_PAGE +from lib import satellite_analytics +from lib.constants import INTERNAL_UA, INTERNAL_UA_TOKEN, internal_ua + +# A real page. `should_skip` drops infrastructure paths (`/llms.txt`, +# `/robots.txt`, `/healthz`, `/api/`, ...), so an assertion made against one of +# those would pass no matter what the tracker did. +PAGE = SAMPLE_PAGE + + +def _ledger_visits() -> list[dict]: + """Every hit on disk.""" + try: + with open(satellite_analytics.LEDGER) as fh: + return [json.loads(ln) for ln in fh if ln.strip()] + except FileNotFoundError: + return [] + + +def _rollup() -> dict: + """Today's rollup as the hub would receive it, or an all-zero stand-in.""" + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + return satellite_analytics.rollup(today) or {"human_hits": 0, "bot_hits": 0} + + +def _distinct_ua(label: str, base: str = BROWSER_UA) -> str: + """A UA that is unique per call site. + + `track()` dedupes the same (visitor, path) inside DEDUPE_S, and the visitor + key is `ip | md5(user-agent)[:8]` — so two hits from the test client with + the same UA collapse into one and a delta assertion silently measures + nothing. Varying the UA gives each assertion its own visitor. + """ + return f"{base} {label}" + + +# --------------------------------------------------------------- the token -- + + +def test_token_is_the_network_wide_string(): + """The contract only works if every host agrees on the byte sequence.""" + assert INTERNAL_UA_TOKEN == "2plot-internal" + assert INTERNAL_UA_TOKEN in INTERNAL_UA + assert INTERNAL_UA.startswith(INTERNAL_UA_TOKEN) + + +def test_caller_suffix_never_breaks_the_token(): + ua = internal_ua("ad-client") + assert INTERNAL_UA_TOKEN in ua + assert ua.endswith("ad-client") + assert internal_ua() == INTERNAL_UA + assert internal_ua(" ") == INTERNAL_UA + + +# ------------------------------------------------------------------ inbound -- + + +def test_the_tests_can_see_the_ledger_at_all(client, tmp_state_dir): + """Guard for every delta assertion below. + + If the ledger path were wrong (or the suite were writing into the repo's + own satellite_traffic.jsonl), every "count did not change" test would pass + vacuously. Prove a write lands first — and that tracking is enabled at all, + which for this app means SATELLITE_ANALYTICS_DRY_RUN rather than a secret. + """ + assert satellite_analytics.enabled(), ( + "tracking is dormant — every exclusion test below would pass vacuously" + ) + assert str(satellite_analytics.LEDGER).startswith(tmp_state_dir), ( + satellite_analytics.LEDGER + ) + before = len(_ledger_visits()) + client.get(PAGE, user_agent=_distinct_ua("ledger-guard")) + assert len(_ledger_visits()) == before + 1 + + +def test_internal_ua_is_counted_nowhere(client): + before = len(_ledger_visits()) + client.get(PAGE, user_agent=internal_ua("network-smoke")) + client.get("/", user_agent=INTERNAL_UA) + assert len(_ledger_visits()) == before + + +def test_a_crawler_shaped_probe_carrying_the_token_stays_internal(client): + """The battery's crawler probe exercises the bot path deliberately. + + It must still not be counted. This is precisely why the drop happens + before `is_bot` — classification would file it under `bot`. + """ + before = len(_ledger_visits()) + client.get(PAGE, user_agent=f"{CRAWLER_UA} {INTERNAL_UA}") + assert len(_ledger_visits()) == before + + +def test_the_token_is_matched_case_insensitively(client): + before = len(_ledger_visits()) + client.get(PAGE, user_agent="2PLOT-INTERNAL/1.0 Health-Sweep") + assert len(_ledger_visits()) == before + + +def test_healthz_is_never_a_visit(client): + before = len(_ledger_visits()) + client.get("/healthz", user_agent="Render/1.0 health-check") + client.get("/healthz", user_agent=_distinct_ua("healthz-browser")) + assert len(_ledger_visits()) == before + + +def test_the_pageview_beacon_also_drops_internal_traffic(client): + """The SPA beacon is a second write path into the same ledger. + + `/api/pageview` is how every client-side route change is counted, so a + drop applied only to the request middleware would leak everything after + the entry page. Both go through `track()`, and this is what pins that. + """ + assert satellite_analytics.is_internal(internal_ua("network-smoke")) + assert satellite_analytics.is_internal(f"{CRAWLER_UA} {INTERNAL_UA}") + assert not satellite_analytics.is_internal(BROWSER_UA) + assert not satellite_analytics.is_internal(None) + + +# ----------------------------------------------- the reported numbers ------- +# +# The exclusion that actually matters. Everything above is about the ledger; +# this is about what 2plot.ai charts. + + +def test_internal_traffic_is_absent_from_human_hits_and_bot_hits(client): + before = _rollup() + + # Four calls that are all machinery, in the two shapes the network sends: + # a plain internal UA, and a crawler-shaped probe carrying the token. + for n in range(2): + client.get(PAGE, user_agent=internal_ua(f"network-smoke-{n}")) + client.get(PAGE, user_agent=f"{CRAWLER_UA} {INTERNAL_UA} {n}") + + after = _rollup() + assert after["human_hits"] == before["human_hits"], ( + "internal traffic reached human_hits — the hub would chart the health " + "sweep as readers of these docs" + ) + assert after["bot_hits"] == before["bot_hits"], ( + "internal traffic reached bot_hits — the hub would chart CI as crawler " + "interest" + ) + + +def test_the_tracker_sits_outside_the_handler_chain(app): + """The structural fix behind `test_real_traffic_is_still_counted`. + + On Flask, `before_request` handlers stop running the moment one returns a + response — and `add_llms_routes` installs `_bot_middleware`, which returns + prerendered HTML for every crawler. While the tracker was a + `before_request`, not one crawler request ever reached it, and this + satellite reported `bot_hits: 0` to 2plot.ai every day it was live. + + Registration order cannot fix that: Flask wants the tracker registered + FIRST, Starlette's `add_middleware` wants it LAST, and one call site + cannot be both. So the WSGI app is wrapped instead. That is what this + pins — the behavioural test catches the symptom, this names the cause. + """ + if backend_name() != "flask": + pytest.skip("WSGI wrapping is a Flask-backend concern") + + handlers = [ + f"{fn.__module__}.{fn.__qualname__}" + for fn in app.server.before_request_funcs.get(None, []) + ] + assert not any("_satellite_track" in name for name in handlers), ( + "the tracker is back to being a before_request handler — the llms bot " + f"middleware will short-circuit every crawler past it. Handlers: {handlers}" + ) + assert "_wsgi_tracker" in repr(app.server.wsgi_app) or callable(app.server.wsgi_app) + + +def backend_name() -> str: + from conftest import backend + + return backend() + + +def test_real_traffic_is_still_counted(client): + """The exclusions must not have lobotomised the tracker. + + A rule that drops everything also satisfies every assertion above, so the + positive case is load-bearing: one browser hit is one human, one Googlebot + hit is one bot. + """ + before = _rollup() + client.get(PAGE, user_agent=_distinct_ua("real-human")) + client.get(PAGE, user_agent=_distinct_ua("real-bot", CRAWLER_UA)) + after = _rollup() + + assert after["human_hits"] == before["human_hits"] + 1 + assert after["bot_hits"] == before["bot_hits"] + 1 + + +# ----------------------------------------------------------------- outbound -- + + +class _Captured(Exception): + """Abort the request once the headers have been seen.""" + + +def _capture_headers(monkeypatch, module, attr): + """Record the headers of the next outbound call, then abort it.""" + seen = {} + + def fake(*args, **kwargs): + seen.update(kwargs.get("headers") or {}) + raise _Captured + + monkeypatch.setattr(module, attr, fake) + return seen + + +def test_the_traffic_rollup_post_sends_the_token(monkeypatch): + """The signed hourly rollup to 2plot.ai.""" + import requests + + monkeypatch.setenv("CROSS_APP_WEBHOOK_SECRET", "test-secret") + seen = _capture_headers(monkeypatch, requests, "post") + with pytest.raises(_Captured): + satellite_analytics.post_signed( + "/api/satellite/traffic", {"app": "leaflet", "date": "2026-07-31"} + ) + assert INTERNAL_UA_TOKEN in seen.get("User-Agent", "") + + +def test_the_ad_fetch_sends_the_token(monkeypatch): + """One call per docs page view — the loudest of the two.""" + from lib import ad_client + + seen = _capture_headers(monkeypatch, ad_client._session, "get") + monkeypatch.setattr(ad_client, "_last_failure", 0.0) + assert ad_client.fetch_ad(SAMPLE_PAGE) is None + assert INTERNAL_UA_TOKEN in seen.get("User-Agent", "") + + +def test_the_ad_fetch_still_fails_soft(monkeypatch): + """The header must not have cost the module its fail-silent contract. + + `fetch_ad` swallowing everything is what keeps an ad-server outage from + breaking a page view; a `from lib.constants import ...` placed outside the + try block would have moved an ImportError out of that guarantee. + """ + from lib import ad_client + + monkeypatch.setattr(ad_client, "_last_failure", 0.0) + monkeypatch.setattr( + ad_client._session, "get", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("ad server down")), + ) + assert ad_client.fetch_ad(SAMPLE_PAGE) is None + + +@pytest.mark.parametrize("script", ["smoke_live", "network_smoke"]) +def test_every_battery_script_sends_the_token(script): + """A post-deploy battery sweeps every peer; it must not register anywhere.""" + import importlib.util + + from conftest import REPO_ROOT + + spec = importlib.util.spec_from_file_location( + f"_ua_{script}", REPO_ROOT / "scripts" / f"{script}.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + agents = [ + value + for name, value in vars(module).items() + if (name == "UA" or name.endswith("_UA")) and isinstance(value, str) + ] + assert agents, f"scripts/{script}.py declares no User-Agent constant" + missing = [ua for ua in agents if INTERNAL_UA_TOKEN not in ua] + assert missing == [], f"scripts/{script}.py sends untokened UAs: {missing}" diff --git a/tests/test_network_smoke.py b/tests/test_network_smoke.py new file mode 100644 index 0000000..0e6dd68 --- /dev/null +++ b/tests/test_network_smoke.py @@ -0,0 +1,150 @@ +"""Run the network battery against the in-process app. + +`scripts/network_smoke.py` only ever executes in two places a developer never +watches: against the container CI just booted, and against production after a +deploy. That is exactly the code that rots — a typo in a check turns it into a +silent pass and the battery keeps reporting green over a broken host. + +So it runs here too, with its `fetch` pointed at the test client. Three +distinct things get proven, and it is worth being explicit about which: + +1. the battery's own logic still works (the checks fire, and they can fail); +2. this app satisfies every check the network standard makes of a satellite; +3. the per-site block at the top of the script — the expected H1, the hidden + paths, the sample page — still matches the app it describes. + +What it cannot prove is the deployed artifact, which is the whole reason the +container run and the post-deploy run exist as well. +""" + +from __future__ import annotations + +import importlib.util +import sys + +import pytest + +from conftest import REPO_ROOT +from lib.constants import BASE_URL, INTERNAL_UA_TOKEN, SITE_BRAND + +BASE = BASE_URL + + +@pytest.fixture(scope="module") +def battery(): + spec = importlib.util.spec_from_file_location( + "network_smoke", REPO_ROOT / "scripts" / "network_smoke.py" + ) + module = importlib.util.module_from_spec(spec) + sys.modules["network_smoke"] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def wired(battery, client, monkeypatch): + """Point the battery's `fetch` at the test client. + + The signature is `fetch(url, ua=..., method=..., body=..., headers=...)` + and it returns `(status, lowercased_headers, text)`. Only GET is used by + the satellite battery, so a non-GET here is a bug in the script rather + than something to emulate. + """ + seen_agents = [] + + def fetch(url, ua=battery.UA, method="GET", body=None, headers=None, + timeout=None, retries=1): + assert method == "GET", f"the satellite battery issued a {method}" + seen_agents.append(ua) + accept = (headers or {}).get("Accept") + + # Off-host URLs — today just the CDN-hosted social card — resolve to a + # stub. Reaching the real CDN from a unit test would make the suite + # depend on another service being up; that the asset genuinely + # resolves is the DEPLOYED battery's job, which is where the check + # earns its keep. + if not url.startswith(BASE) and "://" in url: + return 200, {"content-type": "image/png"}, "" + + path = url[len(BASE):] if url.startswith(BASE) else url + response = client.get(path or "/", user_agent=ua, accept=accept) + return response.status, dict(response.headers), response.text + + monkeypatch.setattr(battery, "fetch", fetch) + monkeypatch.setattr(battery, "_RESULTS", []) + battery.seen_agents = seen_agents + return battery + + +def test_the_battery_passes_against_this_app(wired, capsys): + wired.satellite_checks(BASE) + output = capsys.readouterr().out + + failed = [(name, detail) for name, verdict, detail in wired._RESULTS + if verdict == wired.FAIL] + assert failed == [], f"battery failures against the in-process app:\n{output}" + assert len(wired._RESULTS) >= 9, "checks silently stopped running" + + +def test_every_request_the_battery_makes_is_internal(wired): + """A battery that pollutes the ledger it is auditing is worse than none.""" + wired.satellite_checks(BASE) + untokened = [ua for ua in wired.seen_agents if INTERNAL_UA_TOKEN not in ua] + assert untokened == [], f"battery sent untokened User-Agents: {untokened}" + + +def test_the_expected_h1_tracks_the_brand_constant(battery): + """The per-site block is a copy of `SITE_BRAND`; copies drift.""" + assert battery.SITE_H1 == f"# {SITE_BRAND}" + + +def test_the_expected_og_image_tracks_the_constant(battery): + """Same reason: the battery hard-codes the URL so it can run standalone.""" + from lib.constants import OG_IMAGE_URL + + assert battery.OG_IMAGE_URL == OG_IMAGE_URL + + +def test_the_sample_page_is_a_real_page(battery, page_paths): + """The battery probes one named page; a rename would make it 404 forever.""" + assert battery.SAMPLE_PAGE in page_paths + + +def test_the_hidden_paths_are_the_ones_run_py_marks_hidden(battery): + """A hidden page nobody listed here is a leak the battery cannot see.""" + run_py = (REPO_ROOT / "run.py").read_text() + listed = {p.rsplit("/llms.txt", 1)[0] for p in battery.HIDDEN_DOC_PATHS} + for path in listed: + if path == "/admin": + continue # the canary, deliberately not a registered page + assert f'mark_hidden("{path}")' in run_py, ( + f"{path} is in the battery's hidden list but run.py does not mark " + "it hidden — the check would pass for the wrong reason" + ) + + +def test_the_battery_reports_a_failure_rather_than_swallowing_it(wired): + """The check that keeps every other assertion here honest. + + If `check()` ever caught too broadly, the battery would print `pass` for a + host that is on fire. Break one expectation on purpose and require it to + be reported. + """ + wired.SITE_H1 = "# not this site" + try: + wired.satellite_checks(BASE) + finally: + wired.SITE_H1 = f"# {SITE_BRAND}" + + verdicts = {name: verdict for name, verdict, _ in wired._RESULTS} + assert verdicts.get("llms_txt_identity") == wired.FAIL + + +def test_the_default_base_url_matches_the_container_port(battery): + """CI boots the image and runs the battery with no --base-url.""" + dockerfile = (REPO_ROOT / "Dockerfile").read_text() + port = battery.DEFAULT_BASE_URL.rsplit(":", 1)[1] + assert f"EXPOSE {port}" in dockerfile, ( + f"the battery defaults to port {port}; the image exposes something else" + ) + assert f"PORT={port}" in dockerfile, "the image defaults to a different port" diff --git a/tests/test_network_surfaces.py b/tests/test_network_surfaces.py new file mode 100644 index 0000000..58451b2 --- /dev/null +++ b/tests/test_network_surfaces.py @@ -0,0 +1,164 @@ +"""The agent- and crawler-facing surfaces, in-process. + +Same contract as scripts/network_smoke.py, one layer down: the battery proves +the DEPLOYED artifact serves these, this proves the code does. The overlap is +deliberate — a pull request that breaks a surface fails here in seconds +without waiting for a container, and a deploy that breaks one fails there even +though the code is fine. + +Everything asserted here is a silent failure in production: a sitemap on the +wrong host does not error, it deindexes; an owner-only page that answers +llms.txt does not error, it leaks; a page serving the JavaScript stub does not +error, it just indexes nothing. +""" + +from __future__ import annotations + +import json +import re + +from conftest import BROWSER_ACCEPT, CRAWLER_UA, SAMPLE_PAGE, STUB_MARKER + +# run.py marks exactly one page hidden. `/admin` is the canary for the next. +HIDDEN_PATHS = ("/admin/control-board", "/admin") + + +def test_healthz_is_json_and_names_this_app(client): + """render.yaml's healthCheckPath, and the hub's hourly pulse target.""" + response = client.get("/healthz") + assert response.ok + body = json.loads(response.text) + assert body["ok"] is True + # The 2plot network-directory key — the hub labels this app's series from + # it, so a rename here silently orphans the traffic chart. + assert body["app"] == "leaflet" + + +def test_llms_index_publishes_the_page_directory(client): + body = client.get("/llms.txt").text + assert "## Pages" in body + assert "## Network" in body + + +def test_llms_index_names_the_hub(client): + """What lets an agent walk from this leaf back up to the network.""" + assert "https://2plot.dev" in client.get("/llms.txt").text + + +def test_a_page_document_is_not_a_dead_end(client): + response = client.get(f"{SAMPLE_PAGE}/llms.txt") + assert response.ok + assert "/llms.txt" in response.text + + +def test_hidden_pages_404_their_llms_txt(client): + for path in HIDDEN_PATHS: + response = client.get(f"{path}/llms.txt") + assert response.status == 404, f"{path}/llms.txt leaked ({response.status})" + + +def test_the_sitemap_stays_on_this_host_and_leaks_nothing(client): + from lib.constants import BASE_URL + + response = client.get("/sitemap.xml") + assert response.ok + locs = re.findall(r"<loc>([^<]+)</loc>", response.text) + assert locs, "sitemap lists no pages" + foreign = [u for u in locs if not u.startswith(BASE_URL)] + assert foreign == [], f"sitemap points off-host: {foreign[:3]}" + for path in HIDDEN_PATHS: + assert path not in response.text, f"hidden path {path} leaked into the sitemap" + + +def test_robots_carries_the_ai_search_allowlist(client): + """The 2.3.2 / 2.3.3 artifact fingerprint, visible from outside. + + pip metadata is invisible to a live probe, so these stanzas are how a + deployed host is proven to run the intended package. + """ + lines = [ln.strip() for ln in client.get("/robots.txt").text.splitlines()] + + def rule(agent: str) -> str: + marker = f"User-agent: {agent}" + assert marker in lines, f"{marker} stanza missing" + return lines[lines.index(marker) + 1] + + for agent in ("OAI-SearchBot", "ChatGPT-User", "PerplexityBot", + "Claude-User", "Claude-SearchBot"): + assert rule(agent) == "Allow: /", f"{agent} is not allowed" + + assert any(ln.startswith("Sitemap:") for ln in lines), "no Sitemap line" + + +def test_robots_keeps_this_sites_deliberate_open_training_posture(client): + """`block_ai_training=False` in run.py is a decision, not drift. + + For MIT-licensed component documentation, being in the training corpus is + how a model recommends this library to somebody who never visits the site. + Under that config dash-improve-my-llms emits no ClaudeBot stanza at all — + training crawlers fall under `User-agent: *`. If someone flips the flag, + this is the test that makes them say so out loud. + """ + lines = [ln.strip() for ln in client.get("/robots.txt").text.splitlines()] + assert "User-agent: ClaudeBot" not in lines + + +def test_robots_keeps_the_admin_surface_out_of_the_index(client): + assert "Disallow: /admin/" in client.get("/robots.txt").text + + +def test_a_crawler_gets_prose_not_the_javascript_stub(client, page_paths): + """The prerender — the failure this whole network cares most about. + + A crawler that receives the stub indexes nothing, and the page looks + perfect in a browser the entire time. + """ + checked = [p for p in page_paths if not p.startswith("/admin")][:8] + assert checked, "no public pages registered" + for path in checked: + html = client.get(path, user_agent=CRAWLER_UA).text + assert STUB_MARKER not in html, f"{path} served the JavaScript stub" + + +def test_a_crawler_gets_a_canonical_on_this_host(client): + from lib.constants import BASE_URL + + html = client.get("/", user_agent=CRAWLER_UA).text + found = re.findall(r'rel="canonical"\s+href="([^"]*)"', html) + assert len(found) == 1, f"expected one canonical, got {found}" + assert found[0].startswith(BASE_URL), found[0] + + +def test_agents_and_browsers_get_different_types(client): + """One URL, two audiences, and a `Vary` that stops a CDN mixing them.""" + md = client.get(f"{SAMPLE_PAGE}/llms.txt") + assert md.content_type.startswith("text/markdown"), md.content_type + assert "<!DOCTYPE html>" not in md.text, "viewer chrome reached an agent" + + html = client.get(f"{SAMPLE_PAGE}/llms.txt", accept=BROWSER_ACCEPT) + assert "text/html" in html.content_type, html.content_type + assert "mk-wordmark" in html.text, "the network wordmark is missing" + + for label, response in (("markdown", md), ("html", html)): + assert "accept" in response.header("Vary").lower(), ( + f"no Vary: Accept on the {label} variant — a shared cache may " + "serve it to everyone" + ) + + +def test_the_llms_viewer_is_noindex(client): + """The rendered view must not compete with the page it documents.""" + html = client.get(f"{SAMPLE_PAGE}/llms.txt", accept=BROWSER_ACCEPT).text + assert re.search(r'<meta[^>]+name="robots"[^>]+noindex', html) + + +def test_every_public_page_has_llms_prose(client, page_paths): + """`warn_missing_llms_doc=True` in run.py should have nothing to say.""" + missing = [] + for path in page_paths: + if path.startswith("/admin") or path == "/": + continue + response = client.get(f"{path.rstrip('/')}/llms.txt") + if not response.ok or STUB_MARKER in response.text: + missing.append(path) + assert missing == [], f"pages with no agent-facing prose: {missing}" diff --git a/tests/test_site_identity.py b/tests/test_site_identity.py new file mode 100644 index 0000000..2d548ab --- /dev/null +++ b/tests/test_site_identity.py @@ -0,0 +1,148 @@ +"""Site identity: one brand, every surface, verbatim. + +The network standard says a site states what it is in the same words +everywhere an agent or a reader can reach. The failure this pins is silent, +which is why it needs tests rather than a code review: nothing errors when a +surface falls back to a default. + +dash-improve-my-llms 2.3.4's `resolve_site_title` is what makes the fix +possible: it takes the home page's registered `name` first, `app.title` +second, and *skips* generic candidates ("Home", "Index", "Dash") rather than +publishing them. This site is one candidate away from that failure — its home +page is registered under the nav label "Home", which is on the generic list — +so the explicit `register_page_metadata(path="/", name=SITE_BRAND)` call in +run.py is load-bearing, not decorative. +""" + +from __future__ import annotations + +from conftest import BROWSER_ACCEPT, REPO_ROOT, SAMPLE_PAGE +from lib.constants import SITE_BRAND, SITE_DESCRIPTION + +# Spelled out rather than imported, so that renaming the constant cannot +# silently rename the site. Changing the brand should require changing this +# line, deliberately. +EXPECTED_BRAND = "dash-leaflet2 — Leaflet 2 maps for Dash" + + +def test_brand_constant_is_the_agreed_identity(): + assert SITE_BRAND == EXPECTED_BRAND + + +def test_app_title_is_the_brand(app): + """`Dash(title=...)` — the <title> and `resolve_site_title`'s fallback.""" + assert app.title == EXPECTED_BRAND + + +def test_home_prose_opens_with_the_brand(): + """The home markdown's own H1, below the frontmatter.""" + body = (REPO_ROOT / "docs" / "home" / "home.md").read_text() + headings = [ln for ln in body.splitlines() if ln.startswith("# ")] + assert headings, "docs/home/home.md has no H1" + assert headings[0] == f"# {EXPECTED_BRAND}" + + +def test_llms_index_h1_is_the_brand(client): + """The single most-read line of this site, and the one nobody looks at.""" + response = client.get("/llms.txt") + assert response.ok + assert response.text.splitlines()[0] == f"# {EXPECTED_BRAND}" + + +def test_llms_index_tagline_is_the_description(client): + body = client.get("/llms.txt").text + assert f"> {SITE_DESCRIPTION}" in body + + +def test_the_viewer_brand_chip_is_not_a_framework_default(client): + """The chip that reads a bare "Dash" on a pre-2.3.4 artifact. + + It is rendered from the same `resolve_site_title` call as the H1, so + asserting the brand is present catches both a stale package and a + regressed constant. + """ + import html as html_module + + page = client.get(f"{SAMPLE_PAGE}/llms.txt", accept=BROWSER_ACCEPT).text + assert html_module.escape(EXPECTED_BRAND) in page or EXPECTED_BRAND in page, ( + "the viewer banner does not name this site" + ) + + +def test_a_control_board_toggle_cannot_rename_the_site(): + """The regression `lib.page_visibility.published_name` exists to stop. + + `apply_llms_state` re-registers a page's metadata every time a verdict + changes, from the name the markdown loader recorded — "Home" for this + site's root. Without the substitution, one flip of the home page's + llms.txt switch would overwrite SITE_BRAND at runtime and the /llms.txt H1 + would quietly become "Home", with nothing logged. + """ + from lib.page_visibility import published_name + + assert published_name("/", "Home") == EXPECTED_BRAND + assert published_name(SAMPLE_PAGE, "Pointer Events") == "Pointer Events" + + +def test_the_home_page_name_really_is_generic(): + """The premise of the test above, pinned. + + If the home page were ever renamed to something specific, the substitution + would be redundant rather than load-bearing — and this test failing is how + you would find out, instead of discovering that two mechanisms now fight + over the same string. + """ + from dash_improve_my_llms.handlers import _GENERIC_SITE_TITLES + + import frontmatter + + meta, _ = frontmatter.parse((REPO_ROOT / "docs" / "home" / "home.md").read_text()) + assert meta["name"].strip().lower() in _GENERIC_SITE_TITLES + + +def test_no_surface_falls_back_to_a_generic_title(): + """The values `resolve_site_title` is designed to skip. + + If the brand were ever set to one of these, the package would silently + fall through to the next candidate and this repo would have no idea which + string it was publishing. + """ + from dash_improve_my_llms.handlers import _GENERIC_SITE_TITLES + + assert SITE_BRAND.strip().lower() not in _GENERIC_SITE_TITLES + + +def test_the_package_name_is_in_the_description_not_the_brand(): + """Naming rules from the standard, both directions. + + The brand says what the site *is*; the byline belongs in the description. + A brand of "Pip Install Python" would make every satellite in the network + share one name. (`dash-leaflet2` is this project's actual name, so it + legitimately appears in both.) + """ + assert "dash-leaflet2" in SITE_DESCRIPTION + assert "Pip Install Python" in SITE_DESCRIPTION + assert "Pip Install Python" not in SITE_BRAND + + +def test_readme_agrees_with_the_brand(): + """A README that names the site differently is the next drift.""" + readme = (REPO_ROOT / "README.md").read_text() + assert EXPECTED_BRAND in readme, "README.md does not state the site brand" + + +def test_llms_package_floor_is_the_network_standard(): + """Identity resolution lives in the package; the floor is what delivers it.""" + import dash_improve_my_llms as pkg + + parts = tuple(int(p) for p in pkg.__version__.split(".")[:3] if p.isdigit()) + assert parts >= (2, 3, 4), ( + f"dash-improve-my-llms {pkg.__version__} predates resolve_site_title; " + "the viewer chip and the /llms.txt H1 would fall back to app.title" + ) + + +def test_the_home_markdown_is_no_longer_a_scaffold(): + """`This page demonstrates Home.` was the generated stub's prose.""" + body = (REPO_ROOT / "docs" / "home" / "home.md").read_text() + assert "This page demonstrates Home." not in body diff --git a/tests/test_smoke_live.py b/tests/test_smoke_live.py new file mode 100644 index 0000000..10cb741 --- /dev/null +++ b/tests/test_smoke_live.py @@ -0,0 +1,290 @@ +"""Exercise scripts/smoke_live.py against the app itself. + +The script only ever runs in CD, against a host that already exists, which is +exactly the kind of code that rots unnoticed — a typo in a regex turns every +check into a silent pass and CD keeps reporting green over a broken deploy. +So it gets run here too, with its `fetch` pointed at the in-process app +instead of the network. +""" + +from __future__ import annotations + +import importlib.util +import sys + +import pytest + +from conftest import REPO_ROOT, backend +from lib.constants import BASE_URL + +# The app's real origin, because the script checks that canonical tags and +# sitemap URLs match the host being requested. Pointing it at a made-up +# hostname would fail those checks for the wrong reason. +BASE = BASE_URL + + +@pytest.fixture(scope="module") +def smoke(): + spec = importlib.util.spec_from_file_location( + "smoke_live", REPO_ROOT / "scripts" / "smoke_live.py" + ) + module = importlib.util.module_from_spec(spec) + sys.modules["smoke_live"] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def wired(smoke, client, monkeypatch): + """Point the script's fetch at the test client. + + Off-host URLs (the peers' llms.txt) resolve to a stub 200 — reaching over + the network from a unit test would make the suite depend on eleven other + deployments being up. + """ + def fetch(url, user_agent=smoke.BROWSER_UA, accept=None): + if url.startswith(BASE): + path = url[len(BASE):] or "/" + response = client.get(path, user_agent=user_agent, accept=accept) + return response.status, response.text, response.headers + return 200, "# peer\n", {"Content-Type": "text/markdown"} + + monkeypatch.setattr(smoke, "fetch", fetch) + monkeypatch.setattr(smoke, "failures", []) + monkeypatch.setattr(smoke, "warnings", []) + monkeypatch.setattr(smoke, "checks_run", 0) + return smoke + + +def test_smoke_script_passes_against_this_app(wired, capsys): + exit_code = wired.main(BASE) + output = capsys.readouterr().out + assert exit_code == 0, f"smoke_live reported failures:\n{output}" + assert "checks passed" in output + + +def test_smoke_script_detects_a_stub_body(wired, smoke, monkeypatch, capsys): + """The check that matters most must actually fire when it should.""" + original = smoke.fetch + + def stubbed(url, user_agent=smoke.BROWSER_UA, accept=None): + status, body, headers = original(url, user_agent, accept) + if user_agent == smoke.CRAWLER_UA: + body = f"<main><p>{smoke.STUB_MARKER}</p></main>" + return status, body, headers + + monkeypatch.setattr(smoke, "fetch", stubbed) + assert wired.main(BASE) > 0 + assert "served the JavaScript stub" in capsys.readouterr().out + + +def test_smoke_script_detects_a_foreign_canonical(wired, smoke, monkeypatch, capsys): + original = smoke.fetch + + def rehosted(url, user_agent=smoke.BROWSER_UA, accept=None): + status, body, headers = original(url, user_agent, accept) + return status, body.replace( + f'rel="canonical" href="{BASE}', + 'rel="canonical" href="https://someone-elses-host.example.com', + ), headers + + monkeypatch.setattr(smoke, "fetch", rehosted) + assert wired.main(BASE) > 0 + assert "canonical on" in capsys.readouterr().out + + +def test_smoke_script_detects_viewer_chrome_leaking_to_agents( + wired, smoke, monkeypatch, capsys +): + """The other check ROLLOUT.md calls out as silent and expensive. + + If the viewer's HTML ever reaches a plain fetch, every agent in the + network pays tokens for decoration and nothing anywhere reports it. + """ + original = smoke.fetch + + def leaky(url, user_agent=smoke.BROWSER_UA, accept=None): + status, body, headers = original(url, user_agent, accept) + if url.endswith("/llms.txt") and accept is None: + body = '<!DOCTYPE html><div class="dv-banner">chrome</div>' + body + return status, body, headers + + monkeypatch.setattr(smoke, "fetch", leaky) + assert wired.main(BASE) > 0 + assert "viewer chrome" in capsys.readouterr().out + + +def test_peer_urls_survive_markdown_link_syntax(wired, smoke, capsys): + """The 2.2.0 nav block writes `[https://host/llms.txt](https://host/llms.txt)`. + + A URL pattern that stops only at whitespace and `)` swallows the label and + the opening paren into one malformed URL, which then 404s and fails a + perfectly good deploy. Every extracted URL must be fetchable as-is. + """ + assert wired.main(BASE) == 0 + # Either label: a peer that answers is reported as "serves a document", + # one that doesn't as "reachable". + reported = [ + line.split(": ", 1)[1].strip() + for line in capsys.readouterr().out.splitlines() + if "peer reachable: " in line or "peer serves a document: " in line + ] + assert reported, "no peer URLs were extracted at all" + malformed = [u for u in reported if any(ch in u for ch in "()[]")] + assert malformed == [], f"markdown syntax leaked into peer URLs: {malformed}" + + +def test_smoke_script_detects_a_missing_vary_header(wired, smoke, monkeypatch, capsys): + """A CDN that never sees `Vary: Accept` will serve one cached variant to + everyone — the one failure that only appears in front of a real cache.""" + original = smoke.fetch + + def unvaried(url, user_agent=smoke.BROWSER_UA, accept=None): + status, body, headers = original(url, user_agent, accept) + return status, body, {k: v for k, v in headers.items() if k.lower() != "vary"} + + monkeypatch.setattr(smoke, "fetch", unvaried) + assert wired.main(BASE) > 0 + assert "Vary: Accept" in capsys.readouterr().out + + +def test_smoke_script_rejects_a_peer_serving_its_spa_shell( + wired, smoke, monkeypatch, capsys +): + """A 200 alone does not mean a host serves the document. + + A Dash app answers its catch-all with the SPA shell for any unmatched + path, so a peer that publishes no llms.txt still returns 200 text/html. + Verified against 2plot.dev, where `/api/this-endpoint-cannot-exist` also + returns 200 text/html — a status-only check passes on every such host and + the directory looks healthy while pointing at nothing. + """ + original = smoke.fetch + + def spa_shell(url, user_agent=smoke.BROWSER_UA, accept=None): + if not url.startswith(BASE): + return 200, "<!DOCTYPE html><html><body>app</body></html>", { + "Content-Type": "text/html; charset=utf-8" + } + return original(url, user_agent, accept) + + monkeypatch.setattr(smoke, "fetch", spa_shell) + # Reported, but NOT fatal: this is somebody else's host. See `check()`. + assert wired.main(BASE) == 0 + output = capsys.readouterr().out + assert "that host's catch-all" in output + assert "warn peer serves a document" in output + assert wired.warnings, "the peer problem was detected but not recorded" + + +@pytest.mark.skipif(backend() != "flask", reason="one backend is enough for this") +def test_a_dead_peer_is_reported_but_does_not_fail_the_deploy( + wired, smoke, monkeypatch, capsys +): + """Every peer in the network down at once, and this deploy still ships. + + The policy this pins: a check about THIS host is fatal, a check about + somebody else's host is a warning. Gating on peers is shared fate — one + expired certificate anywhere in the network would stop every satellite + from deploying, which is both wrong and the fastest way to teach people + that a red CD means nothing. + """ + original = smoke.fetch + + def dead_peers(url, user_agent=smoke.BROWSER_UA, accept=None): + if not url.startswith(BASE): + return 404, "", {} + return original(url, user_agent, accept) + + monkeypatch.setattr(smoke, "fetch", dead_peers) + assert wired.main(BASE) == 0 + output = capsys.readouterr().out + assert "warn peer reachable" in output + assert "warnings (peers — not this deployment)" in output + + +def test_a_broken_local_surface_still_fails_the_deploy( + wired, smoke, monkeypatch, capsys +): + """The other half of the policy, and the one worth guarding. + + Demoting peers to warnings is only safe if everything about this host + stayed fatal. Break a local surface while every peer is healthy and the + exit code must still be non-zero. + """ + original = smoke.fetch + + def no_sitemap(url, user_agent=smoke.BROWSER_UA, accept=None): + if url.startswith(BASE) and url.endswith("/sitemap.xml"): + return 500, "", {} + return original(url, user_agent, accept) + + monkeypatch.setattr(smoke, "fetch", no_sitemap) + assert wired.main(BASE) > 0 + assert "FAIL /sitemap.xml responds 200" in capsys.readouterr().out + + +def test_fetch_survives_an_unreadable_error_body(smoke, monkeypatch): + """A peer that 502s mid-body must not take the whole run down. + + `fetch` returns the status and reads the body as a bonus, but + `HTTPError.read()` can itself raise — a truncated error response raises + `IncompleteRead` — and an exception escaping `fetch` crashes the script + before it can report anything. Observed against a live peer during the + 2plot network rollout: one sick host ended a CD run with a traceback + instead of a warning, which is precisely what the fatal/warn split in + `check()` exists to prevent. + """ + import http.client + import urllib.error + + class _Truncated(urllib.error.HTTPError): + def __init__(self): + super().__init__("https://peer.example/llms.txt", 502, "Bad Gateway", {}, None) + + def read(self, *_args, **_kwargs): + raise http.client.IncompleteRead(b"partial", 20205) + + def boom(*_args, **_kwargs): + raise _Truncated() + + monkeypatch.setattr(smoke.urllib.request, "urlopen", boom) + + status, body, _headers = smoke.fetch("https://peer.example/llms.txt") + assert status == 502 + assert body == "" + + +def test_fetch_retries_network_errors_but_not_http_statuses(smoke, monkeypatch): + """The distinction that keeps a cold start from reading as a regression. + + This site is on Render's free tier and sleeps after ~15 minutes idle, so a + burst of ~17 requests reliably meets one cold start. Without a retry that + surfaced as `FAIL canonical on /<page>` — a check that never ran — sending + you to inspect canonical tags that were correct all along. A real 404 must + still be reported on the first response, with no added latency. + """ + import urllib.error + + attempts = {"n": 0} + + def flaky(*_args, **_kwargs): + attempts["n"] += 1 + raise TimeoutError("connection timed out") + + monkeypatch.setattr(smoke.urllib.request, "urlopen", flaky) + monkeypatch.setattr(smoke.time, "sleep", lambda _s: None) + status, body, _ = smoke.fetch("https://leaflet.2plot.dev/") + assert status == 0 + assert "TimeoutError" in body + assert attempts["n"] == smoke.RETRIES, "network errors are not being retried" + + attempts["n"] = 0 + + def not_found(*_args, **_kwargs): + raise urllib.error.HTTPError("https://leaflet.2plot.dev/nope", 404, "NF", {}, None) + + monkeypatch.setattr(smoke.urllib.request, "urlopen", not_found) + status, _body, _ = smoke.fetch("https://leaflet.2plot.dev/nope") + assert status == 404 + assert attempts["n"] == 0 diff --git a/tests/test_social_card.py b/tests/test_social_card.py new file mode 100644 index 0000000..76d4a4a --- /dev/null +++ b/tests/test_social_card.py @@ -0,0 +1,271 @@ +"""The social card and the installable-app surfaces. + +Both fail silently and both fail *outside* this app, which is why they need +tests rather than a look at the page: + +* a link preview is built by Facebook, Twitter/X, Slack, Discord and LinkedIn + from tags nobody on the team ever sees rendered — and an EMPTY `og:image` + is worse than none, because a scraper treats the empty value as the declared + image and renders a blank card. Every page on this site shipped exactly that + until `image_url=` was passed to `register_page`; +* the web app manifest decides whether a browser offers "install". Its + `name`/`short_name` were empty strings and its icon paths pointed at + `/android-chrome-192x192.png` at the site root, where nothing is served, so + no browser could ever have made the offer. Nothing about that is visible on + the page. + +Note on where the tags come from — the split matters when one of these fails: +`og:image`, `twitter:image` and the whole `twitter:*` set are emitted by DASH +(`dash/_pages.py`, per page, from `register_page`), while `og:site_name`, +the `og:image:*` auxiliaries and the icon links come from +`templates/index.html`. dash-improve-my-llms adds a third set, but only on the +prerender path, which social scrapers do not take — its bot list has +`facebookbot` (Meta's AI training crawler) and not `facebookexternalhit` (the +link-preview fetcher). That is why deleting index.html would silently kill +every unfurl. +""" + +from __future__ import annotations + +import json +import re + +from conftest import REPO_ROOT, SAMPLE_PAGE +from lib.constants import ( + OG_IMAGE_ALT, + OG_IMAGE_HEIGHT, + OG_IMAGE_URL, + OG_IMAGE_WIDTH, + SITE_BRAND, +) + +MANIFEST = REPO_ROOT / "assets" / "favicon_io" / "site.webmanifest" + + +def _visible(html: str) -> str: + """The document with HTML comments removed. + + templates/index.html documents itself, and a regex cannot tell an example + tag inside a comment from a live one — this file's first draft reported + phantom `rel="icon"` links that were pure prose. + """ + return re.sub(r"<!--.*?-->", "", html, flags=re.S) + + +def _meta(html: str, key: str, value: str) -> list[str]: + """Every `content` for a given property/name — a list, to catch duplicates. + + Tags carrying `data-dimll-prerender` are excluded. dash-improve-my-llms + injects its own description and OpenGraph block and marks each one exactly + so it can be told apart; counting those would hide what these tests are + for, which is duplication between templates/index.html and the tags Dash + generates from `register_page`. + """ + pattern = ( + rf'<meta[^>]*(?:property|name)="{re.escape(value)}"[^>]*content="([^"]*)"' + rf'|<meta[^>]*content="([^"]*)"[^>]*(?:property|name)="{re.escape(value)}"' + ) + body = re.sub(r'<meta[^>]*data-dimll-prerender[^>]*>', "", _visible(html)) + return ["".join(m) for m in re.findall(pattern, body)] + + +# ------------------------------------------------------------- the og image -- + + +def test_the_og_image_is_never_empty(client, page_paths): + """The regression this file exists for. + + Dash emits `og:image` unconditionally and leaves it empty when it can find + no image. An empty tag is a blank preview card on every platform. + """ + for path in [p for p in page_paths if not p.startswith("/admin")][:8]: + html = client.get(path).text + images = _meta(html, "property", "og:image") + assert images, f"{path} declares no og:image at all" + assert all(src.strip() for src in images), ( + f"{path} serves an EMPTY og:image {images} — the card renders blank" + ) + + +def test_the_og_image_is_absolute(client): + """A relative og:image is unusable: the scraper has no base to resolve it.""" + for prop in ("og:image", "twitter:image"): + values = _meta(client.get("/").text, "property", prop) + assert values, f"no {prop} on the home page" + for src in values: + assert src.startswith("https://"), f"{prop}={src!r} is not absolute" + + +def test_the_image_is_the_one_the_constants_declare(client): + assert OG_IMAGE_URL in client.get("/").text + + +def test_the_image_is_declared_exactly_once(client): + """Two og:image tags let the scraper pick, and it will pick the wrong one. + + templates/index.html deliberately ships only the AUXILIARY image tags + (dimensions, type, alt) precisely so it cannot duplicate the URL Dash + already emits. This is what keeps that rule honest. + """ + html = client.get(SAMPLE_PAGE).text + assert len(_meta(html, "property", "og:image")) == 1 + assert len(_meta(html, "property", "twitter:image")) == 1 + + +def test_the_auxiliary_image_tags_match_the_constants(client): + """index.html hard-codes the dimensions; lib/constants.py is the source.""" + html = client.get("/").text + assert _meta(html, "property", "og:image:width") == [str(OG_IMAGE_WIDTH)] + assert _meta(html, "property", "og:image:height") == [str(OG_IMAGE_HEIGHT)] + assert _meta(html, "property", "og:image:alt") == [OG_IMAGE_ALT] + assert _meta(html, "property", "og:image:secure_url") == [OG_IMAGE_URL] + + +def test_the_twitter_card_is_a_large_image(client): + assert _meta(client.get("/").text, "property", "twitter:card") == [ + "summary_large_image" + ] + + +def test_no_meta_tag_dash_emits_is_also_declared_statically(client): + """The rule templates/index.html's OG block is built on. + + Dash emits all of these per page from `register_page`. A static copy in the + template makes two of each, and the static one describes the SITE where + Dash's describes the PAGE — redundant and less accurate at once. Which tag + a scraper honours is undefined; in practice the later wins. + """ + html = client.get(SAMPLE_PAGE).text + for tag in ("description", "og:type", "og:title", "og:description", + "og:image", "twitter:card", "twitter:url", "twitter:title", + "twitter:description", "twitter:image"): + found = _meta(html, "property", tag) + assert len(found) <= 1, f"{tag} is declared {len(found)} times: {found}" + + +def test_the_tags_dash_omits_are_declared_here(client): + """The other half of the rule — these are not covered by Dash.""" + html = client.get("/").text + for tag in ("og:site_name", "og:url", "og:image:alt", "twitter:image:alt"): + assert _meta(html, "property", tag), ( + f"{tag} is missing and Dash does not emit it" + ) + + +def test_og_site_name_is_present_because_dash_omits_it(client): + """The one image-adjacent tag Dash genuinely does not emit.""" + assert _meta(client.get("/").text, "property", "og:site_name") + + +# ------------------------------------------------------------- the manifest -- + + +def test_the_manifest_is_linked_from_the_document(client): + """Without the link element the manifest may as well not exist.""" + html = client.get("/").text + assert 'rel="manifest"' in html, "no manifest link — no install prompt" + assert "/assets/favicon_io/site.webmanifest" in html + + +def test_the_manifest_is_served(client): + response = client.get("/assets/favicon_io/site.webmanifest") + assert response.ok, f"the manifest 404s ({response.status})" + + +def test_the_manifest_is_installable(): + """The fields a browser requires before it will offer to install. + + An empty `name` disqualifies it, and that is exactly how it shipped. + """ + manifest = json.loads(MANIFEST.read_text()) + assert manifest["name"].strip(), "empty name — no browser will offer install" + assert manifest["short_name"].strip(), "empty short_name" + assert manifest["start_url"] == "/" + assert manifest["display"] == "standalone" + assert manifest["name"] == SITE_BRAND, "the manifest name is not the site brand" + + +def test_every_manifest_icon_actually_resolves(client): + """The failure that made the manifest inert. + + Icons were declared at `/android-chrome-192x192.png` — the site root — + while the files live under `/assets/favicon_io/`. A manifest whose icons + 404 is not installable, and nothing reports it. + """ + manifest = json.loads(MANIFEST.read_text()) + icons = manifest.get("icons") or [] + assert icons, "the manifest declares no icons" + for icon in icons: + src = icon["src"] + assert src.startswith("/assets/"), f"{src} is not under /assets/" + assert client.get(src).ok, f"manifest icon {src} does not resolve" + # A 192px icon is the documented floor for an install prompt. + assert any(i.get("sizes") == "192x192" for i in icons) + assert any(i.get("sizes") == "512x512" for i in icons) + + +def test_the_apple_touch_icon_is_declared_and_resolves(client): + """iOS ignores the manifest and uses this for Add to Home Screen.""" + html = client.get("/").text + match = re.search(r'<link[^>]*rel="apple-touch-icon"[^>]*href="([^"]+)"', html) + assert match, "no apple-touch-icon — iOS home-screen icon falls back to a screenshot" + assert client.get(match.group(1)).ok + + +def test_the_favicon_resolves(client): + """Dash walks assets recursively, so the favicon_io subfolder is found.""" + html = client.get("/").text + hrefs = re.findall(r'<link[^>]*rel="icon"[^>]*href="([^"]+)"', html) + assert hrefs, "no favicon link" + for href in hrefs: + assert client.get(href.split("?")[0]).ok, f"favicon {href} does not resolve" + + +def test_the_theme_colour_agrees_with_the_manifest(client): + """A mismatch shows as one colour in the browser chrome and another in the + installed app's splash screen.""" + manifest = json.loads(MANIFEST.read_text()) + assert _meta(client.get("/").text, "name", "theme-color") == [ + manifest["theme_color"] + ] + + +# --------------------------------------------------- the template itself ---- + + +def test_the_index_template_is_still_wired_in(app_module): + """A guard on the whole file. + + `templates/index.html` looks removable — dash-improve-my-llms appears to + cover OG — but its injection runs only on the prerender path, which social + scrapers do not take. Deleting the template silently kills every unfurl, + the icons and the manifest at once, and nothing else in this suite would + notice on its own. + """ + index = (REPO_ROOT / "templates" / "index.html").read_text() + for placeholder in ("{%metas%}", "{%favicon%}", "{%css%}", "{%app_entry%}", + "{%config%}", "{%scripts%}", "{%renderer%}"): + assert placeholder in index, f"{placeholder} missing from the template" + assert app_module.app.index_string.startswith("<!DOCTYPE html>") + + +def test_the_structured_data_points_at_a_network_host(): + """`pip-install-python.com` was this site's published Organization URL. + + It is not a 2plot network host, and it appeared three times in the + structured data every crawler reads: the Organization `url`, the + SoftwareSourceCode `author.url`, and a link in the noscript footer. + + Scoped to the template on purpose. `lib/network_directory.py` also lists + the domain, but that is a deliberate directory of AFFILIATED (explicitly + non-network) sites — a separate editorial decision, not template drift. + """ + index = (REPO_ROOT / "templates" / "index.html").read_text() + assert "pip-install-python.com" not in index + + for block in re.findall( + r'<script type="application/ld\+json">(.*?)</script>', index, re.S + ): + data = json.loads(block) + for url in re.findall(r'"url":\s*"([^"]+)"', block): + assert "pip-install-python.com" not in url, data.get("@type")