Automated tests for Solo product documentation, extracted out of the doc markdown so that guides stay readable to casual contributors and test scripts are reusable across each product's own CI workflow.
This repo currently holds two things:
scripts/— the extractor/runner tooling (doc_test_extract.py,doc_test_run.py,doc_test_schema_check.py,doc_test_inject_status.py,doc_test_fetch_artifacts.sh,merge_test_results.py,report_summary.py,list_untested_docs.py). This is the canonical copy —agentgateway/website's CI and Makefile check this repo out and invoke these scripts directly (docs-tests/scripts/doc_test_run.py --repo-root ., etc.); it no longer keeps its own copies.products/— the actual test content, one directory per product. For agentgateway,products/agentgateway/annotations/holds the test annotations, which mirror each doc page's own path (for example,annotations/assets/agw-docs/pages/traffic-management/rewrite/host.md.yaml) and are applied to the docs content at test time. Kept as a directory layout onmainrather than a branch per product × version, and as committed, reviewable, diffable files rather than CI-generated artifacts.
Install yamltest and cloud-provider-kind to run the tests:
npm i -y -g yamltest@latest
pip3 install PyYAML
go install sigs.k8s.io/cloud-provider-kind@latest- A product's
tests.yamlmanifest (products/<product>/<mode>/tests.yaml) defines the test scenarios. Each one names its prerequisites and the path selectors to include from a page. A page's front mattertest:block is still read for pages the manifest does not cover. annotations.py attachputs the test markup (paths=tags and hidden{{< doc-test >}}blocks) fromproducts/<product>/annotations/onto a copy of the content repo's pages. The pages themselves carry no markup.doc_test_run.pyreads the scenarios, chains the selected blocks together, and produces a standalone bash script.- The script is run inside a fresh
kindcluster (withcloud-provider-kindfor load balancer support).
Content repos are moving to carry no test markup at all. The markup (paths=
tags on code blocks, hidden {{< doc-test >}} checks, and front matter test:)
moves into this repo as test annotations under products/<product>/annotations/,
one YAML file per page, and scripts/annotations.py attaches it to a copy of the
pages before a run.
An annotation finds its code block by fingerprint, a hash of the block's text, so pages can move and prose can change freely. Each piece of markup ends a run in one of three states:
| State | What happened |
|---|---|
| exact | The block's text is unchanged. |
| close | The block between the same unchanged neighbors was edited slightly, and it is the only nearly identical candidate: line similarity at least 0.6, and at least 0.1 ahead of the next candidate. |
| needs update | The block was rewritten or removed. The markup stays in the annotation file and is reported, never attached somewhere else. |
Hidden checks move only with the block or sentence they follow. A check that follows a sentence is looked for between the same two code blocks, even when the block before it was close-matched.
| Command | Use |
|---|---|
make annotate PAGE=<page> then make save PAGE=<page> |
Add or change the tests on one page. annotate writes the page with its markup to out/annotate/; edit the markup there; save writes only the markup back, and refuses if the copy changes the page itself. |
annotations.py attach |
Attach annotations to a checkout, in place or into --out. Removes any inline markup first. |
annotations.py refresh |
After content changes: re-attach, record the new fingerprints, and follow moved pages (git rename detection, then a search by fingerprint). Never drops markup; refuses to write if any would disappear. Inline markup still on a page is ignored: annotations are the source of truth, so refresh never copies a page's markup into them or creates annotation files for pages that gain it. It reports inline front matter test: edits, which need a matching tests.yaml change. |
annotations.py pending |
List the markup still marked needs update, with --json for a machine-readable copy. See Fixing markup that needs update. |
annotations.py export / strip |
Migration only: copy inline markup into annotation files, then remove it from the pages. |
annotations.py roundtrip |
Strip and re-attach every page in memory; every page must come back byte-identical. Runs daily in CI against agentgateway/website. |
annotation_replay.py |
Replays a repo's git history, whose inline markup is the answer key, to measure close matching. Re-run it when matching changes; close WRONG must not rise. |
doc_test_run.py --annotation-report <attach report> |
Skips, with a warning, any scenario that would select markup which did not attach, instead of running it with a step missing. Skipped scenarios go under skipped_needs_update in the results, not under tests. |
.github/workflows/refresh-annotations.yml runs refresh daily at 05:00 UTC
against agentgateway/website main and commits the result to this repo's
main. Every scheduled run posts to #doctopus-tests in the same layout as the
doc test results, listing every piece of markup that needs update, with a thread
of the day's close matches and moved pages; a failed refresh also posts to
#doctopus-builds. Posting needs a DOCS_TESTS_SLACK_BOT_TOKEN secret on this
repo; until it is set, the step skips with a warning.
For agentgateway/website, every section has moved: scenario definitions live in
the tests.yaml manifests, so their annotations were exported with
--front-matter none. A manifest key must be unique, but a scenario name need
not be: set name: when two pages in one mode declare the same name. Pages that
deliberately have no test are listed under the manifest's skip: key, the
manifest form of front matter test: skip; the runner and
list_untested_docs.py count them as covered. A skip: entry can also be
dir: <folder>/, which covers every page under that folder. Use it for generated
pages, such as the agctl CLI reference, where new pages appear without anyone
editing the manifest.
Note
No current docs page uses the file="..." attribute or the assert: list described
in this section. Test content now lives in the annotation files under
products/<product>/annotations/. The extractor still supports both mechanisms, so
the paths in these examples are placeholders, not real files.
The hidden {{< doc-test >}} shortcode (used for setup/assertions that must run
during tests but never appear in rendered HTML) accepts an optional file="..."
attribute. When present, the extractor reads the block's content from that path
resolved against a docs-tests checkout, instead of requiring an inline body between
the opening and closing shortcode tags:
{{< doc-test paths="rewrite" file="products/<product>/<path>/rewrite.sh" >}}{{< /doc-test >}}The referenced file (products/<product>/<path>/rewrite.sh
in this example) holds exactly the content that used to sit inline between the shortcode
tags — nothing else changes about how the block is selected or emitted; it flows through
the same paths= selection and script assembly as an inline block.
Resolving the docs-tests checkout root:
doc_test_extract.pyanddoc_test_run.pyboth accept--docs-tests-root <path>, or theDOCS_TESTS_ROOTenvironment variable.- If neither is set, it defaults to the docs-tests checkout the script itself runs
from, so the manifests in
products/are always read. - In CI, add a checkout step for
docs-testsand pass its path through either mechanism — seescripts/CI_WORKFLOW_SKETCH.mdfor the concrete workflow change. - A missing or misspelled
file=path fails the extractor immediately with aFileNotFoundErrornaming the source doc, line number, and the resolved path that didn't exist — it never silently drops the block.
This is unrelated to the <!-- doc-test-include file="..." --> HTML comment some of
the extractor's code still supports — that mechanism runs an external bun test <file>
as a separate subprocess (built for a different kind of test) and is not used for this
external-content use case.
The file="..." shortcode above still leaves one line of test scaffolding sitting in
the page body for every hidden block. A step in a page's test: front matter can name
the same external content directly instead, via an assert: list, so the page body
carries nothing but the real content and the paths="X" tag on the visible block:
test:
host-rewrite:
type: functional
steps:
- file: ${versionRoot}/quickstart/install.md
path: experimental
- file: ${versionRoot}/traffic-management/rewrite/host.md
path: host-rewrite
assert:
- products/<product>/<path>/host-rewrite-wait.sh
- products/<product>/<path>/host-rewrite-warmup.sh
- products/<product>/<path>/host-rewrite-assert.shEach entry in assert: is a docs-tests-relative path, run in list order. This
replaces what would otherwise be three separate {{< doc-test paths="host-rewrite" file="..." >}}{{< /doc-test >}} lines physically sitting in the page. Both mechanisms
are supported and can coexist across different pages — assert: is the newer, cleaner
form; file="..." on an inline shortcode still works for pages that haven't been
converted.
Ordering: assert: doesn't say where in the page these files logically belong,
so the extractor anchors each entry to the first block (visible or hidden) in that same
file whose paths= matches the step's path: — the same anchor a reader would expect
from where the inline shortcode used to sit — and runs the whole assert: list
immediately after it, before anything else in the file. This matters because a single
paths= value is often reused later in the same page for something unrelated (a
Cleanup section's kubectl delete, say); anchoring to the first matching block, not
just appending at the end of the file, keeps assertions running before cleanup rather
than after it.
Only scenarios that actually execute something need an assert: list — a type: schema
step never runs anything, so it has no equivalent.
A path is a string label attached to a fenced code block or hidden command block. It controls which blocks are included in which test scenario.
Add ,{paths="<name>}" to the fenced code language line:
```sh,{paths="install-httpbin"}
kubectl apply -f https://raw.githubusercontent.com/.../httpbin.yaml
```A block may belong to multiple paths:
```sh,{paths="standard,experimental"}
helm upgrade -i --create-namespace ...
```Only sh/bash/shell/yaml/yml blocks are extracted.
Tagging hidden command blocks
Use the {{< doc-test >}} Hugo shortcode for commands that must run during tests but must not appear in the website HTML (waits, retries, cleanup). The shortcode template outputs nothing, so the content is completely absent from rendered pages:
{{< doc-test paths="install-httpbin" >}}
YAMLTest -f - <<'EOF'
- name: wait for httpbin deployment
wait:
...
EOF
{{< /doc-test >}}The paths= attribute works identically to fenced blocks. As above, add file="..."
instead of an inline body to pull the content from this repo's products/ tree.
On the page being tested, add a test: key to the YAML front matter. Each child key is a named test scenario. Each entry in the list is a file+path pair — a source file and the path selector to pull from it.
---
title: CORS
test:
cors-in-httproute:
- file: content/docs/kubernetes/main/quickstart/install.md
path: experimental
- file: content/docs/kubernetes/main/setup/gateway.md
path: all
- file: content/docs/kubernetes/main/install/sample-app.md
path: install-httpbin
- file: content/docs/kubernetes/main/security/cors.md
path: cors-in-httproute
cors-in-agentgatewaypolicy:
- file: content/docs/kubernetes/main/quickstart/install.md
path: standard
- file: content/docs/kubernetes/main/setup/gateway.md
path: all
- file: content/docs/kubernetes/main/install/sample-app.md
path: install-httpbin
- file: content/docs/kubernetes/main/security/cors.md
path: cors-in-agentgatewaypolicy
---Multiple scenarios on the same page each get their own kind cluster and generated script.
A scenario can declare a type: alongside its file/path entries, renamed steps: under it:
test:
rewrite:
type: functional
steps:
- file: content/docs/kubernetes/main/quickstart/install.md
path: experimental
- file: content/docs/kubernetes/main/traffic-management/transformations/rewrite.md
path: rewriteA bare list (no type:/steps: wrapper) is still accepted and treated as functional —
every scenario written before this typing existed already applies real config and
asserts on a real response, which is what functional means.
| Type | What it checks | Needs | Blocks the PR? |
|---|---|---|---|
schema |
The doc's own examples validate against the real schema for the version the page documents: custom resources and Gateway API kinds against their CRDs, core Kubernetes kinds through kubeconform, and standalone config files against agentgateway's schema/config.json (renamed/removed/mistyped fields, wrong types) |
Nothing — no cluster, no execution (doc_test_schema_check.py) |
Yes, in its own job |
functional |
Real behavior in a real cluster (apply config, assert on a real response) | A kind cluster, no vendor credentials |
Yes |
schema needs no prerequisite chain — since nothing executes, only the step that shows
the custom resource itself matters:
test:
rewrite-schema:
type: schema
steps:
- file: content/docs/kubernetes/main/traffic-management/transformations/rewrite.md
path: rewriteschema and functional are the only types; any other type: is an error. A page that
depends on an external service (an LLM provider, an identity provider, a cloud account)
is tested as functional against an in-cluster stand-in: the httpbun mock LLM, an
in-cluster Keycloak, a patched provider host. What the stand-in can't reach (a real Entra
tenant, a browser login) stays untested, and the scenario's comments say so.
type: also accepts a list, so one steps: chain gets validated more than one way
without copy-pasting the whole scenario into a same-named -schema sibling just to add a
second type:
test:
rewrite:
type: [schema, functional]
steps:
- file: content/docs/kubernetes/main/quickstart/install.md
path: experimental
- file: content/docs/kubernetes/main/traffic-management/transformations/rewrite.md
path: rewriteThis produces two independent test cases, rewrite::schema and rewrite::functional,
sharing the same steps: — the same shape as hand-writing two separate scenarios, minus
the duplication. --test rewrite selects both; --test rewrite::schema selects only the
schema one. A single-type scenario is unaffected: its name, generated filenames, and
report key stay exactly as before — the name::type suffix only appears once a scenario
declares more than one type.
Add schema this way only when the scenario shows something the checker has a schema
for: a custom resource whose CRD it was given (agentgateway's own kinds, and Gateway API
kinds), a core Kubernetes kind (when kubeconform is available), or, on a standalone page,
a whole config file. A scenario in which nothing was validated fails rather than passing
vacuously, so schema stays opt-in per scenario, not automatic. Helm values and CRD CEL
rules are not checked; see #19.
Scenarios run on Linux, in a kind cluster, unless their tests.yaml entry sets runner:.
Set it only for a page that has to run on another OS, such as an install guide for macOS
or Windows:
migrate-install-macos:
runner: macos # or windows; linux is the default
page: documentation/migrate/install/macos/_index.md
path: migrate-install-macos- A
macosorwindowsscenario runs its script directly, with no cluster. Hosted macOS and Windows runners have no Docker, so these scenarios cannot useneeds:prerequisites that install agentgateway in Kubernetes. --list-testsreports each scenario'srunner, and the consuming site's workflow sends it to a job on that OS (macos-latestorwindows-latest). The script still runs in bash; on Windows that is Git Bash, named throughDOC_TEST_BASH.- A local run skips scenarios for another OS with a
SKIPPED (runner)warning.--generate-onlystill generates them, so you can read the script on any machine.
For a page written in PowerShell, such as a Windows install guide, also set shell: pwsh:
migrate-install-windows:
runner: windows
shell: pwsh
page: documentation/migrate/install/windows/_index.md
path: migrate-install-windows- The scenario is generated as one
.ps1script and run withpwsh -File, so a variable set in one block is still set in the next. - Its page blocks are
powershell(orpwsh,ps1) fences. Write its hiddendoc-testblocks in PowerShell too. - The script stops on the first error, including a native command's non-zero exit.
To fail a check,
throw.
Every guide has a Before you begin section that lists prerequisites. Follow the chain from the feature guide back to the install guide:
feature page (e.g. cors.md)
└── sample-app.md (httpbin installed + HTTPRoute ready)
└── gateway.md (Gateway created + LB address exported)
└── helm.md (CRDs + controller installed)
For each hop:
- Open the file and find its
## Before you beginsection. - Follow the linked page.
- Identify which code blocks are relevant and what path label they carry (or add one if missing).
- Add that file+path as a source entry above the current one in the
test:front matter.
The extractor follows {{< reuse "..." >}} and internal links automatically, so you don't need to inline snippet contents — just reference the top-level content file.
- Use an existing path label if one already exists on the blocks you need.
- The path
allis a conventional catch-all for blocks that are included in every scenario from that file. - For tabbed content (Standard / Experimental installs), separate paths (
standard,experimental) let you pick the right tab per scenario. - A code block with no
paths=is skipped by default (skip_tabs_without_paths: true).
If a block you need has no path, add one:
```sh {paths="install-httpbin"}
kubectl apply -f ...
```If the same block already belongs to another path and you need to add yours:
```sh {paths="standard,my-new-path"}
...
```Use YAMLTest -f - <<'EOF' ... EOF inside a {{< doc-test >}} shortcode block immediately after the kubectl apply it depends on. The wait test type polls a Kubernetes resource until a JSONPath condition is met.
{{< doc-test paths="all" >}}
YAMLTest -f - <<'EOF'
- name: wait for agentgateway-proxy deployment to be ready
wait:
target:
kind: Deployment
metadata:
namespace: agentgateway-system
name: agentgateway-proxy
jsonPath: "$.status.availableReplicas"
jsonPathExpectation:
comparator: greaterThan
value: 0
polling:
timeoutSeconds: 300
intervalSeconds: 5
EOF
{{< /doc-test >}}{{< doc-test paths="all" >}}
YAMLTest -f - <<'EOF'
- name: wait for agentgateway-proxy service LB address
wait:
target:
kind: Service
metadata:
namespace: agentgateway-system
name: agentgateway-proxy
jsonPath: "$.status.loadBalancer.ingress[0].ip"
jsonPathExpectation:
comparator: exists
polling:
timeoutSeconds: 300
intervalSeconds: 5
setVars:
INGRESS_GW_ADDRESS:
value: true
EOF
{{< /doc-test >}}setVars exports the jsonPath-matched value as an environment variable for downstream steps. It is a sibling of wait: (not nested inside it).
{{< doc-test paths="install-httpbin" >}}
YAMLTest -f - <<'EOF'
- name: wait for httpbin HTTPRoute to be accepted
wait:
target:
kind: HTTPRoute
metadata:
namespace: httpbin
name: httpbin
jsonPath: "$.status.parents[0].conditions[?(@.type=='Accepted')].status"
jsonPathExpectation:
comparator: equals
value: "True"
polling:
timeoutSeconds: 300
intervalSeconds: 5
EOF
{{< /doc-test >}}| Comparator | Meaning |
|---|---|
equals |
Exact string/number match |
greaterThan |
Numeric greater-than |
exists |
Field is present and non-empty |
contains |
String contains substring |
After all resources are ready and INGRESS_GW_ADDRESS is exported, add an HTTP test inside a hidden block on the feature page itself:
{{< doc-test paths="cors-in-httproute,cors-in-agentgatewaypolicy" >}}
YAMLTest -f - <<'EOF'
- name: CORS preflight returns expected headers
http:
url: "http://${INGRESS_GW_ADDRESS}:80/get"
method: OPTIONS
headers:
host: www.example.com
Origin: https://example.com
source:
type: local
expect:
statusCode: 200
headers:
- name: access-control-allow-origin
comparator: equals
value: https://example.com
- name: access-control-allow-methods
comparator: contains
value: GET
- name: access-control-max-age
comparator: equals
value: "86400"
EOF
{{< /doc-test >}}source.type: localsends the request from the local machine (default).- Use
source.type: podwith a pod selector to send the request from inside the cluster. - The
headerslist underexpectchecks response headers (case-insensitive name matching). - Use
retries:on a test entry to retry on transient failures (e.g. a race condition where a response code flips briefly). Do not useretries:to work around a new-hostname ECONNRESET — see the Troubleshooting section.
python3 scripts/doc_test_run.py --generate-onlyScripts are written to out/tests/generated/.
Requires kind and cloud-provider-kind in PATH.
python3 scripts/doc_test_run.pyEach test scenario:
- Creates a
kindcluster nameddoc-test-<scenario>. - Starts
cloud-provider-kindin the background (provides LoadBalancer IPs). - Runs the generated bash script.
- Deletes the cluster.
- Writes results to
out/tests/generated/test-results.yaml.
Point directly to a file and (optionally) a named scenario. This generates the script, creates a kind cluster, starts cloud-provider-kind, runs the test, and cleans up — all in one command:
# Run one specific scenario
python3 scripts/doc_test_run.py \
--file content/docs/kubernetes/main/security/cors.md \
--test cors-in-httproute
# Run all scenarios defined in a single file
python3 scripts/doc_test_run.py \
--file content/docs/kubernetes/main/security/cors.mdTo only generate the script without running (useful for inspection):
python3 scripts/doc_test_run.py \
--file content/docs/kubernetes/main/security/cors.md \
--test cors-in-httproute \
--generate-only
bash out/tests/generated/<script-name>.sh| Flag | Default | Description |
|---|---|---|
--file |
— | Path to a single markdown file to generate/run tests for |
--test |
— | Name of a specific test scenario within --file |
--docs-glob |
content/docs/**/*.md |
Glob to discover pages with test: metadata (ignored when --file is set) |
--product |
kubernetes |
Context product used for conditional-text resolution |
--docs-tests-root |
sibling docs-tests dir |
Checkout root for {{< doc-test file="..." >}} external content (or DOCS_TESTS_ROOT env var) |
--generated-dir |
out/tests/generated |
Output directory for scripts and manifests |
--generate-only |
false | Skip cluster creation and execution |
--verbose |
true | Stream all command output |
The version context (used to resolve {{< version include-if="..." >}} blocks) is inferred automatically from the source file paths — e.g. a source under kubernetes/latest/ resolves to version token latest, and kubernetes/main/ to main.
doc_test_extract.py processes source files before emitting the script:
{{< reuse "..." >}}— inlined recursively fromassets/.{{< version include-if="..." >}}— resolved against the inferred version token; non-matching blocks are dropped.{{< conditional-text include-if="..." >}}— resolved against theproductcontext; non-matching blocks are dropped.- Indentation is stripped from fenced block content so heredocs work correctly in bash.
- Duplicate blocks (same content) are emitted only once.
- Blocks without a
paths=attribute are skipped. {{< doc-test file="..." >}}— content is read from thedocs-testscheckout instead of the shortcode body (see above).- A step's
assert:list — synthesizes the same kind of hidden block directly from front matter, anchored to the first block in that file sharing the step'spaths=selector (see above).
All markup goes in this repo. Never add paths=, {{< doc-test >}}, or front matter test: to a page in the content repo.
- Trace prerequisites — follow "Before you begin" links back to
helm.md. - Open the page's annotation with
make annotate PRODUCT=<product> PAGE=<page>. For a thin wrapper page, annotate the reused assets file, not the wrapper. - Verify path labels on the code blocks you need; add
paths="..."where missing, in theout/annotate/copy. - Add wait blocks after each
kubectl applythat creates something tests depend on. - Export
INGRESS_GW_ADDRESS— it flows fromgateway.mdviasetVars. - Add the feature assertion as a
{{< doc-test >}}block in theout/annotate/copy, then runmake savewith the same arguments. - Add the scenario to
products/<product>/<mode>/tests.yaml, with its prerequisites underneeds:and the page undersource:(orpage:). - Regenerate from the content repo with
make test-generate, which attaches your annotations first. Confirm everything attached, and inspect the script for unresolved shortcodes or missing commands. - Run locally with
make test-run DOC_TESTS_ARGS='--file <page> --test <scenario>'from the content repo. If the change goes with a content PR, push it here on a branch with the same name as the content PR's branch, so the content PR's run uses it.
Content changes every day, and the tests here were written against the content as it was. Two things keep them lined up:
- The daily refresh (
.github/workflows/refresh-annotations.yml, see Test annotations) follows edited and moved blocks, so most content edits need no test change at all. It only moves markup; it never decides whether the content or the test is correct. - A docs-tests branch with the same name as the content PR's branch. When a content
change needs a test change, push the test change to a branch here with the same name
as the content PR's branch. The content repo's doc-tests workflow checks out that
branch instead of
main, so the PR's own run tests the new content with the new test. The job summary says when it did. Merge the content PR first, then the docs-tests PR.
A run can report two different problems, and they mean different things.
1. The test could not find its block (needs update). The content changed enough that an annotation no longer attaches.
- Small edits are followed automatically (the
closestate), and the test still runs. - Bigger edits leave the markup unattached. Any scenario that uses it is skipped with a
warning, not run with a step missing. The PR's job summary lists it, the daily
refresh marks it as needs update and counts it in its Slack post, and the page loses
its Verified badge (
test_status: partial) until the annotation is updated. The refresh never updates it; a daily fixer job drafts the update as a pull request here, for a person to review (see Fixing markup that needs update). - This usually means someone edited the content on purpose and the test has to catch up. It does not by itself mean anything is wrong with the content.
- Watch for: a broken content edit that is also big enough to land here. Its test is skipped rather than run, so nothing fails; only the skip warning shows it.
2. The test found its block and failed. The markup attached, the scenario ran, and an assertion or command failed. Any of these can be the cause:
| Cause | Where the fix goes |
|---|---|
| The content is wrong, for example a typo in a command or a renamed field | The content repo |
| The test is wrong, for example a stale assertion or a missing wait | This repo |
| The product's behavior changed (the daily run installs the latest dev build) | The content repo if the change is intended, the product repo if it is a bug |
| The environment was flaky, for example a slow load balancer or a rate-limited registry | Usually a re-run; a wait or retry here if it repeats |
Compare what changed since the last passing run: commits to the page in the content
repo, commits to its annotation or tests.yaml entry here, and the product version.
Whichever one moved is usually the cause. The daily fixer job sorts each nightly
failure into these causes and drafts a fix for the first two (see
Fixing a failing doc test).
A person or the daily fixer job follows the same steps. The fixer is an agent that
runs after the refresh, does this for one product, and opens a pull request here.
Nothing it writes reaches main until someone reviews and merges that pull request.
-
List the work.
python3 scripts/annotations.py pending --annotations products/<product>/annotations
-
Open the page with
annotations.py annotate, ormake annotate PAGE=<page>. The copy inout/annotate/carries the markup that still attaches. The pieces that do not attach are in the annotation file, undertagsorhidden, withstatus: needs-update. -
Find out what changed. Read the page's history in the content repo, for example
git log -p -- <page>, to see what happened to the block or sentence the markup followed. -
Decide, for each piece:
What happened to the step What to do Still on the page, rewritten or moved Put the tag on the new block, or the hidden check after the new block or sentence. Keep the check's contents as they are, unless a value that the page uses changed, such as a resource name, key, port, or path. Then change only that value. Gone from the page Remove the markup. Record it as removed, with the reason. You cannot tell Leave it. Record it as unresolved, with the reason. -
Save with
annotations.py save(ormake save).saverefuses a copy whose prose or code differs from the page. It also keeps the old needs-update entries, so delete the ones you replaced from the annotation file. -
Confirm it attaches.
python3 scripts/annotations.py attach --repo <content repo> \ --annotations products/<product>/annotations --page <page> \ --out out/attached --report out/attach-report.json
The page must report no needs update, apart from the pieces you recorded as unresolved.
Rules:
- Do not copy test markup from the page. Some content pages still carry inline markup from before the move. Annotations here are the only source of truth, and an inline copy can undo a fix that was made here. Work from the annotation file and the page's content.
- Re-anchor; do not rewrite. A check that worked before the page changed is still right, even when the page's inline copy of it differs. If the check needs more than the page's new values, record it as unresolved, and a person rewrites it.
- Change annotation files only. A fix that needs a
tests.yamlchange, a new scenario, or a content change is unresolved. Say what it needs in the reason. - For a product whose pages are not public, previews stay off, and a check never repeats the page's prose. This repository is public.
scripts/annotation_fix_check.py holds the fixer to these rules. It compares the
annotation files to the commit the agent started from and re-attaches every page. It
fails when any of the following is true:
- Markup still does not attach, or has disappeared, and is not recorded.
- Anything outside the product's annotations changed.
- A check's contents changed by more than small edits (less than 85% alike, by character), or a check is new.
- With
--private, a check repeats page text.
The fixer records its work in a JSON summary with fixed, unresolved, and removed
lists. The comment at the top of the script describes the format.
A rewrite can change which test type fits a page. The stale test still passes, so
nothing else notices. scripts/doc_test_type_check.py compares each scenario's
type: with the page it tests, looking only at the page itself, not its needs:.
| Finding | Severity | What --apply does |
|---|---|---|
schema-empty: schema, but the page shows no resource or config file. The check passes only on its prerequisites' resources. |
Error | Drops schema. A scenario left with no type moves to skip:. |
functional-empty: functional, but the page has no visible yaml or shell block left |
Error | Drops functional, keeping or adding schema if the page shows resources |
skip-checkable: under skip:, but the page shows resources |
Error | Adds a <page>-schema scenario and removes the skip: entry |
schema-unreadable: schema, but no resource on the page parses as YAML, often \$ in a heredoc |
Warning | Nothing |
could-be-functional: schema only or skip:, but the page has commands to run |
Warning | Nothing. A functional test needs prerequisites and assertions, so a person writes it. |
A finding only counts if it holds in every version root the page is in, so a
main-only rewrite waits for the release. Run the check after annotations.py attach:
python3 docs-tests/scripts/doc_test_type_check.py --repo-root . \
--product agentgateway --config-schema agentgateway-main/schema/config.json \
--summary "$GITHUB_STEP_SUMMARY"For a site made of {{< rebase >}} shells, such as agentgateway-enterprise, pass the
same --upstream-root, --upstream-versions, and --condition values as the schema
check. Only the versions in the upstream map are checked. Pass the config schema the
site's own schema check uses, here the enterprise config.json. With the open source
one, enterprise-only keys make whole config files look like fragments.
Errors listed in products/<product>/type-check-baseline.yaml are reported as
baselined and don't fail the run. Use the baseline for a finding --apply would get
wrong. For example, install/argocd.md shows an Argo CD Application, which the schema
check has no schema for. A run logs baseline entries that no longer match anything.
Remove those entries, or regenerate the file with --write-baseline.
--apply edits tests.yaml line by line, so comments stay. A comment right above a
removed entry goes with it. The script re-parses the result and compares it with the
intended change before it writes anything. Before you merge, run the schema check on
the edited manifests: a new skip-checkable scenario can still fail on a kind that
has no schema.
The same daily fixer job also reads the last nightly run's failures and drafts fixes
for them. A person can follow the same steps. Nothing it writes is published until
the failing scenario passes on a cluster with the fix applied, and nothing reaches any
main branch until someone reviews and merges the pull request it opens.
-
List the work.
scripts/collect_failures.pyturns a nightly run's results into a short list. Each entry has the scenario (key,page,test), the step that broke (step: its kind, page, line and command), and the last part of its output. When the page is a shell that rebases another site's page,upstream_pageandstep.upstream_filename that page. A content fix goes there, not in the shell. -
Read the failure, then the pages. Start from the step's command and output. Open the step's page and the scenario's page, and follow each
{{< reuse "..." >}}to the file it pulls in. The page you edit is usually a reused file underassets/, not the page the result names. Read the page's annotation here too (products/<product>/annotations/<page>.yaml): a hidden check that broke lives there, not in the page. -
Decide what kind of failure it is.
Kind What it looks like What to do contentThe page tells the reader to do something that does not work: a wrong name, field, flag, path, or value. Fix the page or the file it reuses. testThe page is right, but the test is not: a stale assertion, a missing wait, a hidden check with the wrong name. Fix the annotation here. environmentSomething outside the page and the test: a timeout pulling an image, a rate limit, a missing secret, a cluster that never came up. Report it. productThe page and the test agree, and the product does something else. Report it. A person decides whether the page or the product changes. unknownYou cannot tell from what you have. Report it, saying what you would need. A step that broke because an earlier step of the same scenario did not do its job is that earlier step's failure. Fix the earlier one.
-
Fix it the way a writer would. Change as little as you can.
- Where the site already has a snippet for a value that differs between sites
(a GatewayClass, a namespace, a deployment name), use it instead of the
literal:
{{< reuse "agw-docs/snippets/<name>.md" >}}. - Where it has none and the value differs between a site and a site that rebases
it, add one in both: the upstream snippet with the upstream value, and a file
at the same path in the consumer's
assets/with its value. The consumer's copy shadows the upstream one in its build and in its tests. - A check here that must work on more than one site reuses the same snippet, or
reads the value from the cluster, rather than naming one site's value.
{{< reuse "..." >}}inside a hidden check is expanded at test time, with each site's own copy of the snippet. - Editing a page moves its markup's anchors. A tag follows its block's text,
and a hidden check follows the line or block before it, so changing that text
can leave markup that no longer attaches. After every page edit, run the
attachcommand from step 6 of Fixing markup that needs update on the page. Re-anchor anything that no longer attaches withannotateandsave(steps 2 and 5 there), keeping its contents. The check refuses markup you left unattached.
- Where the site already has a snippet for a value that differs between sites
(a GatewayClass, a namespace, a deployment name), use it instead of the
literal:
-
Never put test markup in a content page. No
paths=tags, no{{< doc-test >}}blocks, no front mattertest:key. Test markup lives here only. Some pages still carry inline markup from before the move; the run removes it before it tests, so editing it changes nothing. -
Keep the private side out of public repos. A consumer's pages, its product name, and its values never appear in a public repo's files or in the text that goes with them, apart from the consumer's own annotations here, which follow the private rules in Fixing markup that needs update.
-
Record each failure in the summary's
failureslist, one row each:{"key": "<key from the list>", "kind": "content", "fix": true, "reason": "why it failed and what you changed", "public_reason": "the same, in words fit for the public repo's pull request", "files": ["<every file you changed for it, workspace-relative>"]}public_reasonis needed whenever the row changes a public content repo. It is the only text of the row that reaches that repo.
scripts/failure_fix_check.py holds the fixer to these rules before anything runs on
a cluster. It fails when any of the following is true:
- A failure has no row, or a row has no kind or reason.
- An
environment,product, orunknownrow changes a file. - A file changed that no row lists, or that is outside the paths a fix may change:
content/docs/andassets/upstream,assets/<product>-docs/in a consumer, and annotation files here.tests.yamlis not among them. - An annotation changed for a failure belongs to a page that the failure's scenario does not run.
- A content page gains test markup.
- A line added to a public content repo, or a
public_reason, names the private side. - An added line reads a secret or the environment, calls
gh, or names a web host that its repo does not already name. These lines run on a cluster before anyone reviews them.
A check edited to fix a failure may change more than a re-anchor would, so
annotation_fix_check.py --exempt-pages does not hold it to the small-edit rule. The
cluster run holds it instead: the workflow re-runs every failure with a fix, and drops
the fix of any that still fails.
A re-run often fails later in the scenario than the nightly did, because the fix got past the step that broke first. So when the re-run of a fix fails, the fixer tries once more, for that failure only:
- Every checkout already holds the first attempt's changes.
- The failure's entry in the list carries
previous_attempt: the kind and reason the first attempt gave, the files it changed, and the step and output where the re-run failed (rerun_step,rerun_output). For a fix that broke the open source site's own test of the same page,broke_open_sourcenames that test. - Read the re-run's failure as a new one, and decide its kind again (step 3). Fix it
on top of the first attempt only when it is
contentortest. Otherwise record it with"fix": false, and the first attempt's changes are taken back with it. - The row's
reasonandpublic_reasondescribe the whole fix, both attempts.fileslists only what this attempt changed; the workflow adds the first attempt's.
The second attempt is re-run the same way, and a fix that still fails is not published.
Doc pages with passing tests display a "Verified" badge below the page title.
The following diagram shows how a test result in this repo becomes a badge on a published page. It uses kgateway's site repo (kgateway-dev/kgateway.dev) and Cloudflare Pages as the example. agentgateway follows the same steps with its own site repo and host.
flowchart LR
subgraph dt["docs-tests repo"]
direction TB
ann["products/PRODUCT/annotations/<br/>and tests.yaml"]
refresh["Refresh test annotations workflow<br/>daily 05:00 UTC"]
refresh --> ann
end
subgraph site["Site repo, GitHub Actions"]
direction TB
run["Doc tests workflow on main<br/>daily 06:30 UTC, or started by hand"]
attach["annotations.py attach<br/>adds test markup to a copy of the pages"]
kind["doc_test_run.py builds a script<br/>and runs it in a fresh kind cluster"]
artifact[("doc-test-results artifact<br/>test-results.yaml")]
run --> attach --> kind --> artifact
end
dt -->|"cloned at test time"| site
subgraph build["Site build, Cloudflare Pages"]
direction TB
make["make fetch-test-artifacts-build"]
fetch["doc_test_fetch_artifacts.sh<br/>finds the latest scheduled or manual run on main"]
inject["doc_test_inject_status.py<br/>sets test_status in each tested page's front matter"]
hugo["hugo --gc --minify"]
make --> fetch --> inject --> hugo
end
site -->|"download needs GITHUB_TOKEN"| build
build --> page["Published page<br/>docs-theme-extras shows the Verified badge<br/>when test_status is passed"]
A few things the diagram implies:
- Only runs on
maincount. The fetch script ignorespull_requestruns, so the badge appears only after a scheduled or manual run onmainhas finished. - A build reads results only when it runs. A new passing test run does not change pages that are already published. The badge updates on the next site build.
- The badge never fails the build. If
GITHUB_TOKENis missing, or no run or artifact is found, the build goes ahead without badges and prints a warning.
The following diagram shows what runs for a pull request in the site repo and for one in this repo. It uses the same kgateway example.
flowchart LR
subgraph sitepr["PR in the site repo"]
direction TB
edit["Change a tested page<br/>or the Doc tests workflow"]
prtest["Doc tests workflow runs on the PR<br/>with docs-tests main"]
check["Pass or fail check on the PR<br/>results in the job summary"]
edit --> prtest --> check
end
subgraph preview["Preview build, Cloudflare Pages"]
direction TB
pfetch["doc_test_fetch_artifacts.sh<br/>skips PR runs"]
pbadge["Badges show the results of the<br/>latest scheduled or manual run on main"]
pfetch --> pbadge
end
sitepr -->|"push to the PR branch"| preview
subgraph dtpr["PR in docs-tests"]
direction TB
dedit["Change annotations, tests.yaml,<br/>or scripts"]
unit["Tests workflow<br/>Python unit tests only"]
dedit --> unit
end
subgraph manual["Optional, in the site repo"]
direction TB
dispatch["Start Doc tests by hand on main<br/>with docs_tests_ref set to the PR branch"]
counts["Results saved as doc-test-results-trial,<br/>so they never reach the badge"]
dispatch --> counts
end
dtpr -->|"to try the change on real pages"| manual
A few things the diagram implies:
- The test run on a site PR never sets the badge. It only adds a pass or fail check to the PR. The preview for the PR still shows the badges from
main. - Doc tests run on a site PR only when it touches a tested page. The workflow's
paths:filter lists those pages. Widen the filter when a new scenario is added here. - A docs-tests PR does not run any doc tests. To try an annotation change on real pages, start the site's Doc tests workflow by hand with
docs_tests_refset to the PR branch. That run uploads its results asdoc-test-results-trialinstead ofdoc-test-results, so they never reach the badge.
- Test results are written to
out/tests/generated/test-results.yamlafter tests run. doc_test_inject_status.pyreads the results and adds atest_statusfield to each tested document's front matter:test_status: passed— all tests for the page passedtest_status: failed— one or more tests failed (no badge displayed)test_status: partial— no tests failed, but a scenario was skipped because its test annotations did not attach (no badge displayed)
- docs-theme-extras renders a green "Verified" badge under the title of any page with
test_status: passed. The badge is built into the module's docs page layout from v0.3.18-beta.2 on, so a consuming site needs no layout file or CSS for it, only the inject step. See Doc-test badge for details, including what to delete from a site that had its own copy of the badge.
| Target | Description |
|---|---|
make deps |
Install Python dependencies (PyYAML) |
make test-generate |
Generate doc test scripts without running them |
make test-run |
Run all doc tests |
make test-artifacts-fetch |
Fetch test artifacts from the latest main branch workflow run |
make test-status |
Inject test status into markdown files |
make fetch-test-artifacts-build |
Fetch artifacts, inject status, and build Hugo site |
make fetch-test-artifacts-serve |
Fetch artifacts, inject status, and serve Hugo site locally |
make test-run-build |
Run tests, inject status, and build Hugo site |
make test-run-serve |
Run tests, inject status, and serve Hugo site locally |
To preview the "Verified" badges locally:
# Option 1: Fetch results from CI and serve
make fetch-test-artifacts-serve
# Option 2: Run tests locally and serve
make test-run-serveTo manually inject test status after running tests:
make test-statusThis updates the markdown files in content/docs/ with the test status. The badge will appear when you run Hugo.
The test-artifacts-fetch target downloads test results from the most recent completed workflow run on the main branch. This requires a GITHUB_TOKEN environment variable with actions:read scope:
export GITHUB_TOKEN=<your-token>
make test-artifacts-fetch| Flag | Default | Description |
|---|---|---|
--repo-root |
. |
Repository root directory |
--results-file |
out/tests/generated/test-results.yaml |
Path to test results file |
--dry-run |
false | Preview changes without modifying files |
--quiet |
false | Suppress verbose output |
If you run into issues with installing yamltest, include the --force flag.
On macOS, you might need to run either the python3 scripts/doc_test_run.py command with sudo, or run sudo cloud-provider-kind --gateway-channel=disabled in a separate tab before running the tests. In macOS, the cloud-provider-kind tool to get a LoadBalancer IP requires elevated permissions.
File paths differ between latest and main
When copying a test chain from main to latest (or vice versa), update every file: path in the front matter. A main chain references files under content/docs/kubernetes/main/, while latest uses content/docs/kubernetes/latest/. Using the wrong version directory causes the extractor to pull blocks from a different version's content, or fail silently if the file doesn't exist.
Wrong prerequisite file paths
The install prerequisite should point to content/docs/kubernetes/<version>/quickstart/install.md, not content/docs/kubernetes/<version>/install/helm.md or similar. Check the "Before you begin" section of the guide you're testing and follow the links to confirm the exact paths rather than guessing from memory.
Test fails immediately with "kubectl port-forward" error
Tests that contain kubectl port-forward in the generated script are automatically failed without running. Port-forwarding requires a persistent background process that doesn't work in the automated test environment. Replace any port-forward-based verification with a YAMLTest HTTP assertion using ${INGRESS_GW_ADDRESS} instead.
Wait assertions pass but HTTP test hangs then fails with read ECONNRESET
When a test creates a new HTTPRoute with a hostname that was not previously registered, agentgateway-proxy (Rust/hyper) goes through two phases before it can serve the new route. Accepted=True and ResolvedRefs=True on the HTTPRoute only reflect control plane state (~50ms) — they do not guarantee the data plane is ready.
- Phase 1 (~120s): Proxy is unaware of the new hostname; every connection is immediately reset (< 1ms).
curl --max-time 5iterations cost ~2s each (instant failure + 2s sleep). - Phase 2 (last few seconds): Proxy receives xDS update and holds connections while applying it (4–26s per connection), then resets them.
Adding retries: 3 alone (without a warmup loop) multiplies the Phase 2 hang — observed total: 4 × 107s ≈ 429s.
Fix: use both a curl warmup loop (covers Phase 1) and retries: 1 on the first HTTP test entry (covers Phase 2):
{{< doc-test paths="<scenario-name>" >}}
for i in $(seq 1 60); do
curl -s --max-time 5 -o /dev/null "http://${INGRESS_GW_ADDRESS}:80/get" -H "host: <new-hostname>" && break
sleep 2
done
{{< /doc-test >}}
Then on the first YAMLTest HTTP assertion entry, add retries: 1. Once curl gets any HTTP response (even 404), Phase 1 is over. retries: 1 absorbs Phase 2. Max poll window: ~420 seconds.
This only applies when the feature page creates an HTTPRoute with a new hostname not in the prereq chain. Tests that update an existing prereq-chain HTTPRoute (same name/namespace) are not affected.
read ECONNRESET persists beyond the warmup window (cloud-provider-kind LB failure)
If the test already has the warmup loop + retries: 1 pattern but the HTTP assertion still fails with read ECONNRESET after 200+ seconds (the warmup curl loop never breaks out), the issue is likely a cloud-provider-kind LoadBalancer networking failure — not a data-plane warmup problem.
How to distinguish from the data-plane warmup issue: Check the controller logs in the diagnostic artifacts at out/tests/generated/context/<scenario>/pods/<controller-pod>-controller-logs.log. Look for XDS: Pushing entries with clients:1 and RDS push responses for your routes:
{"msg":"XDS: Pushing","component":"krtxds","clients":1,"version":"..."}
{"msg":"push response","component":"krtxds","type":"RDS","resources":1,...}
If the controller pushed routes to the proxy successfully, the proxy IS configured — the problem is at the network level between the LB IP and the pod.
Root cause: cloud-provider-kind assigns a LoadBalancer IP to the Service, but traffic from that IP is not properly forwarded to the pod through the Kind Docker network. The LB IP is reachable (TCP connect succeeds) but the proxy resets the connection because it never receives the forwarded packets.
Typical diagnostic evidence:
- All pods Running with 0 restarts
- LB IP assigned (e.g.
172.18.0.x) and Service shows80:<nodePort>/TCP - HTTPRoutes show
Accepted=True - Controller logs show successful xDS pushes with
clients:1 - Proxy log shows
started bind bind="80/agentgateway-system/agentgateway-proxy" - curl gets
ECONNRESETfor the entire test duration (200+ seconds)
Resolution: This is a transient infrastructure issue. Re-running the test typically resolves it. On macOS, ensure cloud-provider-kind has proper permissions (sudo). If it recurs frequently, check Docker resource allocation (memory/CPU) for the Kind cluster.
/expect: unknown property "bodyJsonPath" errors
This error almost always means the expect block has bad indentation. bodyJsonPath must be a direct child of expect:, not nested under statusCode or headers. Double-check that all keys under expect: are at the same indentation level:
expect:
statusCode: 200
bodyJsonPath:
- path: "$.choices[0].message.content"
comparator: contains
value: "hello"