Skip to content

Commit 4875194

Browse files
aledbfclaude
andcommitted
test(cli): hold every edit mutate makes in the command files
A whole-repository mutate run left 66 edits in open, stop, check, exec, up, build and gpu that no test refused. The new tests check what a caller depends on, without Docker: the positional [path] of open, stop, down and build, open's editor launch and config, check's exit code, saved state and JSON, the up/build host hint on a real pseudo-terminal, the lifecycle marker scripts run by the host's sh, exec's Compose container lookup against the fake engine, and the GPU runtime probe. Two edits nobody can observe carry a mutate-exempt instead: the tabwriter's minwidth, tabwidth and flags in the check table (moved to a const so the padding stays mutated and tested), and the signal branch of execExitCode, where ExitCode -1 always comes with a signaled WaitStatus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent eae4177 commit 4875194

10 files changed

Lines changed: 791 additions & 1 deletion
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/devcontainers/cli/internal/config"
11+
)
12+
13+
// buildConfigNotFound runs build with args and returns the path its "config
14+
// not found" error names.
15+
func buildConfigNotFound(t *testing.T, args ...string) string {
16+
t.Helper()
17+
cmd := newBuildCmd()
18+
cmd.SilenceUsage, cmd.SilenceErrors = true, true
19+
var out bytes.Buffer
20+
cmd.SetOut(&out)
21+
cmd.SetErr(&bytes.Buffer{})
22+
cmd.SetArgs(args)
23+
_ = cmd.ExecuteContext(t.Context())
24+
var got struct{ Outcome, Message string }
25+
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
26+
t.Fatalf("stdout is not one JSON result: %v\n%s", err, out.String())
27+
}
28+
if got.Outcome != "error" {
29+
t.Fatalf("outcome = %q, want error", got.Outcome)
30+
}
31+
return got.Message
32+
}
33+
34+
// build [path] builds the workspace the argument names, not the current
35+
// directory; --workspace-folder wins over the argument.
36+
func TestBuildPositionalPathIsTheWorkspace(t *testing.T) {
37+
ws := t.TempDir()
38+
t.Chdir(t.TempDir())
39+
want := "Dev container config (" + filepath.Join(ws, ".devcontainer", "devcontainer.json") + ") not found."
40+
if got := buildConfigNotFound(t, ws); got != want {
41+
t.Errorf("message = %q, want %q", got, want)
42+
}
43+
if got := buildConfigNotFound(t, t.TempDir(), "--workspace-folder", ws); got != want {
44+
t.Errorf("message with --workspace-folder = %q, want %q", got, want)
45+
}
46+
}
47+
48+
// A build target names the stage to build, and the Dockerfile is used as it
49+
// is, with no stage name added.
50+
func TestPrepareDockerfileBuildUsesTheConfiguredTarget(t *testing.T) {
51+
dir := t.TempDir()
52+
dockerfile := filepath.Join(dir, "Dockerfile")
53+
if err := os.WriteFile(dockerfile, []byte("FROM alpine AS base\nFROM base\n"), 0o644); err != nil {
54+
t.Fatal(err)
55+
}
56+
cfg := &config.DevContainer{
57+
ConfigFilePath: filepath.Join(dir, "devcontainer.json"),
58+
Build: &config.Build{Dockerfile: "Dockerfile", Target: "base"},
59+
}
60+
prep, err := prepareDockerfileBuild(cfg)
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
defer prep.Cleanup()
65+
if prep.StageName != "base" || prep.Path != dockerfile {
66+
t.Errorf("stage %q at %q, want base at %q", prep.StageName, prep.Path, dockerfile)
67+
}
68+
}
69+
70+
// Without a target, the final stage gets a name to build, in a Dockerfile
71+
// written next to the user's, whether the Dockerfile is named under build or
72+
// at the top level.
73+
func TestPrepareDockerfileBuildNamesTheFinalStageWithoutATarget(t *testing.T) {
74+
for name, cfg := range map[string]config.DevContainer{
75+
"build": {Build: &config.Build{Dockerfile: "Dockerfile"}},
76+
"top level": {DockerFile: "Dockerfile"},
77+
} {
78+
t.Run(name, func(t *testing.T) {
79+
dir := t.TempDir()
80+
dockerfile := filepath.Join(dir, "Dockerfile")
81+
if err := os.WriteFile(dockerfile, []byte("FROM alpine\n"), 0o644); err != nil {
82+
t.Fatal(err)
83+
}
84+
cfg.ConfigFilePath = filepath.Join(dir, "devcontainer.json")
85+
prep, err := prepareDockerfileBuild(&cfg)
86+
if err != nil {
87+
t.Fatal(err)
88+
}
89+
defer prep.Cleanup()
90+
const stage = "dev_container_auto_added_stage_label"
91+
if prep.StageName != stage || prep.Path != dockerfile+".devcontainer.build" {
92+
t.Errorf("stage %q at %q, want %s at the written Dockerfile", prep.StageName, prep.Path, stage)
93+
}
94+
})
95+
}
96+
}

