Skip to content

test: hold every edit mutate makes without docker - #40

Merged
aledbf merged 22 commits into
mainfrom
test/mutation-nodocker
Oct 26, 2026
Merged

aledbf merged 22 commits into
mainfrom
test/mutation-nodocker

Conversation

@aledbf

@aledbf aledbf commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

#39 made every edit go tool mutate makes across the repository refused, but it verified with Docker available, so some of those refusals came from Docker-backed e2e tests. CI's mutate job has no reachable Docker. A whole-repository run with Docker unreachable left 227 edits no unit test refused, most in library code no pull request had touched (doctor, the OCI auth policy, the Dockerfile parser, the metadata merge). This PR answers each one, so the repository holds against mutate with unit tests alone, as CI checks it.

Bugs fixed (each with a regression test that fails before the fix)

  • The features lockfile. Go wrote it without a trailing newline and with & escaped, and compared bytes. A lockfile written by the reference CLI therefore failed --frozen-lockfile here, and without the flag was rewritten. It is now written as JSON.stringify(lockfile, null, 2) + '\n' and the old one is normalized before comparing; lockfiles Go wrote before still compare equal.
  • initializeCommand output went to the CLI's stdout, ahead of up's JSON result: stdout/stderr separation is a public contract. It now goes to the log, as TS's output.raw does.
  • Credential helpers. After a registry's own credHelper failed, Go fell back to credsStore, and ran docker-credential- for an empty helper name; TS consults credsStore only when the registry has no helper.
  • Compose version gates. A one-component Compose version (2, 3) was treated as unknown for project names and additional_contexts; TS counts a missing component as 0.
  • An absolute dotfiles path became https://github.com//srv/dots.git; TS clones it as written.
  • hostRequirements sizes overflowed int64 (10000000000tb); they are computed in float64 and printed as JS does.
  • getSafeId wrote one _ for a character outside the BMP; TS writes two.
  • The control manifest sanitizer dropped an advisory with an empty field, and a whole list over one mistyped element; it now filters element by element as TS does.

Test doubles

The race detector caught TestRunHooksErrorNamesTheFailedCommand writing to a hand-written executor's slice from the parallel commands of an object hook. The four hand-written CommandExecutor doubles in internal/lifecycle are replaced by MockCommandExecutor (go.uber.org/mock v0.6.0, generated with go generate ./internal/lifecycle). Tests now state the commands they expect, in order where the lifecycle runs them in order, instead of reading back a shared record; tests that counted commands now name them. gomock serializes its own bookkeeping, but the actions (Do, DoAndReturn) run outside its lock, so tests keep state out of them where commands run in parallel.

Left for follow-ups (found, not changed here)

  • EnsureFinalStageName is tolerant where TS throws ("Dockerfile contains no FROM instructions").
  • Terminal log lines end in \n where TS writes \r\n, and Stop lines show at debug where TS shows them at trace.
  • An unknown ${templateOption:...} stays as written; TS substitutes ''.
  • --terminal-columns/--terminal-rows implications check the value, not the flag's presence.
  • normalizeHTTPSAuthority rejects forms WHATWG URL accepts (a/, a?) and keeps :443.
  • pfs.ExtractTarGz's traversal guard never fires (entries are re-rooted inside the destination, which is safe).

Validation

Check Result
go tool mutate per group over whole files, Docker unreachable 0 survived in all five groups
go test ./cmd/... ./internal/... (hermetic) pass
task lint pass
task parity:contract against the reference oracle 81/81
Whole-repository mutate, Docker unreachable 3452 planned: 3097 refused, 1 survived, 354 did not build (14m, 3h20m CPU); the survivor, ResolveBuildAuth's empty-registry skip, is held by TestResolveBuildAuthSkipsAnEmptyRegistry
go test -race ./... (Docker unreachable) pass
task vuln (new dependency) pass
parity-runtime (1) failure on one run the TS side's verify failed on up.compose-image-with-features-omit-config-remote-env-from-metadata-success; the same job passed on rerun

🤖 Generated with Claude Code

