Skip to content

Testing strategy

Principle

Test the lowest stable boundary that proves the behavior:

  • package behavior in colocated Go tests;
  • protocol wiring with smoke tests;
  • cross-component behavior with deterministic end-to-end scenarios.

The oracle is state and structured output, never prose generated by a model. LLM/agent behavior is evaluated during real usage rather than in CI because it is non-deterministic, costly and dependent on external providers.

Levels

Go tests

make test runs go test -timeout 20m ./... (internal/mcpserver alone takes about 10 minutes on the Windows runner, the default timeout). Tests live beside their packages and cover parsing, KB invariants, tools, authorization, git behavior, provisioning and client configuration.

make vet runs go vet ./... twice, natively and with GOOS=windows (the test-windows job is skipped on feature PRs outside platform-specific code, so a test that does not compile on Windows would otherwise reach main), and make fmt-check fails on anything not gofmt-clean. make race runs, under the race detector, the tests whose name contains Race or Concurrent: a data race seldom fails an assertion, so a test that drives readers and writers concurrently only proves something under -race, and the whole suite under -race is several times slower. A test written to exercise concurrency is named accordingly, or it never runs with the detector. fmt-check, vet, test and race are what make gate runs, the single command CI runs.

Three tests in internal/mcpserver guard what a tool result costs (D1185). TestToolResultGolden drives one call of every JSON-returning tool over a deterministic KB and checks that each answer is json.Compact of the golden in testdata/results byte for byte and decodes to an equal value: the goldens are the answers of the indenting code, so compaction provably changed nothing but whitespace; commit SHAs, timestamps and the KB root are normalised on both sides, and -update rewrites them only for an intended change of content. TestNoIndentedMarshalInToolResults fails when a non-test file calls MarshalIndent instead of jsonResult. TestSearchDefaultAnswerBudget runs 14 default-argument queries over a generated 41-concept KB and fails when the average search answer exceeds 5 KB (limit 20 and a related block of 5, the v0.26 default D1354 restored; the fixture measures 4.6 KB); the budget is the measured average plus a margin, and moves only when the default does, with a decision (D1354 superseded the "do not raise the number" rule of D1185). TestSearchDefaultLimit pins the 20 hits, and the related tests the block of 5. TestGuidanceDoesNotPushSectionReads (internal/provisioning) keeps the generated guide and the bundled skills from sending an agent from a search hit to a section, or from telling it to read runbooks, procedures and incidents by section.

TestFixedCallCostBudget (internal/mcpserver) guards what an agent pays on every model call: the managed instructions block plus the tools/list, for 1 and 9 KBs on a routed mount, in the agent, access=read and access=read-min profiles, one byte budget per component and cell (docs/control-plane.md §Tool descriptions and their size budget); if it fails, a component grew: shrink it, do not raise the number without a decision.

make gate runs on ubuntu in the test job; the tests also run on Windows (ci.yml). There the git-backed packages are about 13 times slower (process creation), so the Windows leg is split across windows-latest runners, one per shard (D785): scripts/test-shard.sh COUNT INDEX deals every package's top-level tests round-robin, so each shard runs a slice of every slow package and together they run each test once. It skips internal/repodocs (documents, not platform behaviour) and leaves fmt-check and vet to test, which vets with GOOS=windows too. Another runner runs install.ps1. The shards turn off Defender's real-time scanning (about a fifth of their time) and use bash, the scripts being POSIX shell. Required checks match job names literally and a matrix renames its legs, so a small job named test-windows collects the legs and is what branch protection requires; web does the same for its two legs — the @video-tagged browser test (about two minutes of software rendering) with the unit tests and bundle checks, and the rest of the browser suite. make e2e-web E2E_WEB_ARGS=... passes the filter to Playwright. The Windows legs run on every push to main, on the release-please PR, and on a feature PR that touches a package with platform-specific code — cmd/, internal/{service,provisioning,configurator,clientconfig,mcpbridge,agents,execbit,gitx,kb,repoindex,defaults}, the installers, any *_windows.go (D672). A regression elsewhere still shows on main right after the merge. The web legs run after every push to main and on a PR that touches the frontend, test/lib/, the build files, or a non-test file of a package bin/cartographer imports. internal/{changelog,gitx/gitxtest,kbtest,repodocs,testkbs,testtree,webui/gen,worker/workertest} and every _test.go cannot change what the browser sees, so they do not trigger it (D672, D255, D994); the Playwright browser is cached by version. A demo-edge job runs the demo Worker's tests (make demo-worker-test) and, for Terraform changes, make demo-tf-check when deploy/demo/ changes. A changes job decides all of these with scripts/ci-filters.sh (a table pinned by internal/repodocs/ci_filters_test.go, which also compares the excluded packages with go list -deps), failing open, and the release-please PR always runs everything. test, test-windows, web and pr-title (its own workflow, pr-title.yml, which re-runs when a PR title or body is edited) and plan-gate (plan-gate.yml, the approved-plan and size rules, D955; the docs-owner WARN it prints is not enforced) are required checks; a job skipped by its path condition reports as passed. On main every push gets its own run (the concurrency group is the commit), and the main-status job opens a ci-red issue on the first failing push, comments on later ones, and closes it on the next green push. docs/decisions.md's generated index is checked only on the release PR, which regenerates it. The working tree is LF on both legs (.gitattributes): gofmt is line-ending-sensitive, so a CRLF checkout fails fmt-check on every file at once.

The platform-specific code is the per-KB lock and the process-liveness check (internal/kb/lockfile_unix.go, internal/kb/lockfile_windows.go), the client-state lock beside it (internal/provisioning/clientlock_*.go), and the native service's shutdown event (internal/service/shutdownevent_*.go, cmd/cartographer/shutdownevent_*.go); the tests that assert their contract — the holder stays nameable, a dead pid is reclaimed, a non-contention error fails fast, a second acquirer is refused — run on both legs. So does the rest of the suite: the Windows leg is the whole of make gate, not a subset.

internal/service covers three platforms from whichever host runs the suite, because goos is a package var: the launchd, systemd and Scheduled Task branches are all asserted on their exact command sequences, and the Windows-only calls that cannot run from a POSIX host sit behind two package-level seams (setShutdownEvent, and lstat in internal/provisioning). Two rules there are load-bearing rather than stylistic. A test must stub getenv as well as userHomeDir: the Windows paths derive from %APPDATA%/%LOCALAPPDATA%, so a test that stubs only the home directory writes task definitions into the test-windows runner's real user profile — withTestHome neutralises both. And the recorded commands are asserted, not merely the absence of an error: no test stubs os.Getuid, which returns -1 on Windows, so a branch that built gui/-1/com.cartographer.serve would otherwise pass. The launchd branch is the one that is also run for real, on a macOS runner (§Real macOS, D997). What the gate cannot check is the schema validity of the two generated task XML documents — nothing registers a task, since make gate stays side-effect-free — so their element order follows Task Scheduler's own export, and the first real cartographer service install on Windows is the check.