‎internal/cli/check.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,9 @@ func writeSetupResult(out Output, opts *setupOpts, actions []doctor.Action, fina
147147

148148
// writeReportTable renders the ✔/⚠/✖ table plus per-line remediation hints.
149149
func writeReportTable(w io.Writer, report doctor.Report) {
150-
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
150+
// mutate-exempt: a minwidth of 1 is below every cell's width plus the padding of 2, tabwidth counts only with '\t' padding, and flag 1 (FilterHTML) re-measures only HTML, which no status symbol or check name holds
151+
const minwidth, tabwidth, flags = 0, 0, 0
152+
tw := tabwriter.NewWriter(w, minwidth, tabwidth, 2, ' ', flags)
151153
for _, r := range report.Results {
152154
fmt.Fprintf(tw, "%s\t%s\t%s\n", r.Status.Symbol(), r.Name, r.Summary)
153155
}

‎internal/cli/check_held_test.go‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"encoding/json"
55
"errors"
66
"io"
7+
"os"
8+
"path/filepath"
79
"strings"
810
"testing"
911

@@ -98,3 +100,82 @@ func TestWriteSetupResultText(t *testing.T) {
98100
t.Errorf("stderr = %q, want no note when the state was saved", saved.err.String())
99101
}
100102
}
103+
104+
// The table pads its columns two spaces past their widest cell.
105+
func TestWriteReportTableAlignsTheColumns(t *testing.T) {
106+
var buf strings.Builder
107+
writeReportTable(&buf, sampleReport())
108+
want := "✔ docker-daemon Docker daemon reachable (server 27.0)\n" +
109+
"⚠ build-cache-export cannot export cache\n" +
110+
" → build-cache-export: run `devcontainer setup`\n" +
111+
"\noverall: ⚠ warn\n"
112+
if buf.String() != want {
113+
t.Errorf("table =\n%s\nwant\n%s", buf.String(), want)
114+
}
115+
}
116+
117+
// runCheckWithoutDocker runs check against a docker CLI that does not exist,
118+
// so the daemon check fails, with the check state under stateHome.
119+
func runCheckWithoutDocker(t *testing.T, out Output, json bool, stateHome string) error {
120+
t.Helper()
121+
t.Setenv("XDG_STATE_HOME", stateHome)
122+
return runCheck(t.Context(), out, &checkOpts{json: json, dockerPath: filepath.Join(t.TempDir(), "docker")})
123+
}
124+
125+
// A failing host prints the table, saves the state and exits 1.
126+
func TestRunCheckFailingHostExitsOne(t *testing.T) {
127+
state := t.TempDir()
128+
out := &captureOutput{}
129+
err := runCheckWithoutDocker(t, out, false, state)
130+
if exitCode(err) != 1 {
131+
t.Fatalf("err = %v, want exit code 1 for a failing host", err)
132+
}
133+
if s := out.out.String(); !strings.HasSuffix(s, "\noverall: ✖ fail\n") {
134+
t.Errorf("stdout = %q, want the table of a failing host", s)
135+
}
136+
if out.err.Len() != 0 {
137+
t.Errorf("stderr = %q, want nothing when the state was saved", out.err.String())
138+
}
139+
saved, ok, err := doctor.LoadFrom(filepath.Join(state, "devcontainer", "check.json"))
140+
if err != nil || !ok || saved.Overall != doctor.StatusFail {
141+
t.Errorf("saved state = %+v, %v, %v; want the failing report", saved, ok, err)
142+
}
143+
}
144+
145+
// A state that cannot be saved is a note on stderr, not a failure of its own.
146+
func TestRunCheckNotesAStateItCouldNotSave(t *testing.T) {
147+
blocker := filepath.Join(t.TempDir(), "file")
148+
if err := os.WriteFile(blocker, nil, 0o644); err != nil {
149+
t.Fatal(err)
150+
}
151+
out := &captureOutput{}
152+
err := runCheckWithoutDocker(t, out, false, blocker)
153+
if exitCode(err) != 1 {
154+
t.Fatalf("err = %v, want exit code 1 for a failing host", err)
155+
}
156+
if s := out.err.String(); !strings.HasPrefix(s, "note: could not save check state: ") {
157+
t.Errorf("stderr = %q, want the note", s)
158+
}
159+
}
160+
161+
// --json prints the report as JSON and still exits 1 for a failing host; a
162+
// stdout that cannot be written fails with that error.
163+
func TestRunCheckJSON(t *testing.T) {
164+
out := &captureOutput{}
165+
err := runCheckWithoutDocker(t, out, true, t.TempDir())
166+
if exitCode(err) != 1 {
167+
t.Fatalf("err = %v, want exit code 1 for a failing host", err)
168+
}
169+
var got doctor.Report
170+
if err := json.Unmarshal(out.out.Bytes(), &got); err != nil {
171+
t.Fatalf("stdout is not JSON: %v\n%s", err, out.out.String())
172+
}
173+
if got.Overall != doctor.StatusFail || len(got.Results) == 0 {
174+
t.Errorf("report = %+v, want a failing report", got)
175+
}
176+
177+
err = runCheckWithoutDocker(t, &brokenStdout{}, true, t.TempDir())
178+
if err == nil || !strings.Contains(err.Error(), "stdout closed") {
179+
t.Fatalf("err = %v, want the write error", err)
180+
}
181+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//go:build linux
2+
3+
package cli
4+
5+
import (
6+
"bufio"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"syscall"
13+
"testing"
14+
"unsafe"
15+
16+
"github.com/devcontainers/cli/internal/doctor"
17+
)
18+
19+
// openPTY opens a pseudo-terminal: what is written to the returned terminal
20+
// can be read back from the returned master.
21+
func openPTY(t *testing.T) (master, terminal *os.File) {
22+
t.Helper()
23+
master, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
24+
if err != nil {
25+
t.Fatal(err)
26+
}
27+
t.Cleanup(func() { master.Close() })
28+
var unlock int32
29+
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, master.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); errno != 0 {
30+
t.Fatalf("unlock pty: %v", errno)
31+
}
32+
var n uint32
33+
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, master.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n))); errno != 0 {
34+
t.Fatalf("pty number: %v", errno)
35+
}
36+
terminal, err = os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|syscall.O_NOCTTY, 0)
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
t.Cleanup(func() { terminal.Close() })
41+
return master, terminal
42+
}
43+
44+
type terminalStderr struct{ terminal *os.File }
45+
46+
func (terminalStderr) Stdout() io.Writer { return io.Discard }
47+
func (o terminalStderr) Stderr() io.Writer { return o.terminal }
48+
49+
// warnOnTerminal runs warnUncheckedHost with a terminal for stderr and the
50+
// check state under stateHome, and returns what it wrote there.
51+
func warnOnTerminal(t *testing.T, stateHome string) string {
52+
t.Helper()
53+
t.Setenv("XDG_STATE_HOME", stateHome)
54+
master, terminal := openPTY(t)
55+
warnUncheckedHost(terminalStderr{terminal})
56+
const end = "END-OF-OUTPUT"
57+
fmt.Fprintln(terminal, end)
58+
var got strings.Builder
59+
r := bufio.NewReader(master)
60+
for {
61+
line, err := r.ReadString('\n')
62+
if err != nil {
63+
t.Fatalf("read terminal: %v (read %q)", err, got.String())
64+
}
65+
line = strings.TrimRight(line, "\r\n")
66+
if line == end {
67+
return got.String()
68+
}
69+
got.WriteString(line + "\n")
70+
}
71+
}
72+
73+
func saveCheckState(t *testing.T, overall doctor.Status) string {
74+
t.Helper()
75+
home := t.TempDir()
76+
if err := doctor.SaveTo(filepath.Join(home, "devcontainer", "check.json"), doctor.Report{Overall: overall}); err != nil {
77+
t.Fatal(err)
78+
}
79+
return home
80+
}
81+
82+
// On a terminal, up and build hint at `check` when the host was never checked,
83+
// warn when the last check failed, and say nothing after a passing check or
84+
// when the state cannot be read.
85+
func TestWarnUncheckedHostOnATerminal(t *testing.T) {
86+
unreadable := t.TempDir()
87+
if err := os.MkdirAll(filepath.Join(unreadable, "devcontainer", "check.json"), 0o755); err != nil {
88+
t.Fatal(err)
89+
}
90+
for _, tc := range []struct {
91+
name string
92+
state string
93+
want string
94+
}{
95+
{"never checked", t.TempDir(), "hint: host not yet checked — run `devcontainer check` to verify your Docker setup.\n"},
96+
{"failing host", saveCheckState(t, doctor.StatusFail), "warning: `devcontainer check` reported a failing host configuration — run `devcontainer check` for details.\n"},
97+
{"passing host", saveCheckState(t, doctor.StatusOK), ""},
98+
{"unreadable state", unreadable, ""},
99+
} {
100+
t.Run(tc.name, func(t *testing.T) {
101+
if got := warnOnTerminal(t, tc.state); got != tc.want {
102+
t.Errorf("stderr = %q, want %q", got, tc.want)
103+
}
104+
})
105+
}
106+
}

‎internal/cli/exec.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ func execExitCode(exitErr *exec.ExitError) int {
366366
if code != -1 {
367367
return code
368368
}
369+
// mutate-exempt: ExitCode is -1 only for a child a signal ended, whose WaitStatus is always there and Signaled, so || takes the same branch
369370
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
370371
return 128 + int(ws.Signal())
371372
}

0 commit comments

Comments
 (0)