Skip to content

Commit 59c2cc5

Browse files
aledbfclaude
andcommitted
test(cli): hold the collection, metadata and tarball commands' edits
A mutation run over these files left edits no test refused: the argument wiring of templates metadata, features info, generate-docs and features test, features test's flag conflicts, the template metadata failures, createTarArchive's entry names, empty user names and footer, extracted file modes, the 2xx-only tarball download and the per-user Linux cache folder. Each test asserts what a caller sees, without Docker, reusing readRegistry and httptest servers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0686cd6 commit 59c2cc5

5 files changed

Lines changed: 368 additions & 0 deletions
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package cli
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"encoding/json"
7+
"errors"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"slices"
12+
"strings"
13+
"testing"
14+
15+
"github.com/spf13/cobra"
16+
)
17+
18+
// generate-docs documents the folder its positional argument names, else the
19+
// one --project-folder names; the working directory (the default ".") is left
20+
// alone either way.
21+
func TestGenerateDocsCommandsProjectFolder(t *testing.T) {
22+
t.Chdir(t.TempDir())
23+
writeFeature(t, ".", "decoy", `{"id":"decoy","version":"1.0.0","name":"Decoy"}`)
24+
commands := map[string]struct {
25+
cmd func() *cobra.Command
26+
metadata string
27+
flags []string
28+
}{
29+
"features": {realFeaturesGenerateDocsCmd, "devcontainer-feature.json", []string{"--namespace", "o/r"}},
30+
"templates": {realTemplatesGenerateDocsCmd, "devcontainer-template.json", nil},
31+
}
32+
for name, c := range commands {
33+
for _, positional := range []bool{true, false} {
34+
project := t.TempDir()
35+
if err := os.WriteFile(filepath.Join(project, c.metadata), []byte(`{"id":"solo","version":"1.0.0","name":"Solo"}`), 0o644); err != nil {
36+
t.Fatal(err)
37+
}
38+
args := []string{"--project-folder", project}
39+
if positional {
40+
args = []string{project}
41+
}
42+
cmd := c.cmd()
43+
cmd.SilenceUsage, cmd.SilenceErrors = true, true
44+
cmd.SetOut(&bytes.Buffer{})
45+
cmd.SetErr(&bytes.Buffer{})
46+
cmd.SetArgs(append(args, c.flags...))
47+
if err := cmd.Execute(); err != nil {
48+
t.Fatalf("%s %v: %v", name, args, err)
49+
}
50+
if _, err := os.Stat(filepath.Join(project, "README.md")); err != nil {
51+
t.Errorf("%s %v: README not generated in the project folder: %v", name, args, err)
52+
}
53+
}
54+
}
55+
if _, err := os.Stat(filepath.Join("decoy", "README.md")); !errors.Is(err, os.ErrNotExist) {
56+
t.Errorf("the working directory was documented: %v", err)
57+
}
58+
}
59+
60+
func runFeaturesTestCmd(t *testing.T, args ...string) (string, error) {
61+
t.Helper()
62+
cmd := realFeaturesTestCmd()
63+
cmd.SilenceUsage, cmd.SilenceErrors = true, true
64+
var out bytes.Buffer
65+
cmd.SetOut(&out)
66+
cmd.SetErr(&bytes.Buffer{})
67+
cmd.SetArgs(args)
68+
err := cmd.Execute()
69+
return out.String(), err
70+
}
71+
72+
// features test refuses the flag combinations TS refuses, before it looks at
73+
// the collection; each flag alone is accepted and reaches the collection check.
74+
func TestFeaturesTestCommandFlagConflicts(t *testing.T) {
75+
empty := t.TempDir()
76+
conflicts := map[string][]string{
77+
"Cannot combine --global-scenarios-only and --features": {"--global-scenarios-only", "--features", "a"},
78+
"Cannot combine --skip-scenarios and --global-scenarios-only": {"--global-scenarios-only", "--skip-scenarios"},
79+
}
80+
for want, args := range conflicts {
81+
if _, err := runFeaturesTestCmd(t, append(args, "-p", empty)...); err == nil || err.Error() != want {
82+
t.Errorf("%v: err = %v, want %q", args, err, want)
83+
}
84+
}
85+
for _, args := range [][]string{{"--features", "a"}, {"--global-scenarios-only"}, {"--skip-scenarios"}} {
86+
if _, err := runFeaturesTestCmd(t, append(args, "-p", empty)...); exitCode(err) != 1 {
87+
t.Errorf("%v: err = %v, want the collection check's exit 1", args, err)
88+
}
89+
}
90+
}
91+
92+
// features test checks the collection its positional target names, or
93+
// --project-folder's when the target is missing or "."; a collection without
94+
// src/ and test/ exits 1 naming the folder on stdout.
95+
func TestFeaturesTestCommandTarget(t *testing.T) {
96+
project, target := t.TempDir(), t.TempDir()
97+
cases := []struct {
98+
args []string
99+
want string
100+
}{
101+
{[]string{target, "-p", project}, target},
102+
{[]string{".", "-p", project}, project},
103+
{[]string{"-p", project}, project},
104+
}
105+
for _, c := range cases {
106+
out, err := runFeaturesTestCmd(t, c.args...)
107+
if exitCode(err) != 1 {
108+
t.Errorf("%v: err = %v, want exit 1", c.args, err)
109+
}
110+
if want := "[-] Folder '" + c.want + "' does not contain the required 'src' and 'test' folders.\n"; !strings.HasSuffix(out, want) {
111+
t.Errorf("%v: stdout = %q, want it to end with %q", c.args, out, want)
112+
}
113+
}
114+
}
115+
116+
// orderedObjectKeys lists an object's own keys in source order, whatever its
117+
// values nest, and nothing for anything but an object.
118+
func TestOrderedObjectKeys(t *testing.T) {
119+
raw := `{"z":{"x":"y","k":{"deep":[1,{"q":"r"}]}},"a":["s",{"t":"u"}],"m":"v","b":null}`
120+
if got, want := orderedObjectKeys(json.RawMessage(raw)), []string{"z", "a", "m", "b"}; !slices.Equal(got, want) {
121+
t.Errorf("keys = %q, want %q", got, want)
122+
}
123+
for _, raw := range []string{``, `["a","b"]`, `"a"`, `{`} {
124+
if got := orderedObjectKeys(json.RawMessage(raw)); len(got) != 0 {
125+
t.Errorf("%s: keys = %q, want none", raw, got)
126+
}
127+
}
128+
}
129+
130+
// createTarArchive roots the entries at "./", with forward slashes and a
131+
// trailing slash on directories, and records no user or group names, as
132+
// node-tar does.
133+
func TestCreateTarArchiveEntries(t *testing.T) {
134+
src := t.TempDir()
135+
if err := os.MkdirAll(filepath.Join(src, "sub"), 0o755); err != nil {
136+
t.Fatal(err)
137+
}
138+
if err := os.WriteFile(filepath.Join(src, "sub", "install.sh"), []byte("echo hi\n"), 0o644); err != nil {
139+
t.Fatal(err)
140+
}
141+
archive := filepath.Join(t.TempDir(), "out.tgz")
142+
if err := createTarArchive(archive, src); err != nil {
143+
t.Fatal(err)
144+
}
145+
f, err := os.Open(archive)
146+
if err != nil {
147+
t.Fatal(err)
148+
}
149+
defer f.Close()
150+
tr := tar.NewReader(f)
151+
var names []string
152+
for {
153+
h, err := tr.Next()
154+
if err == io.EOF {
155+
break
156+
}
157+
if err != nil {
158+
t.Fatal(err)
159+
}
160+
names = append(names, h.Name)
161+
if h.Uname != "" || h.Gname != "" {
162+
t.Errorf("%s: uname %q, gname %q, want both empty", h.Name, h.Uname, h.Gname)
163+
}
164+
if h.Name == "./sub/install.sh" {
165+
if b, _ := io.ReadAll(tr); string(b) != "echo hi\n" {
166+
t.Errorf("install.sh = %q", b)
167+
}
168+
}
169+
}
170+
if want := []string{"./", "./sub/", "./sub/install.sh"}; !slices.Equal(names, want) {
171+
t.Errorf("entries = %q, want %q", names, want)
172+
}
173+
// The archive is finished: whole 512-byte blocks ending in the two zero
174+
// blocks that mark the end of a tar.
175+
data, err := os.ReadFile(archive)
176+
if err != nil {
177+
t.Fatal(err)
178+
}
179+
if len(data)%512 != 0 || len(data) < 1024 || !bytes.Equal(data[len(data)-1024:], make([]byte, 1024)) {
180+
t.Errorf("archive of %d bytes does not end in the tar footer", len(data))
181+
}
182+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"os/user"
6+
"path/filepath"
7+
"runtime"
8+
"testing"
9+
)
10+
11+
// On Linux the cache folder carries the user's name, as TS getCacheFolder's
12+
// does, so two users of one machine do not share it.
13+
func TestCacheFolderNamesTheLinuxUser(t *testing.T) {
14+
if runtime.GOOS != "linux" {
15+
t.Skip("the per-user folder is Linux's")
16+
}
17+
u, err := user.Current()
18+
if err != nil || u.Username == "" {
19+
t.Skipf("no current user name: %v", err)
20+
}
21+
if got, want := cacheFolder(), filepath.Join(os.TempDir(), "devcontainercli-"+u.Username); got != want {
22+
t.Errorf("cacheFolder() = %q, want %q", got, want)
23+
}
24+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package cli
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"context"
7+
"net/http"
8+
"net/http/httptest"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"testing"
13+
)
14+
15+
// A regular file keeps the mode its tar entry records, so a Feature's
16+
// install.sh stays executable; an entry recording no mode keeps the mode the
17+
// file was created with instead of becoming unreadable.
18+
func TestExtractTarGzFileModes(t *testing.T) {
19+
var buf bytes.Buffer
20+
tw := tar.NewWriter(&buf)
21+
for name, mode := range map[string]int64{"install.sh": 0o750, "nomode.txt": 0} {
22+
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: mode, Size: 2, Typeflag: tar.TypeReg}); err != nil {
23+
t.Fatal(err)
24+
}
25+
if _, err := tw.Write([]byte("hi")); err != nil {
26+
t.Fatal(err)
27+
}
28+
}
29+
if err := tw.Close(); err != nil {
30+
t.Fatal(err)
31+
}
32+
archive := filepath.Join(t.TempDir(), "feature.tar")
33+
if err := os.WriteFile(archive, buf.Bytes(), 0o644); err != nil {
34+
t.Fatal(err)
35+
}
36+
dest := t.TempDir()
37+
if err := extractTarGz(archive, dest); err != nil {
38+
t.Fatal(err)
39+
}
40+
if fi, err := os.Stat(filepath.Join(dest, "install.sh")); err != nil || fi.Mode().Perm() != 0o750 {
41+
t.Errorf("install.sh: %v, %v, want mode 0750", fi.Mode(), err)
42+
}
43+
if b, err := os.ReadFile(filepath.Join(dest, "nomode.txt")); err != nil || string(b) != "hi" {
44+
t.Errorf("nomode.txt: %q, %v, want it readable", b, err)
45+
}
46+
}
47+
48+
// A tarball download succeeds only on a 2xx answer, as TS treats only
49+
// 200-299 as success: a 300 fails it.
50+
func TestDownloadFeatureTarballStatus(t *testing.T) {
51+
for status, ok := range map[int]bool{200: true, 299: true, 300: false} {
52+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
53+
w.WriteHeader(status)
54+
_, _ = w.Write([]byte("tgz"))
55+
}))
56+
data, _, err := downloadFeatureTarball(context.Background(), nil, srv.URL+"/f.tgz", nil)
57+
srv.Close()
58+
if ok && (err != nil || string(data) != "tgz") {
59+
t.Errorf("HTTP %d: %q, %v, want the body", status, data, err)
60+
}
61+
if !ok && (err == nil || !strings.Contains(err.Error(), "HTTP 300")) {
62+
t.Errorf("HTTP %d: err = %v, want the status", status, err)
63+
}
64+
}
65+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// info <mode> <feature> takes the mode from its first argument and the
10+
// Feature from its second.
11+
func TestFeaturesInfoCommandArguments(t *testing.T) {
12+
reg := newReadRegistry(t)
13+
reg.manifests["ns/tool"] = featureManifest("tool")
14+
reg.tags["ns/tool"] = []string{"1.0.0"}
15+
16+
cmd := realFeaturesInfoCmd()
17+
cmd.SilenceUsage, cmd.SilenceErrors = true, true
18+
var out bytes.Buffer
19+
cmd.SetOut(&out)
20+
cmd.SetErr(&bytes.Buffer{})
21+
cmd.SetArgs([]string{"tags", reg.host + "/ns/tool:1", "--output-format", "json"})
22+
if err := cmd.ExecuteContext(t.Context()); err != nil {
23+
t.Fatal(err)
24+
}
25+
if got := strings.Join(strings.Fields(out.String()), ""); got != `{"publishedTags":["1.0.0"]}` {
26+
t.Errorf("stdout = %s, want the Feature's tags", out.String())
27+
}
28+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"strconv"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// templateManifest is a Template manifest whose annotations are the given
11+
// JSON object body.
12+
func templateManifest(annotations string) []byte {
13+
return []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",` +
14+
`"config":{"mediaType":"application/vnd.devcontainers","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},` +
15+
`"layers":[],"annotations":{` + annotations + `}}`)
16+
}
17+
18+
func runTemplatesMetadataCmd(t *testing.T, templateID string) (string, error) {
19+
t.Helper()
20+
cmd := realTemplatesMetadataCmd()
21+
cmd.SilenceUsage, cmd.SilenceErrors = true, true
22+
var out bytes.Buffer
23+
cmd.SetOut(&out)
24+
cmd.SetErr(&bytes.Buffer{})
25+
cmd.SetArgs([]string{templateID})
26+
err := cmd.ExecuteContext(t.Context())
27+
return out.String(), err
28+
}
29+
30+
// templates metadata <id> prints the manifest's metadata annotation as
31+
// JSON.stringify(JSON.parse(annotation)) does: compacted, keys in source order.
32+
func TestTemplatesMetadataPrintsTheAnnotation(t *testing.T) {
33+
reg := newReadRegistry(t)
34+
metadata := `{ "id": "tpl", "version": "1.0.0", "a": [1, 2] }`
35+
reg.manifests["ns/tpl"] = templateManifest(`"dev.containers.metadata":` + strconv.Quote(metadata))
36+
37+
got, err := runTemplatesMetadataCmd(t, reg.host+"/ns/tpl:latest")
38+
if err != nil {
39+
t.Fatal(err)
40+
}
41+
if want := `{"id":"tpl","version":"1.0.0","a":[1,2]}` + "\n"; got != want {
42+
t.Errorf("stdout = %q, want %q", got, want)
43+
}
44+
}
45+
46+
// Every failure prints an empty object on stdout, as TS does before exiting 1,
47+
// and returns the reason.
48+
func TestTemplatesMetadataFailures(t *testing.T) {
49+
reg := newReadRegistry(t)
50+
reg.manifests["ns/none"] = templateManifest(`"other":"x"`)
51+
reg.manifests["ns/empty"] = templateManifest(`"dev.containers.metadata":""`)
52+
reg.manifests["ns/broken"] = templateManifest(`"dev.containers.metadata":"{\"id\":"`)
53+
cases := []struct{ name, id, want string }{
54+
{"unparsable id", "UPPER/Not A Ref", "parse template identifier"},
55+
{"no manifest", reg.host + "/ns/missing:latest", "fetch manifest for template"},
56+
{"no annotation", reg.host + "/ns/none:latest", "does not contain metadata"},
57+
{"empty annotation", reg.host + "/ns/empty:latest", "does not contain metadata"},
58+
{"invalid annotation", reg.host + "/ns/broken:latest", "has invalid metadata"},
59+
}
60+
for _, c := range cases {
61+
got, err := runTemplatesMetadataCmd(t, c.id)
62+
if err == nil || !strings.Contains(err.Error(), c.want) {
63+
t.Errorf("%s: err = %v, want %q", c.name, err, c.want)
64+
}
65+
if got != "{}\n" {
66+
t.Errorf("%s: stdout = %q, want an empty object", c.name, got)
67+
}
68+
}
69+
}

0 commit comments

Comments
 (0)