A test that asserts on a POSIX file mode is written execbit.Supported && ..., because the Windows filesystem has no execute bit (D219). A skip on that leg states what it is about — there is no bit to set or remove, so a chmod changes nothing and a file that is readable but not executable cannot exist — and a skip whose reason is a platform name is not accepted.

A test that needs a blank KB takes it from kbtest.New(t) (inside internal/kb, from newTestKB), never from its own kb.Init: the suite is dominated by the git processes it spawns, and Init alone runs about a dozen, so a template built once per test binary and copied into a temporary directory of the test (through internal/testtree) is what keeps the suite fast (D812). kb.Init stays in the tests whose subject is what Init itself does (skeleton, git attributes and initial commit, identity, default branch, existing or unborn repositories) and in those where the directory name is the subject; kbtest.New always names the directory kb. Test-support packages are never imported by production code (internal/repodocs fails if one is). To see where the processes go, put a git wrapper that appends a line to a log and then execs the real binary first on PATH and count the lines after a run; run it with GIT_CONFIG_COUNT=0 when the environment injects safe.bareRepository, which breaks the bare-repository tests. On macOS /usr/bin/git is an xcrun shim that costs roughly twice a real binary per call, so a suite that looks slow there is mostly paying for process creation, not for the code under test.

Provisioning signature coverage includes deterministic Ed25519 envelopes, strict key parsing and identity separation, plus remote sync_pull verification and tampering rejection before Apply.