aledbf and others added 21 commits October 25, 2026 19:48
With Docker unreachable no unit test pinned the summaries, remediations,
boundaries and builder actions of the doctor checks, so mutate left 41
edits standing. The new tests assert the exact Result and Action each
probe yields through fake runners, temp dirs and HOME.

The probe-context setup moves into writeProbeContext so its write failure
needs no unobservable branch, keeping both messages. Writes of StatusOK,
Result's zero value, are exempt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
No unit test checked the 0755 directory mode, that a header without a
mode keeps os.Create's default, or that a regular file naming destDir
itself fails, so mutate's edits to those lines survived.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Nothing checked that NODE_EXTRA_CA_CERTS is added to the system roots
rather than replacing them, nor that HTTP/2 negotiation survives. Tests
now install generated system roots in TestMain and probe the
GODEBUG=http2client=0 case, where the transport has no TLS config, in a
child process. The TLS config is now cloned and assigned in one place so
the nil case leaves no field write only that child can observe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The reference CLI asks credsStore only when the docker config names no
credential helper for the registry (getCredentialFromDockerConfigOr-
CredentialHelper in httpOCIRegistry.ts): a registry's own helper that
finds nothing goes straight to the auths entry, and an empty helper name
counts as no helper. Go asked credsStore after a failed registry helper
and ran "docker-credential-" for an empty name.

The new tests also hold, without Docker, the platform default helper
choice (pass on PATH or secretservice), the configContainsAuth rule that
skips it, and what each helper outcome logs: until now only Docker-backed
e2e tests refused those mutate edits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A mutate run with Docker unreachable, as on CI, left edits in the OCI
auth policy, ref parsing, the client's response-header timeout and the
CLI's policy context that only Docker-backed e2e tests refused. Unit
tests now hold them against the reference: diagnostics written to the
policy's logger, URL.origin port and Docker Hub rules, authority
validation, the 300-399 token redirect range, 403 challenges, relative
realms, digest rejections, fetch by digest, and a context holding the
policy keeping its cancellation.

Edits nobody can observe are simplified away instead: the userinfo/path/
query/fragment checks the Host comparison already covers, the
unreachable ::1 host, the resp == nil a conforming transport never
returns, and index arithmetic strings.Cut replaces. ParseRef now splits
a digest on every colon as TS does, so sha256:a:b is a format error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…rence

TS useNewProjectName and the additional build contexts check compare the
parsed Compose version with isEarlierVersion, a missing component counting
as 0. The Go gates treated a one-component version as unknown, so "1"
allowed new project names and "3" refused additional contexts. Both now use
isEarlierVersion, and parseComposeVersion keeps every component like TS
parseVersion (the cap at three could not change any comparison).

Down's error paths and the ToProjectName drop value are now held or
exempted, so mutate refuses every edit in compose.go without Docker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ocker

With Docker unreachable, as on CI, only the e2e tests refused these
edits. Unit tests now hold them through the mock API, the HTTP transport
fake, temporary context stores and the BuildKit parser: the endpoint
NewEngineClient dials, the seven removal tries (under synctest), image
labels without a Config, the pull platform, the continued-FROM and
label-insertion paths of EnsureFinalStageName, the escape directive and
named-stage labels.

Edits nobody can observe are simplified (the Stages preallocation, the
Keys loop, the redundant parse-error guard) or exempted (exit codes and
offsets returned beside a failure).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Nothing held New's stderr, start-time and info-level defaults, the
banner's kernel release, the elapsed and stop durations, or the single
trailing newline, so mutate left those edits standing without Docker.
The secret-masking guard is dropped: masking with no secrets or on
empty text already changes nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
parseBytes parsed the count as an int64: a count past its range was 0
and a large unit count wrapped ("10000000000tb" became 856809829107236864),
where TS parseInt and `${n}` give 1.099511627776e+22. It now works in
float64 and writes the number as JS does. safeID wrote one "_" for a
character outside the BMP; the TS regex sees its two UTF-16 code units
and writes "__".

