- Status: done
- Date: 2026-05-23
- Specs touched: docs/dev/testing-brain.md
Realized the high-leverage subset of the plan in
docs/dev/testing-brain.md: the enabling
DockerDriver refactor plus the first three test layers. The brain now has a
real pyramid; per-PR go test ./... runs in <1s with no Docker daemon.
Refactor (build order #1).
- New
internal/lifecycle/docker.godefinesDockerDriver(Pull, ImageInspect, ComposeUp/Down/Stop, Inspect, NetworkCreate/Remove, PSManaged, RemoveContainersByInstance).cliDockeris the production impl — the previous inlineexec.Command("docker", …)calls, moved verbatim. - Same file promotes
caddy.Clientandhostclient.Clientto the local interfacesCaddyDriverandHostDriver; concrete clients satisfy them unchanged.Managernow holds interface-typed fields. Managergains a swappableAdmitter(defaultadmission.Check) and overridablehealthWait/healthPoll. Lifecycle tests injectadmission.CheckStructureand 200ms/20ms timings.admission.CheckStructureextracted fromCheck— same structural rules, nodocker compose config -qshell-out. Iteration is sorted so rejection messages are deterministic for table-driven tests.
Layer 2 — store tests (internal/store/store_test.go). Real SQLite on
t.TempDir(). Covers migration idempotency across reopen, CRUD,
SetInstanceImages atomic replace, GetInstanceImages ordering, FK cascade
on delete, SlugTaken, SetMDNSName, and SetState on missing rows.
Layer 1 — admission + manifest + lifecycle helpers.
internal/admission/admission_test.go: table-driven rejection rows for ports / privileged / cap_add / build / extends / network_mode / pid / ipc / userns_mode / absolute bind path / named volume (short + long form), plus the happy + relative-bind cases. Each row asserts the message names the service and the offending field.internal/manifest/manifest_test.go:Parsehappy + per-required-field missing + unsupportedmanifest_version;Synthesizecovers single-service inference, ambiguous services, bad main service, empty name, missing port, unusable slug, empty compose.internal/lifecycle/helpers_test.go:repoOf(with registry-port edge case, digest form),digestOf,serviceImages,servicePin.PinnedRef, andManager.allocateSlugagainst a real store (reserved-skip, conflict fallback to-2, exhaustion, preferred-slugs order).
Layer 3 — lifecycle scenarios with fakes
(internal/lifecycle/{fakes,lifecycle}_test.go). A newTestEnv helper
wires Manager against fakeDocker + fakeCaddy + fakeHost + real temp
store/state dir + temp catalog. Scenarios:
- Install happy Door-1 — state=
running, override pinstraefik/whoami@sha256:…, route flipped from splash → upstream, orderedPull → ImageInspect → ComposeUpcalls. - Install happy Door-2 — synthesized manifest, no
images:catalog map. - Admission rejection — no SQLite row, no instance dir, no Docker/Caddy work attempted.
- Digest mismatch — rollback clean,
ComposeUpnever called. - Unpullable image — rollback before per-app network / route work.
ComposeUpfailure — full rollback including per-appNetworkRemove.- Health timeout — state=
failed, instance dir kept, splash flipped tofailed. - Uninstall when
ComposeDownfails — every other teardown step still runs; SQLite row, route, mDNS, network, dir all gone. - Reconcile drift — running-no-containers →
ComposeUp+ route re-asserted; stopped-with-containers →ComposeStop; orphan → torn down viaRemoveContainersByInstance+NetworkRemove.
Each scenario asserts end state + the one or two driver calls that actually matter, per the doc's assertion-discipline note.
- Realizes
docs/dev/testing-brain.mdbuild-order steps 1–4 (the doc's recommended high-leverage subset). - Exercises
APP_LIFECYCLE.mdinstall transaction shape, rollback semantics, the register-early-with-splash → flip pattern, and the reconciler. - Exercises
APP_MANIFEST.mdadmission rules (Tier-3 closed-by-default). - No spec decisions flipped → no
DECISIONS.mdentry.
- Layer 4 (HTTP API via httptest) — deferred per doc's "pay-for-itself" guidance.
- Layer 5 (real Docker integration,
//go:build integration) — deferred. - Layer 6 (bash + curl e2e canary) — deferred; manual verification stays the user-visible regression net for now.
cliDocker.RemoveContainersByInstanceis the one untyped escape hatch: it exists only for orphan reconciliation when the instance dir is gone.
- Wire Layer 6 (
dev/e2e/*.sh) — codifies the verifications we keep redoing slice after slice, independent of any future refactor. - Layer 4 + Layer 5 when concrete pain (an API regression we'd have caught, or a Docker CLI surface drift) justifies them — not on principle.
lifecycletestextraction if a future package needs the helper outsideinternal/lifecycle; today the same-package test file is enough.