Git write coverage (D903) uses local bare remotes: a KB with a remote syncs and one without never contacts anything, every write commits, the branch named by branch counts only when committed on the remote's default branch, and a push the remote refuses because the branch is protected is reported as such and not retried. The forge's push rules are one portable pre-receive hook, test/forge/pre-receive.sh (D996), shared by the Go tests and the e2e scenarios and configured by files next to it in the bare repository's hooks/: allowed-identities (one email glob per line, GitLab's member check on the author and the committer of every new commit, a merge or a new branch included) and protected-branch (a glob, with the GH006 wording). It is POSIX sh and LF, so it runs under git's bundled sh on the Windows shards too. The writer path is tested against it: a write that names its author (Cartographer-Author) reaches a remote that checks membership, one that does not is refused with the hook's message and stays committed locally, a KB with git.identity publishes, and a commit made with no writer is re-committed by that identity (gitwrap_author_test.go, memberrule_test.go). A membership refusal is an identity error and not a protected branch (internal/kb/identityreject_test.go), the neutral rendering a generic connection reads (TestErrIdentityRejected_NeutralText, D1101), and kb reauthor (D1063) is covered by unit tests of the rewrite (internal/gitx/reauthor_test.go), by the command's preview, apply and refusals against this hook (cmd/cartographer/kbreauthor_test.go) and by e2e scenario 21 section c. Authentication is tested against the real git http-backend The text a generic MCP connection receives is guarded by internal/mcpserver/operatortext_test.go (D1101): no cartographer <subcommand> in the tool descriptions, the routed refusals, the hints, the conflict and identity messages, with a provisioned twin that pins the unchanged texts. behind basic auth, in process (internal/gitx/gitxtest): clone, fetch and push with the token helper, a wrong token (the "credentials were rejected" remedy), no token (terminal prompts disabled, never a hang), SyncOut/SyncIn of a KB and a hook's rejection reaching the caller through HTTP. Every package whose tests touch git calls gitxtest.QuietGit() in its TestMain (a repodocs test enforces it): it switches off git's auto-gc and maintenance, whose detached run would otherwise race t.TempDir()'s cleanup. A test that waits on git or the server with a wall-clock deadline (time.After, a polling loop, a client timeout) wraps it in gitxtest.Deadline(d), which is d everywhere but 4×d on Windows, where one commit takes 1-2 s under load (#1048); never raise a single number after a red run, and never scale a deadline that is the thing under test. smoke-http and e2e run with no git identity on the host (GIT_CONFIG_GLOBAL points at a file holding only init.defaultBranch): a scenario that commits by hand passes its identity with -c; scenario 21 serves a KB whose remote enforces membership. Realignment of the settings files (server YAML, client file, a KB's settings.yaml) is tested for what it drops, what it resets, what it keeps, its report, its backup (or, for a KB, its commit) and that a second run changes nothing. An ordinary CI run never calls a forge; the real ones are below.

Real forges (D996)

test/forge/run.sh forgejo|gitlab starts a pinned container (Forgejo, GitLab CE), creates a user, a project and an access token through the REST API, then drives the binary against it: kb create over HTTPS with the token, a write with and one without Cartographer-Author, a second clone publishing and the next write pulling it in, a protected branch (reported as such, the remote unmoved), and on Forgejo SSH with the KB's own <kb>.ssh. Everything is asserted through the forge's API. GitLab's member-check push rule is a Premium feature: where the edition answers that it is not there the script says so and the rule stays covered by the hook tests above. It runs in the forge CI job only on the release PR, on a PR that touches test/forge/, and on workflow_dispatch (gh workflow run ci.yml --ref <branch>), never on main and never as a required check. By hand, from the repository root, with Docker: make build && test/forge/run.sh forgejo (FORGE_KEEP=1 leaves the container up; FORGE_PORT, FORGE_SSH_PORT and FORGE_SERVER_PORT move the ports).

Client upgrade from the previous release (#1173)

test/e2e/optional/26_upgrade_from_previous.sh starts from the state a previous release leaves on a client, which no scenario of make e2e does: each connects a fresh sandbox with the binary under test. It builds the newest stable tag reachable from HEAD (vX.Y.Z, no pre-release suffix, the tag at HEAD excluded) from a throwaway git worktree; a release candidate (vX.Y.Z-rc.<date>, D1192) is skipped by its tag as the installers skip it, and the scenario checks that rule on a throwaway repository first, runs it as the claude client of a HEAD server whose KB serves a skill, a hook, an MCP descriptor, a block: full instructions.md and a {{path:…}} key, approves what it waits for as a user does, then switches the client to HEAD and syncs twice. It asserts that no approval is asked again, that the D1166 migration lines appear only when the old tag predates it, that no file is left under a name HEAD's lockfile no longer lists (the oracle is the two lockfiles, not a list of renamed skills), that every local path a written file cites exists, that the first sync turns the block brief once (D1182, when the old tag predates it), that the second sync writes nothing, leaves every file byte-identical and status exits 0, and that a revoked hook is not approved again. The lockfile is compared with its entries sorted: a sync that rewrote an artifact leaves the synthetic hooks' entries to move on the next one, an order-only rewrite (#1235).

It is outside make e2e (in test/e2e/optional/, which the default loop skips): building the old tree costs more than the whole suite. make e2e-upgrade runs it (./test/e2e/run.sh --only 26_upgrade_from_previous); the upgrade-e2e CI job runs it on the release PR and on workflow_dispatch, never on main and not as a required check. E2E_UPGRADE_FROM=<ref> picks another starting point; E2E_UPGRADE_ONLINE=1 retries the build once through the Go proxy when the module cache lacks a module of the old tag (the job sets it: its cache is keyed on HEAD's go.sum). No stable tag reachable (a shallow clone) is an error naming git fetch --tags --unshallow, never a skip. Locally the scenario takes about 10 s with a warm module cache and under a minute when the old tag's modules are downloaded. From v0.26.0 on the previous release already has the approval gate, so the default run proves that an approval survives the upgrade, not the one-time migration of D1166: exercise that with E2E_UPGRADE_FROM=v0.25.1 E2E_UPGRADE_ONLINE=1 make e2e-upgrade, which expects one approved hook/<name> [kb:kb]: installed before approvals existed, unchanged line.

Real macOS (D997)

The test-macos CI job (macos-latest) runs the scripts under test/macos/ (service.sh, install.sh, cask.sh; shared helpers in lib.sh, bash 3.2 as macOS ships it), against two real binaries built with -X main.version=v9.9.8 and v9.9.9. service.sh installs the service under the real launchd (plutil -lint, launchctl print state, /health ready, restart --wait with a new pid, stop (nothing serves) then start, uninstall then status exit 4, the sync timer plist). install.sh runs install.sh install and update (the fake curl of test/install/lib/, no network) over a real running service and asserts /health reports the new version after upgrade-repair, that a checksum mismatch leaves the old binary and the same pid, and that uninstall refuses while the units exist. cask.sh installs the Cask a goreleaser release --snapshot --skip=publish renders from a local tap (brew tap-new local/cart-test, only the url rewritten to the snapshot binary), and checks the version, the absent quarantine attribute (the postflight_steps), the caveats and the uninstall. The scripts register a real job under the account's own $HOME (the home guard, internal/service/homeguard.go, refuses a sandbox one) so they refuse to run outside CI unless CARTOGRAPHER_MACOS_E2E=1: never on a Mac that runs its own Cartographer service, which carries the same label. The job runs on the release PR, on a PR touching internal/service/, install.sh, .goreleaser.yaml, test/install/, test/macos/ or the service commands, on main and on workflow_dispatch; it is not a required check, main-status watches it.

CLI JSON tests decode stdout as JSON and assert that diagnostics remain on stderr. Golden help is limited to 80 columns. Bubble Tea view/update tests use 60, 80 and 120-column window messages, strip ANSI sequences before measuring line width, and cover healthy, unavailable, drift, connect retry, sync-all and disconnect confirmation states.

Documentation gates

internal/repodocs holds most of the deterministic checks on the repository's own documentation and agent-facing layout, and they are ordinary Go tests so make test runs them (D204). They exist because each of these failures is otherwise silent — no client, compiler or linter reports any of them:

  • the AGENTS.md budgets: 120 lines of hand-written text, 12.000 characters for the whole file;
  • the generated blocks are up to date — the decision index in docs/decisions.md and the code map in AGENTS.md. The generator is this test run with -update, which is all make decisions-index and make codemap do, so the writer and the checker cannot disagree about the format;
  • every decision file is well formed, carries a known topic, holds no leftover template placeholder, and claims a number no other file claims;
  • every D<n> cited anywhere in the repository resolves to a record, is a declared gap, or is reserved by an open plan (D208);
  • every docs/… path cited anywhere exists — including in Go and shell comments, which no other check reads;
  • every relative link in every tracked markdown file resolves, and none uses the retired #d<n> anchor form;
  • every <page>.md §Heading citation, in prose, comments and shipped skills (outside docs/decisions/), names a heading of that page, and every #fragment of a relative link names a heading or <a id> of its target (D1124). A cited text matches a heading when the two are equal or one is a prefix of the other ending at a word boundary; sectionRefAllow (reason mandatory, enforced both ways) holds what is not a pointer. mkdocs.yml turns on validation.links.anchors as the published-site half;
  • the structure cross-lists agree (D1124): every page of docs/ is in docs/index.md and in the mkdocs.yml nav, every make <target> and internal/, cmd/, scripts/, deploy/, test/ path cited exists (repoPathAllow for the exceptions), and in cmd/cartographer the usage table equals the dispatch switch of run() and every cartographer <command> cited names one;
  • each skill's frontmatter parses with the same spec-compliant YAML parser a client uses, and satisfies the strictest client's rules (D207), the skills being real directories in .claude/skills/;
  • each subagent in .claude/agents/ pins a Claude model, and the read-only ones (reviewers, plan writer, source reader) carry no Edit or Write tool (D992);
  • the tool table of docs/control-plane.md §MCP API agrees with the registry both ways, markers and parameters (D1125), and the tool-name scan reads the bundled skills, .claude/ and the area AGENTS.md files too;
  • the deployment.md environment-variable table lists exactly the Schema's variables, plus the reasoned few that are not Schema keys, with the flag the Schema declares; every CARTOGRAPHER_* name in a string literal of non-test Go is documented in a current-state page, removed in config.Retired, or allow-listed with a reason (internal/config/envdocs_test.go, D1126);
  • current-state pages carry no history phrase (used to, previously, removed by D<n>, as of D<n>, became) and no hand-kept count (a number before tools, kinds, providers…): the rules live in internal/repodocs/doc_rot_test.go, and so does the allow-list for text about a retired key a reader can still meet, which is pruned both ways (D1128);
  • every row of scripts/doc-owners.tsv is well formed (three fields, no repeated glob, an existing page), its glob matches a file, its page is named in docs/index.md §Documentation maintenance rules, the script's matcher and the tests' agree, and pr-triage.sh calls doc-owners.sh; the signal itself is the advisory WARN of pr-triage.sh, silenced by Docs: none - <reason> in the PR body;
  • CLAUDE.md is exactly @AGENTS.md and nothing else, and the corpus these checks read actually contains the files they exist to read — a corpus definition fails by matching nothing and passing.

Stdio smoke

make smoke builds the binary, starts a temporary stdio server and verifies the MCP initialize handshake.

It holds stdin open past the request rather than piping a bare echo: over stdio the client owns the pipe's lifetime and the server tears the session down on EOF, so closing it immediately races the response out of existence (D168).

make smoke runs in the test job after make smoke-http (D994). A test in internal/repodocs fails when a Makefile target is run by no workflow and is not in its list of local-only targets.

Image smoke

make docker-smoke builds the server image and runs test/smoke/image.sh on it (D995): git, sops, age and ssh are in the image, version prints the VERSION build-arg, worker without --config exits 2 (the third role is in the same image and needs no volume), the user is not root, a KB created on a data volume is served over HTTP with a token (no token is a 401), and a map_create plus a concept_batch write read back through concept_read and leave one more commit in the KB's repository. It runs on the runner's architecture only.

CI runs it in the docker job of ci.yml, after the Dockerfile's test stage (docker build --target test .: vet and test in a clean alpine) and before make demo-smoke DEMO_BASE=cartographer:ci, on a PR that touches the image's inputs and always on the release PR and on main. preview.yml runs the same script on the release candidate before it pushes. mcp_call is shared with the HTTP smoke through test/lib/mcp.sh. Run docker build --target test . from a clone, not from a git worktree: the worktree's .git file is copied into the image and breaks git config.

HTTP smoke

make smoke-http runs test/smoke/http.sh. It starts the real binary with two temporary KBs, calls MCP through HTTP and exercises Map creation, concept writes/expansion and Atlas overview.

Tool names are bare: since D288 a server never prefixes them. Each call asserts on isError — a top-level unknown tool comes back as a JSON-RPC -32602 error (unknown tool <name>, D1303), but the routed kb "x": tool not found (a sibling KB has the tool, this one does not) stays an isError result whose text says "tool not found"; the script must catch both, or it would print and count either as a pass.

15_operational_audit closes the loop end-to-end by tampering with a recorded entry and requiring audit verify and audit export to fail on it. It also strips every signature from a copy of the log (the chain stays valid, the signature is not hashed) and requires both commands to fail with --public-key while verify without a key still passes.

Removed topology keys are exercised as a real upgrade. 16_deprecated_prefix_keys starts a server whose config still carries mcp.mount_mode, mcp.tool_prefix_mode and an explicit kbs[].tool_prefix (D288, D325) plus a KB named like the routed endpoint. It asserts the server starts and logs no deprecation warning, tools are bare on the per-KB endpoint and listed on /mcp/routed, /health carries no per-KB tool_prefix value, and the KB named routed is skipped with a warning to rename it while its siblings are unaffected.

The routed mount is asserted on the wire, not only in Go. 17_routed_multikb starts a real three-KB server with no topology setting (routed is always on) and drives it with curl: /health must advertise mount_mode and routed_path, /mcp/routed must list each tool exactly once with a kb argument and in far fewer bytes than the per-KB mounts summed, a call must reach the KB it names, a call without kb must be refused naming the mounted KBs, and ?kb= on that URL must be 400. It asserts the per-KB plumbing still answers with no kb argument. Then the bindings (D288): connect writes one entry per provider, with ?kbs= carrying an explicit binding; a provider bound to 2 of 3 KBs sees kb required with no enum and is refused the third, one bound to a single KB sees no kb property and calls without it, and an unknown name in ?kbs= is 400. Last, the heal: the client state is put back into the old per-KB shape by hand and one sync must leave a single entry and no orphan.

The scenario covers the wiring between the config default and the mount, which no Go test of either half sees: a default the operator never configures (such as a derived kb-name) must not make the routed mode fail at startup.

The workspace scope is asserted on real repositories. 18_workspace_projection is D193's mandatory acceptance criterion, and it is a scenario that cannot pass before the scope exists: two KBs holding a skill with the same name, two git repositories, one provider bound to a different KB in each. It asserts that each workspace receives its own perimeter — comparing the bodies, not just the paths, because reaching the wrong KB also produces a readable file — that no KB artifact is left in the global catalogue while Cartographer's own transversal bundled skills stay there, that an unbound workspace receives nothing, that git status is clean and .gitignore untouched, that unbinding one workspace prunes only its files, and that one workspace bound to both KBs is still refused as a collision.

It also pins what no unit test sees: the bundled skills are not copied into every workspace, a CLAUDE.md Cartographer created itself does not leave the repository dirty, and unbinding a workspace removes its projected files even though a later sync only iterates the declared projections.

22_workspace_derived_binding asserts the other way to get a binding (D1019): a KB's paths.yaml declares two repositories, one with bind: false, and no workspace bind runs in either. sync projects into the declared one only, its git status stays clean with a tracked CLAUDE.md untouched (the block goes to the excluded CLAUDE.local.md), the bridge started there serves the KB with its write tools, and an explicit --no-kb binding on the same path replaces the derived one and prunes the projection.

The hero's branch is asserted without a network. 19_hero_publish runs web/scripts/publish-hero.sh and hero-needed.sh (D790) against a scratch repository and a bare origin: no branch means a recording is needed; after a publish the same tag needs none and a tag whose web/ tree differs does; publishing twice leaves one commit carrying the Web-Tree: trailer; a tag that changes only files outside web/ needs none.

That a search hit names no section, and the search defaults, are asserted on the wire (D1207, D1354). 25_search_section drives a real server with curl and python3: a multi-section page returns no section anywhere in the answer, a journal entry and a type listed in the map's read_whole return read: "whole", and a search without limit returns all 12 pages that match with a related block of 5.

A client with no Cartographer client side is asserted on the wire. 24_generic_mcp_client drives a real server with curl and python3 only, as Open WebUI or a gateway would, and checks what that host receives (mcp-clients.md): /health and tools/list follow the token, initialize carries the guide and kb_guide serves it, the first page of a new map is accepted and authored by the token id, a KB the token cannot read is indistinguishable from an unmounted one, no generic response carries the doctor nudge or a cartographer <subcommand>. Three points are not obvious: a token id exists only in the YAML auth.tokens form, so the scenario starts the server with E2E_AUTH=true and E2E_CONFIG; a KB with no lint debt never nudges, so a seeded stray page makes "no nudge" non-vacuous and a provisioned control connection must receive it; and the nudge window is shared by every stateless connection of a KB, so the generic calls come first and the control last.

The film's branch is asserted the same way, and its render only in CI. 23_film_publish runs film/publish.sh and film/film-needed.sh (D1068) against a scratch repository and a bare origin: no branch means a render is needed; after a publish the same revision needs none; a change to a file in each class of the film's inputs (film/inputs.sh) needs one and a change outside them (README.md, another web/src file) none; publishing twice leaves one commit with README.md, film.webp and the Film-Tree: trailer; an input missing at the revision is exit 2. TestFilmImportsStayInsideTheHashedInputs (internal/repodocs) fails when a file reached from film/ by imports (followed transitively: multi-line, side-effect, dynamic and ?raw/?url forms) is outside that list, and TestPreviewCallsTheFilmWithoutToleratingFailure pins the workflow (read-only render, publish on main only) shape (no continue-on-error, write only in publish, actions pinned). The render itself needs a browser, ffmpeg and minutes of software WebGL, so it is exercised only by film.yml: no test or pull-request check renders it, except film-check.yml: on a pull request touching film/ or the film workflows it renders 30 frames (film/render.sh --no-mp4 --frames 30, same pinned actions and packages, no cache, read-only) so the encode runs on the runner's tool versions before the release candidate does. It is not a required check.

Packaging (install.sh and the generated Cask)

make test-install runs the network-free suite under test/install/ plus test/install/goreleaser_guard.sh (which also pins that the SBOMs are published and no asset name moved), and runs in CI after make e2e. The scenarios drive install.sh against a fake curl and a stateful fake installed binary that records its invocations and returns a scripted exit code, so the D121 contract is asserted without downloading a release or touching a real launchd/systemd service: an already-current version never invokes upgrade-repair, a stopped or absent service is never started, exit 1 still leaves the binary update successful, and exit 2 (or an unexpected code) fails visibly but only after the new binary is in place.

The suite also covers the checksum path — a matching digest installs and says checksum OK, a mismatch fails without leaving a binary behind, a sha256sums.txt that exists but does not cover the asset is an error rather than a silent skip, and so is a manifest that cannot be downloaded (D1022: every release ships one, so its absence is never an old tag) — and the uninstall branches: nothing installed, a binary with no units, a unit present (refused, non-zero, nothing removed), --binary-only (proceeds and states what it left behind), and a partial state such as a timer without a service.

One scenario covers a platform the script deliberately does not support: with a stub uname reporting MINGW64_NT-… on PATH, install.sh must exit non-zero naming the install.ps1 command, without falling through to the generic "unsupported OS" and without requesting an asset or writing a binary (D252). What is asserted is that the refusal is actionable.

install.ps1 has its own suite, test/install/windows/run.ps1, run by the test-windows CI job under both PowerShell 7 and Windows PowerShell 5.1. Unlike the POSIX suite it fakes only the release server (a local python -m http.server over a fixture tree): the binaries are two real builds of the commit (v9.9.9 and v9.9.8), the zip is a real zip, and the per-user PATH is the real registry value, saved and restored. It covers a fresh install into the default directory and the PATH entry, the already-current no-op, an update over an idle binary and over one running as a server (the lock the rename-aside exists for), a wrong digest and a manifest without the asset — each leaving the previous binary untouched — and the uninstall refusal while a task definition exists, -BinaryOnly, and a clean uninstall removing the PATH entry.

A separate, non-deterministic check lives in internal/provisioning/clientcompat_test.go: it runs the client's own discovery command and asserts it recognizes the destination Cartographer declares, for the two paths that diverge from the vendor documentation (see interoperability §Known divergences). It skips when the client is not installed, and also when the client cannot answer — it errors, times out, or prints nothing — because an unrelated client problem must not turn this into a red suite that everyone learns to ignore. Only a successful run whose output does not mention the declared directory is a signal.

The Cask install steps are checked as a repository template, never as the generated file in BeppeTemp/homebrew-tap: on the YAML of .goreleaser.yaml (comments excluded) the guard asserts a postflight_steps stanza that removes quarantine from the staged path, and the absence of hooks, of the deprecated postflight do and of upgrade-repair, which cannot work inside Homebrew's sandbox (D199).

The same guard covers the Windows packaging shape: windows among the build targets, a format_overrides entry making its archive a zip — what install.ps1 downloads and extracts — no override producing a raw binary, and no winget: block, which would start opening pull requests against microsoft/winget-pkgs again (D252). internal/repodocs TestReleaseIsPublishedLast pins the publish-last order (D1350): draft and force-tag-creation in release-please-config.json, release.draft and use_existing_draft in .goreleaser.yaml, and in release.yml a publish job that needs goreleaser, prerelease-flag and docker and reads draft/prerelease back, with mcp-registry after it. Its Ruby is not executed — that would need a real Homebrew and GoReleaser environment, which is out of the deterministic gate (see below).

Frontend (make web-test, make web-check)

web/src/test/locale.test.ts scans the sources and rejects any toLocale*String call without "en" as first argument, and any Intl. or navigator.language: the UI is English in every browser locale, and a value test of a formatter would pass on an English runner even with the bug back.

To work on the UI live, make dev from a worktree runs both halves and rebuilds the server on every Go change (with DATA, the installed client reports to it through sync --report-to, D1044, and each KB copy keeps its remote's fetch URL, so the sync badge shows the real state, with an unreachable push URL, so a push fails and nothing leaves the machine) (CONTRIBUTING.md §Live sessions). Without DATA it serves a throwaway copy of the generated test KBs (internal/testkbs; D818, D1032): the 5,000-concept scale KB, the bare 30-concept KB, and the KBs that take the shape of measured real ones (Maps and journals, expanded concepts with up to a hundred satellites, pages past 90 KB, hubs with a hundred backlinks, a status mix, thousands of commits) with invented content, named after their themes (seed-bank, ferry-line, …). The shapes are a table of numbers (profiles.go), the words invented per theme (themes.go); the history is written through one git fast-import per KB. Every Atlas state is planted somewhere in the fleet, the conflicts on ferry-line. The fleet is cached per day in $TMPDIR/cartographer-testkbs, with the audit log on; internal/testkbs tests check the planted states, the bare KB, the fleet, each profile (profiles_test.go) and that no generated page holds a word outside the package's vocabulary. Beside the KBs it posts the settings report of an imaginary client (client-report.json, built by clientconfig.Save and Report) after every server start, so Settings → Client has a machine to show. By hand: run a server (cartographer serve --http :39273 --kb …) and cd web && npm run dev: Vite serves the UI with hot reload on http://localhost:5173/ui/ and proxies /api and /health (the version beside the theme switch) to the server (CARTOGRAPHER_DEV_API for another address); the Atlas follows the KB live (D337). Keep no local file under web/: every file there enters the bundle's provenance hash, so an untracked one makes make web record a hash CI cannot reproduce and web-check fails.

The static demo build (D745, deployment.md §The public static demo) has no test level of its own: the docs workflow runs demo/build.sh on every pull request that touches demo/ or web/, so a KB the server cannot serve, or an export request it refuses, fails that build. static.test.ts pins the file-name mapping the UI and demo/export.mjs must share, and the live-source decision of that build (D800): the origin's answer picks live, a timeout, an error status or a body that is not a KB list picks the snapshot, and the revision poll is 20 s only in a live session on a build with a live origin.

The live demo's edge is tested without Cloudflare: deploy/demo/worker.test.mjs (make demo-worker-test, run by the demo-edge CI job) covers every moderation rule, the id format, the argument rewrite, the routing table, CORS and cache key, that every non-approved call goes with the read token, and the webhook signature against a vector computed with openssl. make demo-smoke runs the image against a bare git remote on a Docker volume: a write with the write token lands in the remote, the read token is refused on both KBs, the routed tools/list shape follows the number of mounted KBs, and an unreachable remote leaves astronomy up.

The Atlas UI's own tests: Vitest with Testing Library, under web/. They run in the web CI job, not in make gate, so a pure-Go change needs no Node toolchain (D227). What they hold:

  • the token contract — no literal colour, duration, delay or easing outside web/src/styles/tokens.css (every transition* and animation* declaration, multi-line ones included), the JavaScript motion scale (MOTION_MS) equal to the CSS one, and every dark-theme colour has a light-theme value;
  • the growth replay's video export (D691): an offscreen scene has no frame loop, observer or input and runs on the virtual clock it is given, the encoder picks H.264/MP4 or VP9/WebM, stamps frame n at n/60 s with a keyframe every 120, opens on the logo card and encodes nothing once cancelled, and the export runs in the background on its own scene (the button is in the replay's bar, a second export is refused, cancel, error and unmount dispose the offline scene, switching KB does not cancel it) (offscreen.test.ts, record.test.ts, export.test.tsx);
  • the shell boots, and degrades to a named state rather than a blank page on a 401, an unreachable server or an empty KB;
  • the atlas starts graph-only — rail folded, no inspector until a selection — and there is no concept list beside the canvas: Ctrl/Cmd+K is the way to a concept, and the list is shown only when there is no WebGL to draw with; [[wiki-links]] in a concept body become in-atlas links, code spans left alone;
  • Markdown from a concept body renders inert — a raw <script> and an onerror attribute must not survive;
  • the bearer token reaches neither localStorage nor a URL;
  • the live refresh bumps only when the KB revision changes, stops for good on a 404 (a narrowed principal has no revision) and never polls while disabled (D337);
  • Health (D338, D365, D947): lint problems grouped by check with who acts on each (one list, no suggestions band, the doctor's review items left out), revealing a concept or saying there is none, scoped by its own Map chips so a clean Map never reads as a clean KB; the upkeep, questions and repairs, each copy action a clipboard write and every request a GET; a whole-KB 404 left out silently while another failure is reported; the old panel=observatory and panel=maintenance links landing on Health;
  • Work's columns in the order work moves and its Map cards busiest first, its Map, status, stale and text filters; Activity's day groups; the legend folding and remembering it;
  • the graph layout is deterministic for identical input, and the community colouring reads the server's communities (D244), which are deterministic themselves: the same KB state paints the same node the same colour;
  • the WCAG 2.2 AA contrast audit, computed from tokens.css on every run, for both themes (§Atlas UI contrast);
  • a complete keyboard path — shell, filters, search, inspector and back — and, at narrow widths, navigation and inspector as modal sheets that trap and return focus;
  • the 2,000-node budget fixture (§Atlas UI budgets);
  • the 3D physics, on a headless d3-force-3d simulation configured exactly as the scene configures its own (src/test/physics.test.ts, D234): seeding is deterministic; d3's centring force is replaced by per-axis gravity; at rest the drift moves nodes by less than 0.5% of the graph's radius per tick at p95, and paused motion dies out; a non-finite node is repaired at its neighbours' centroid;
  • the 3D motion and camera policy (src/test/motion.test.ts): the panorama turns only after quiet time with nothing selected; the drift runs at half rate past the tick budget and rests only past 12 ms (D1044); signals run only on the selection's links, in their data direction, within 48 and a finite span; focus centres the node in the strip the inspector leaves visible;
  • the no-WebGL state: the shell names why there is no graph and keeps the list (graphfirst.test.tsx);
  • the reading panel's width (D239, D281): the stored preference reads garbage or a throwing storage as the default and clamps at both ends, the upper one also at the reading measure once measured (src/test/layout.test.ts); its splitter resizes from the keyboard, persists only what the viewer set, and is absent from the narrow layout (shell.test.tsx);
  • the Artifacts panel (D238, src/test/artifacts.test.tsx): absent when the KB's artifacts flag is false, with a link to it falling back to the atlas; the agent kit grouped by kind (no text filter), the instructions shown as source on their own tab and the templates on theirs, the selection round-tripping through the URL and Back, and the pre-tab artifact links reading as the tab of their kind (D1057, src/test/viewstate.test.ts); an artifact's Markdown, frontmatter and script files render inert (security.test.tsx); every kind listed even for a KB that ships none (D864): its path as text, its clients, a Docs link that is only ever a repository docs path on main with noopener noreferrer, empty kinds hidden by a slice, and the older-server fallback (no kinds) unchanged. On the Go side, provisioning.Kinds is pinned from both ends: every matrix kind has a descriptor, a file written at each KBPath is found by the scanner of its kind (internal/provisioning/kinds_test.go, and TestKinds_KBOnlyPathsAreWhatTheScannersFind for templates and secrets), and each Doc is an existing page with the heading its anchor names.
  • the palette and archived pages (D820, src/test/palette-archived.test.tsx): the "Include archived" toggle sending include_archived=1, badging archived hits and saying so when the server fell back to them, and starting each opening unpressed;
  • retirements on Activity's timeline (D1146, src/test/activity.test.tsx): a retired concept's row with its status pill and "Replaced by …", the "retired" stat, the empty window's quiet notice with the earlier changes under it, and ?panel=retired reading as Activity (src/test/viewstate.test.ts); an archive map stays badged and listed after the live ones.
  • the release history (D862, src/test/releases.test.tsx): the version at the foot of the rail opens a sheet that fetches only then; newest first, one Current badge, scrolled to only when newer releases precede it; the upgrade block only with a command; the disabled, unreachable and stale notices; a cut changelog pointing at GitHub; a changelog with <script>, an onerror image and a javascript: link, and a non-https release URL, stay inert (security.test.tsx); the static demo answers unavailable: "disabled" locally and never fetches (api/static.test.ts). Server side: updatecheck/releases_test.go (pages, drafts, truncation, ETag, errors, token, tags that are not versions), updatecheck_test.go (a hostile tag refused from the network and the cache), mcpserver/uiapi_releases_test.go (current marking, TTL, stale, concurrent opens fetch once, https-only URLs) and cmd/cartographer/serverupdate_test.go (the source follows the update-check switch).

make web rebuilds the committed bundle; make web-check does a clean locked build and verifies internal/webui/dist/provenance.json still matches the sources. The staleness of the committed bundle is checked by a pure-Go test in internal/webui, which is why make gate catches it without Node.

Atlas UI contrast

web/src/test/contrast.test.ts reads the hex tokens out of web/src/styles/tokens.css and checks, for the dark and the light theme:

Role Pairs Minimum
Text (1.4.3) --text-primary/-secondary/-muted on --surface-0…--surface-3 4.5:1
Brand and severity used as text --primary, --accent, --sev-* on --surface-0…--surface-2 4.5:1
Text on filled controls --text-on-primary on --primary, --text-on-accent on --accent 4.5:1
Graphical objects (1.4.11) --hue-1…--hue-12, --graph-community-other and the --accent focus ring on --surface-0 3:1

Edges and borders are not audited: they are decoration, never the only carrier of a relationship — the inspector lists every link.

Atlas UI budgets

  • Bundle: initial compressed JS+CSS ≤ 700 KiB. make web-check runs npm run budget (web/scripts/budget.mjs), which gzips every asset in internal/webui/dist/assets and fails over the ceiling.
  • Client pipeline: web/src/test/perf.test.tsx generates a 2,000-node fixture (generateSnapshot in web/src/test/fixtures.ts, which carries the server's pagerank, community and communities fields), runs the deterministic layout and prints its timing (cd web && npx vitest run src/test/perf.test.tsx). Communities are computed by the server (D244), so the browser spends no time on them. The ceiling is 12 s: it catches a several-fold regression on any runner, not a few percent. Reference measurement for 2,000 nodes / ~3,900 edges: the 3D warm-up (300 ticks) takes ~2.2 s on an Apple M5 and ~5.4 s on a GitHub-hosted runner.
  • Scale target (#861: the largest KB at 5,000 concepts). Measured by hand, not in CI, headed on the GPU (Apple M5, ANGLE Metal, 144 Hz display) with web/scripts/bench.mjs --gpu and web/scripts/idle.mjs --gpu, from web/ after make build, on the generated fleet of make dev (internal/testkbs: test-5000 and the profiled KBs such as ferry-line). The budget: an untouched tab under 30 % of one core with motion on and under 5 % with it off, every view usable within half a second at 5,000 concepts, and the server computing every UI API route of the scale KB in under 200 ms warm (kb_status is the slowest).
  • Selection feedback: the same file asserts the inspector skeleton renders while the concept request is still pending, so feedback never waits on the network.

  • 3D view (D234): web/scripts/bench3d.mjs, not in CI. It generates a demo KB (web/scripts/demo-kb.mjs: clustered Maps, hubs, orphans, an unlinked component), serves it, opens the 3D view and reports cold and warm load to the first 3D frame, frame time at rest in live mode and while the view is orbited by a pointer drag (5 s each, rAF intervals), the JS heap and the GL renderer string. Run it headed on the GPU (cd web && node scripts/bench3d.mjs 1000,2000 --gpu); a headless run falls back to SwiftShader and measures software rendering, not the Atlas. Sizes are the ones the UI receives: the graph API serves 2,000 nodes by default and 5,000 at most. Target p95 frame < 33 ms.

Reference run — Apple M5 (8-core GPU), 16 GB, macOS 27.0, Playwright Chromium 1.63, ANGLE Metal, 1,440×900 viewport at device pixel ratio 2 (the view caps it at 2):

Concepts Cold / warm load Rest p50 / p95 Orbit p50 / p95 Heap
1,000 1.5 s / 1.4 s 16.7 / 17.3 ms 16.7 / 17.2 ms 57 MB
2,000 3.2 s / 2.6 s 16.7 / 16.8 ms 16.7 / 17.1 ms 35 MB

Both sizes hold 60 fps at rest and while orbiting. The heap figure is performance.memory after load and moves with garbage collection timing more than with size. The scene draws every node in one instanced mesh and every link in one line set, so the frame cost barely moves with size; the load time is the synchronous warm-up of the simulation, which a live view stops after 120 ms and lets the opening burst finish (D736; reduced motion and the video export run it whole). 5,000 is not reported; the view accepts it (the API's ceiling) without a measured budget.

Browser (make e2e-web)

Playwright drives Chromium against bin/cartographer serving its embedded bundle — never Vite's dev server, which serves different bytes under different headers (no CSP, no SPA fallback, no auth chain), so a pass there would verify nothing that ships (D228). web/e2e/run.sh builds the binary, starts two servers on ephemeral loopback ports and runs the suite: local with auth off, auth with an admin token and one narrowed by a role to Entity concepts under infra/ in one KB. Node runs the test driver only.

The fixture is web/e2e/fixture.mjs, a pure function of itself: three KBs — atlas (two Maps and a Journal, type and status on every concept, a backlink pair, a broken target, an orphan, an expanded concept with a satellite, a map index with a dead entry, and a concept body carrying a raw <script> and an onerror attribute), annex (for KB switching) and void (no concepts).

What it holds, beyond the component tests:

  • the flows — local mode with no prompt; the bearer prompt, and "remember for this tab" surviving a reload but not a new tab; KB switching; Map drill-in; type/status filters; the command palette; URL state with Back/Forward and deep links; backlink chips; Health findings revealing their concept or saying there is no node; Health's findings scoped by its Map chips (hmap), the rail's Maps opening the Atlas, and the node filters stepping aside; Health loading its upkeep schedule and questions (D323, D338); the Artifacts catalog opening an artifact beside it and closing it; the reading panel's splitter (D239, D281): a drag persists across a reload, a wider panel keeps its close button at the head's right edge and reframes the selection in the strip it now leaves, and the panel stops growing at the reading measure; the truncation banner, the empty KB, a 500 and an unreachable server, each confined to its panel;
  • fine-grained non-disclosure: under the narrowed token a hidden concept appears in no node, edge, count, finding, search result or API body, and requesting it directly yields the 404 state with the same body as a concept that never existed — never a 403;
  • determinism: two fresh browser contexts compute byte-identical layouts;
  • accessibility: axe finds no serious or critical WCAG 2.2 A/AA violation on the shell, graph, inspector, navigation, Activity, Work, Health, Artifacts and auth prompt, at 1,440×900 and 390×844, and no page wider than the screen; one keyboard-only traversal; reduced motion skips the entry settle and jumps the camera, where full motion plays and tweens it (read from the data-entry and data-camera attributes the graph exposes for this);
  • the 3D view (graph3d.spec.ts, on a software WebGL context via SwiftShader, so it tests the shipped renderer rather than its fallback): a lost WebGL context says so and keeps the list; with no WebGL at all the list, search and inspector still work; the Motion toggle is remembered and reduced motion starts it paused; a selection names the selected node and each neighbour once; a hidden tab stops the render loop; touch-action: none is on the canvas only; the growth video export (D691) produces a download with the page hidden halfway through, and the downloaded file is decoded in a blank page: 1080x1080, a duration of 13 s (a 1 s opening card, the 8 s replay of a small KB, a 1.5 s hold, a 2.5 s end card), frame 0 showing the logo rather than a dark frame, a mid-replay frame with the graph drawn, and presentation times 1/60 s apart (the regression test for the black videos; MP4 where the browser encodes H.264, WebM otherwise);
  • the README's hero tour (hero.spec.ts, D330): graph, search, a concept's links, colour by Map, a skill in the Artifacts panel, Health — so a step the recorded animation shows and the UI no longer offers fails here;
  • security: no request leaves the Cartographer origin (any foreign request fails the test, it is not allow-listed); the shell's CSP is present and an injected inline script and a same-origin eval are both refused; the token reaches no URL, localStorage entry, console line or error body; a hostile concept body renders inert.

There are no screenshot baselines: cross-runner pixel baselines are a maintenance sink, and the behavioural assertions carry the contract. There are no retries either: a flaky flow is fixed at its cause or deleted. The suite runs in the web CI job on Linux only, like the other shell harnesses.

Locally: cd web && npx playwright install chromium once, then make e2e-web (extra arguments pass through: web/e2e/run.sh -g palette).

make hero records the README's animation to hero.webp at the repo root (gitignored, a local preview) from the same hero.spec.ts over the demo KB (web/scripts/record-hero.sh). It is deterministic and needs no GPU or desktop session: the spec runs headless on the config's SwiftShader launch args, at a device scale factor of 2, with Playwright's clock installed and paused before the page loads. Every frame advances the clock by 1000/HERO_FPS ms (30 by default), steps the CSS transitions and animations by the same amount (the fake clock does not drive them, so the spec pauses document.getAnimations() and sets their currentTime itself) and screenshots the viewport. A slow renderer makes the capture take longer, never choppier. Every wait of the tour goes through one helper that, in a recording, steps frames until the condition holds and fails with the step's name after 30 virtual seconds; the graph's warm-up advances the clock without shooting until it is drawn (120 virtual seconds). The script scales the 2560x1520 frames to HERO_WIDTH (1600), refuses to write hero.webp when the encoded aspect differs from the viewport's (1280/760) by more than 1% or cropdetect finds a dark border wider than a few pixels in the first frames, encodes with img2webp, and prints the capture time and the per-frame average. HERO_OUT moves the output, HERO_KEEP=1 keeps the frames.

The README does not carry that file. The hero workflow (hero.yml, called by release.yml) records the tag's Atlas on ubuntu-latest and force-pushes it as the one commit of the orphan branch readme-assets (web/scripts/publish-hero.sh); the README points at raw.githubusercontent.com/BeppeTemp/cartographer/readme-assets/hero.webp, which GitHub renders at any size, unlike a Pages or release asset (D790). A release records only when its web/ tree differs from the Web-Tree: trailer of the branch's commit (web/scripts/hero-needed.sh); gh workflow run hero.yml -f tag=<tag> always records. The 19_hero_publish e2e scenario drives both scripts against a scratch bare repository.

Security scans (D1020)

security.yml is the one workflow that tests nothing of ours: make vulncheck (govulncheck) and npm audit over the web/ production dependencies, make lint-workflows (actionlint and zizmor); CodeQL is codeql.yml, which also runs on a PR touching code and skips the generated bundle internal/webui/dist (.github/codeql/codeql-config.yml). It runs on push to main, on a PR touching dependencies or workflows, and weekly, and none of it is a required check: an advisory published against an old dependency must not block an unrelated PR. A red scan on main opens a vuln-scan issue with the run link. TestWorkflowActionsArePinnedBySHA is the part that does gate: it fails when a uses: in .github/workflows/ is not a full commit SHA followed by a # vX.Y.Z comment.

What is deliberately not in CI

  • Blocking a PR on a security advisory (D1020): the scans report, they do not gate a PR. They gate the release instead: the release skips while an alert is open (D1123).
  • Whether a particular model interprets an instruction well.
  • Provider/model quality comparisons.
  • Tests requiring production credentials or external private infrastructure.
  • Manual UI appearance checks, and screenshot baselines of the Atlas UI (D228).
  • The macOS scripts on any other host: test/macos/ runs only on a macOS runner (or a throwaway Mac with CARTOGRAPHER_MACOS_E2E=1).
  • The shell harnesses on Windows: make smoke-http, make e2e and make test-install are POSIX sh scripts (test/smoke/, test/e2e/, test/install/) driving a launchd/systemd install path, so they run on the ubuntu leg only.
  • Registering a real Scheduled Task, or setting the real named shutdown event: both change state outside the test's temp directory. The command sequences and the XML are asserted; that Task Scheduler accepts the document is verified by an actual install on Windows. The ordering of stop, exit and start is asserted against a fake task whose process outlives Stop-ScheduledTask for a few polls (D266); how long the real one takes to release the port is not.

These belong to production validation or an explicit release exercise, not to the deterministic repository gate.

Before a pull request

make gate          # gofmt + vet + test + race, documentation gates included
make smoke-http
make smoke
make e2e
make test-install

Exactly what CI runs, in the same order: make gate is the one place that defines "green", so a step added here has to be added to gate or to ci.yml, not to a list that only lives in prose.

The Windows leg runs the same make gate. From a POSIX host the part of it that can be reproduced is the compilation:

GOOS=windows GOARCH=amd64 go build ./...
GOOS=windows GOARCH=amd64 go vet ./...

Before a release

  • The release candidate (release-train.yml with action: rc dispatches preview.yml, tag v<next>-rc.<YYYYMMDD>, D1384) has passed the image smoke before its push (D995) and has run on a real deployment, and its pre-release binaries have been installed as native service and client on a machine that is not the build host (CARTOGRAPHER_INSTALL_TAG, CONTRIBUTING.md §Releases); only then is the rc-ok label added to the release PR.
  • CI is green on the release commit, including make e2e-web in the web job.
  • make e2e-upgrade is green on the release PR (the upgrade-e2e job): a client of the previous release upgrades and converges.
  • make smoke succeeds for the packaged/local stdio path.
  • Installation and upgrade are verified on the target platform.
  • Every command the installers' closing "Next:" line (install.sh, install.ps1) names exists in the release being cut: main can reference one that no release has yet.
  • Private deployment rollout checks are performed through maintainer tooling.