The new tests also hold the parseBytes units and the regex it mirrors,
the forwardPorts that stay strings, and the warning an empty metadata
label does not get, which only Docker-backed tests refused before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
TS writeLockfile writes JSON.stringify(lockfile, null, 2) with a
trailing newline and compares the old lockfile through JSON.parse and
JSON.stringify. Go wrote it without the newline and with "&" escaped,
and compared raw bytes, so a lockfile TS wrote always failed
--frozen-lockfile here and was rewritten otherwise, and a cosmetic
difference counted as a change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
TS sanitizeControlManifest keeps each entry whose fields are strings.
Go dropped entries with an empty field, so an advisory without a
description was never reported, and one mistyped entry made the whole
list unparsable and dropped its valid siblings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Only Docker-backed tests refused these edits. The tests hold the
soft-dependency match and the tie-breaks of TS compareTo for every
source kind, the unresolved nodes of a hand-built graph, one-character
ids and the single-alias diagram label. The compareNodes Set guard and
the GitHub-shorthand tag split change nothing a caller sees and are
gone, aliasesFor no longer sizes its slice, and boolCompare's
magnitude is exempt as only its sign is read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
TS installDotfiles turns a repository into a github.com URL only when it
has no colon and does not match /^\.{0,2}\//, so an absolute path is
cloned as written. The port tested for ./ and ../ alone and turned
/srv/dots into https://github.com//srv/dots.git.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
initializeCommand's stdout went straight to the CLI's stdout, ahead of
up's JSON result, and its stderr to the CLI's stderr outside the log.
TS runInitializeCommand writes every chunk of the command's output to
the log with output.raw at info level; do the same, so stdout holds only
the result and --log-format json carries the output as raw events.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Without Docker no unit test refused edits to BuildContext, the union
types' MarshalJSON, the worktree and git-root walks, the empty ${} tag,
containerEnv's Windows case folding, templateOption's one-name rule and
the host platform. Test each against the TS reference. Drop the
strings.Builder capacity hint in substituteTags, which changes no
result.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Without Docker no unit test refused edits to HookError's message, the
shell server's -1 exit code on a failed exec, the hook output logging,
the sbin PATH filter, the env cache read and parseEnvOutput's
continuation lines. Test each through the package's fake executors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Without Docker no unit test refused raising the one-path limit of stop,
down, open, generate-docs and features test, the boundary of the
terminal-columns/rows implications, or prepareDockerfileBuild's cleanup.
Test them; exempt the written Dockerfile's mode, which only the build
this process starts reads, and drop cacheFolder's empty-username check,
since TS builds devcontainercli-<username> whatever the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
An object hook runs its commands in parallel, so the race detector
flagged the unsynchronized record in TestRunHooksErrorNamesTheFailedCommand.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Four hand-written CommandExecutor doubles recorded commands in slices
and the tests read them back; one of them raced once an object hook ran
its commands in parallel. MockCommandExecutor (go.uber.org/mock,
generated with `go generate ./internal/lifecycle`) states the commands
each test expects instead, in order where the lifecycle runs them in
order, with no state shared between goroutines. Tests that counted
commands now name them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The whole-repository mutation run without Docker left one edit standing:
`reg == "" || seen[reg]` read as `&&`. A DEVCONTAINERS_OCI_AUTH entry
with an empty host matches an empty registry, so without the skip the
build's auth config gains a "" entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Without BuildKit both CLIs build Features from the fixed tag
dev_container_feature_content_temp, so two such cases at once can take
each other's Features. On the docker8 fleet the TS side failed that
way twice (an up in setup_cmd failing silently, then exec or verify
finding no container). A case can now name a lock; cases with the same
lock run one at a time and stay parallel with the rest. The 24 cases
that build Features without BuildKit hold feature-content-image, and
TestParityMatrixLocksTheFeatureContentImage keeps that list exact.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@aledbf
aledbf merged commit 5bc8054 into main Oct 26, 2026
13 checks passed
@aledbf
aledbf deleted the test/mutation-nodocker branch October 26, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant