From 1bba954c69d31e0f2c41ebeaf38b7493add9da6e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 00:54:13 -0500 Subject: [PATCH 1/5] feat(cli): add features test command for isolated feature testing Implements the `features test` command that runs lifecycle hook tests for devcontainer features in isolation using Docker containers. The command discovers features in src/, locates corresponding test scripts in test/, builds Docker containers with each feature installed, and runs the test scripts to verify correct installation. --- cmd/features/root.go | 1 + cmd/features/test.go | 392 ++++++++++++++++++++++++ cmd/features/test_test.go | 280 +++++++++++++++++ e2e/tests/features/features_test_cmd.go | 161 ++++++++++ 4 files changed, 834 insertions(+) create mode 100644 cmd/features/test.go create mode 100644 cmd/features/test_test.go create mode 100644 e2e/tests/features/features_test_cmd.go diff --git a/cmd/features/root.go b/cmd/features/root.go index 54b5eb7b2..3d0ffa271 100644 --- a/cmd/features/root.go +++ b/cmd/features/root.go @@ -17,6 +17,7 @@ func NewFeaturesCmd(globalFlags *flags.GlobalFlags) *cobra.Command { featuresCmd.AddCommand(NewInfoCmd(globalFlags)) featuresCmd.AddCommand(NewResolveDepsCmd(globalFlags)) featuresCmd.AddCommand(NewGenerateDocsCmd(globalFlags)) + featuresCmd.AddCommand(NewTestCmd(globalFlags)) return featuresCmd } diff --git a/cmd/features/test.go b/cmd/features/test.go new file mode 100644 index 000000000..8197565df --- /dev/null +++ b/cmd/features/test.go @@ -0,0 +1,392 @@ +package features + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/spf13/cobra" +) + +const defaultBaseImage = "mcr.microsoft.com/devcontainers/base:ubuntu" + +type TestCmd struct { + *flags.GlobalFlags + + ProjectFolder string + Features string + BaseImage string + RemoteUser string + SkipScenarios bool + Quiet bool + PreserveTestContainers bool +} + +type testResult struct { + FeatureID string `json:"featureId"` + Scenario string `json:"scenario,omitempty"` + Passed bool `json:"passed"` + Error string `json:"error,omitempty"` +} + +func NewTestCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &TestCmd{GlobalFlags: globalFlags} + testCmd := &cobra.Command{ + Use: "test", + Short: "Test dev container features in isolation", + Long: `Run lifecycle hook tests for dev container features. + +Scans the project's src/ directory for features, builds test containers +from a base image, installs each feature, and runs the corresponding +test scripts from the test/ directory.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, _ []string) error { + return cmd.Run() + }, + } + + testCmd.Flags().StringVar( + &cmd.ProjectFolder, "project-folder", "", + "Path to feature project containing src/ and test/ directories", + ) + testCmd.Flags().StringVar( + &cmd.Features, "features", "", + "Comma-separated list of feature IDs to test (default: all)", + ) + testCmd.Flags().StringVar( + &cmd.BaseImage, "base-image", defaultBaseImage, + "Base Docker image for test containers", + ) + testCmd.Flags().StringVar( + &cmd.RemoteUser, "remote-user", "root", + "User to run tests as", + ) + testCmd.Flags().BoolVar( + &cmd.SkipScenarios, "skip-scenarios", false, + "Only run the global test script, skip per-feature scenario tests", + ) + testCmd.Flags().BoolVar( + &cmd.Quiet, "quiet", false, + "Suppress verbose build output", + ) + testCmd.Flags().BoolVar( + &cmd.PreserveTestContainers, "preserve-test-containers", false, + "Don't remove test containers after run (for debugging)", + ) + _ = testCmd.MarkFlagRequired("project-folder") + + return testCmd +} + +func (cmd *TestCmd) Run() error { + projectFolder, err := filepath.Abs(cmd.ProjectFolder) + if err != nil { + return fmt.Errorf("resolve project folder: %w", err) + } + + srcDir := filepath.Join(projectFolder, "src") + testDir := filepath.Join(projectFolder, "test") + + features, err := cmd.discoverFeatures(srcDir) + if err != nil { + return err + } + + features = cmd.filterFeatures(features) + if len(features) == 0 { + return fmt.Errorf("no features matched the filter %q", cmd.Features) + } + + var results []testResult + for _, feat := range features { + featureResults := cmd.testFeature(feat, projectFolder, testDir) + results = append(results, featureResults...) + } + + cmd.printResults(results) + + for _, r := range results { + if !r.Passed { + return fmt.Errorf("one or more feature tests failed") + } + } + return nil +} + +type featureEntry struct { + id string + config *config.FeatureConfig +} + +func (cmd *TestCmd) discoverFeatures(srcDir string) ([]featureEntry, error) { + entries, err := os.ReadDir(srcDir) + if err != nil { + return nil, fmt.Errorf("read src/ directory: %w", err) + } + + var features []featureEntry + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + featureDir := filepath.Join(srcDir, entry.Name()) + featureCfg, parseErr := config.ParseDevContainerFeature(featureDir) + if parseErr != nil { + continue + } + + features = append(features, featureEntry{ + id: entry.Name(), + config: featureCfg, + }) + } + + if len(features) == 0 { + return nil, fmt.Errorf("no features found in %s", srcDir) + } + return features, nil +} + +func (cmd *TestCmd) filterFeatures(features []featureEntry) []featureEntry { + if cmd.Features == "" { + return features + } + + filter := make(map[string]bool) + for f := range strings.SplitSeq(cmd.Features, ",") { + filter[strings.TrimSpace(f)] = true + } + + var filtered []featureEntry + for _, feat := range features { + if filter[feat.id] { + filtered = append(filtered, feat) + } + } + return filtered +} + +type testCase struct { + script string + scenario string + options map[string]string +} + +func (cmd *TestCmd) testFeature( + feat featureEntry, projectFolder, testDir string, +) []testResult { + var results []testResult + + featureTestDir := filepath.Join(testDir, feat.id) + globalTestScript := filepath.Join(featureTestDir, "test.sh") + + if _, err := os.Stat(globalTestScript); err == nil { + tc := testCase{script: globalTestScript} + results = append(results, cmd.runTest(feat, projectFolder, tc)) + } + + if cmd.SkipScenarios { + return results + } + + scenariosDir := filepath.Join(featureTestDir, "scenarios") + scenarios, err := os.ReadDir(scenariosDir) + if err != nil { + return results + } + + for _, scenario := range scenarios { + if !scenario.IsDir() { + continue + } + + scenarioDir := filepath.Join(scenariosDir, scenario.Name()) + scenarioTestScript := filepath.Join(scenarioDir, "test.sh") + + if _, err := os.Stat(scenarioTestScript); err != nil { + continue + } + + tc := testCase{ + script: scenarioTestScript, + scenario: scenario.Name(), + options: cmd.loadScenarioOptions(scenarioDir), + } + results = append(results, cmd.runTest(feat, projectFolder, tc)) + } + + return results +} + +func (cmd *TestCmd) loadScenarioOptions(scenarioDir string) map[string]string { + scenarioJSON := filepath.Join(scenarioDir, "scenario.json") + data, err := os.ReadFile(scenarioJSON) // #nosec G304 -- path from project structure + if err != nil { + return nil + } + + var scenario struct { + Options map[string]string `json:"options"` + } + if err := json.Unmarshal(data, &scenario); err != nil { + return nil + } + return scenario.Options +} + +func (cmd *TestCmd) runTest( + feat featureEntry, projectFolder string, tc testCase, +) testResult { + result := testResult{ + FeatureID: feat.id, + Scenario: tc.scenario, + } + + dockerfile := cmd.generateDockerfile(feat, tc.options) + + containerName := fmt.Sprintf("devsy-test-%s", feat.id) + if tc.scenario != "" { + containerName = fmt.Sprintf("devsy-test-%s-%s", feat.id, tc.scenario) + } + + imageName := containerName + ":latest" + + buildErr := cmd.dockerBuild(dockerfile, projectFolder, imageName) + if buildErr != nil { + result.Passed = false + result.Error = buildErr.Error() + return result + } + + runErr := cmd.dockerRun(imageName, containerName, tc.script) + if runErr != nil { + result.Passed = false + result.Error = runErr.Error() + } else { + result.Passed = true + } + + if !cmd.PreserveTestContainers { + cmd.dockerRemove(containerName) + } + + return result +} + +func (cmd *TestCmd) generateDockerfile( + feat featureEntry, options map[string]string, +) string { + var b strings.Builder + + fmt.Fprintf(&b, "FROM %s\n", cmd.BaseImage) + + featureSrcDir := filepath.Join("src", feat.id) + fmt.Fprintf(&b, "COPY %s /tmp/build-features/%s\n", featureSrcDir, feat.id) + + for k, v := range options { + envKey := strings.ToUpper(feat.id) + "_" + strings.ToUpper(k) + fmt.Fprintf(&b, "ENV %s=%s\n", envKey, v) + } + + fmt.Fprintf( + &b, + "RUN chmod +x /tmp/build-features/%s/install.sh && /tmp/build-features/%s/install.sh\n", + feat.id, feat.id, + ) + + if cmd.RemoteUser != "root" { + fmt.Fprintf(&b, "USER %s\n", cmd.RemoteUser) + } + + return b.String() +} + +func (cmd *TestCmd) dockerBuild(dockerfile, contextDir, imageName string) error { + args := []string{"build", "-t", imageName, "-f", "-", contextDir} + dockerCmd := exec.Command("docker", args...) // #nosec G204 -- args built from trusted inputs + dockerCmd.Stdin = strings.NewReader(dockerfile) + + if !cmd.Quiet { + dockerCmd.Stdout = os.Stdout + dockerCmd.Stderr = os.Stderr + } + + return dockerCmd.Run() +} + +func (cmd *TestCmd) dockerRun(imageName, containerName, testScript string) error { + testContent, err := os.ReadFile(testScript) // #nosec G304 -- path from project test directory + if err != nil { + return fmt.Errorf("read test script: %w", err) + } + + args := []string{ + "run", "--name", containerName, + "--rm", + imageName, + "bash", "-c", string(testContent), + } + dockerCmd := exec.Command("docker", args...) // #nosec G204 -- args built from trusted inputs + + if !cmd.Quiet { + dockerCmd.Stdout = os.Stdout + dockerCmd.Stderr = os.Stderr + } + + return dockerCmd.Run() +} + +func (cmd *TestCmd) dockerRemove(containerName string) { + rmCmd := exec.Command("docker", "rm", "-f", containerName) // #nosec G204 + _ = rmCmd.Run() + + rmiCmd := exec.Command("docker", "rmi", "-f", containerName+":latest") // #nosec G204 + _ = rmiCmd.Run() +} + +func (cmd *TestCmd) printResults(results []testResult) { + w := os.Stdout + _, _ = fmt.Fprintln(w, "\n=== Feature Test Results ===") + passed := 0 + failed := 0 + + for _, r := range results { + label := r.FeatureID + if r.Scenario != "" { + label = fmt.Sprintf("%s/%s", r.FeatureID, r.Scenario) + } + + if r.Passed { + _, _ = fmt.Fprintf(w, " PASS: %s\n", label) + passed++ + } else { + _, _ = fmt.Fprintf(w, " FAIL: %s — %s\n", label, r.Error) + failed++ + } + } + + _, _ = fmt.Fprintf(w, "\nTotal: %d passed, %d failed\n", passed, failed) +} + +// GenerateDockerfileForTest exposes Dockerfile generation for unit testing. +func GenerateDockerfileForTest( + featureID, baseImage, remoteUser string, + options map[string]string, +) string { + cmd := &TestCmd{ + BaseImage: baseImage, + RemoteUser: remoteUser, + } + feat := featureEntry{ + id: featureID, + config: &config.FeatureConfig{ID: featureID}, + } + return cmd.generateDockerfile(feat, options) +} diff --git a/cmd/features/test_test.go b/cmd/features/test_test.go new file mode 100644 index 000000000..0fae44f68 --- /dev/null +++ b/cmd/features/test_test.go @@ -0,0 +1,280 @@ +package features + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTestCmd_FlagDefaults(t *testing.T) { + cmd := NewTestCmd(nil) + + projectFolderFlag := cmd.Flags().Lookup("project-folder") + require.NotNil(t, projectFolderFlag) + assert.Equal(t, "", projectFolderFlag.DefValue) + + featuresFlag := cmd.Flags().Lookup("features") + require.NotNil(t, featuresFlag) + assert.Equal(t, "", featuresFlag.DefValue) + + baseImageFlag := cmd.Flags().Lookup("base-image") + require.NotNil(t, baseImageFlag) + assert.Equal(t, defaultBaseImage, baseImageFlag.DefValue) + + remoteUserFlag := cmd.Flags().Lookup("remote-user") + require.NotNil(t, remoteUserFlag) + assert.Equal(t, "root", remoteUserFlag.DefValue) + + skipScenariosFlag := cmd.Flags().Lookup("skip-scenarios") + require.NotNil(t, skipScenariosFlag) + assert.Equal(t, "false", skipScenariosFlag.DefValue) + + quietFlag := cmd.Flags().Lookup("quiet") + require.NotNil(t, quietFlag) + assert.Equal(t, "false", quietFlag.DefValue) + + preserveFlag := cmd.Flags().Lookup("preserve-test-containers") + require.NotNil(t, preserveFlag) + assert.Equal(t, "false", preserveFlag.DefValue) +} + +func TestTestCmd_AllFlagsRegistered(t *testing.T) { + cmd := NewTestCmd(nil) + expected := []string{ + "project-folder", + "features", + "base-image", + "remote-user", + "skip-scenarios", + "quiet", + "preserve-test-containers", + } + for _, name := range expected { + assert.NotNil(t, cmd.Flags().Lookup(name), "flag %q should be registered", name) + } +} + +func TestTestCmd_DiscoverFeatures(t *testing.T) { + projectDir := t.TempDir() + srcDir := filepath.Join(projectDir, "src") + + featureADir := filepath.Join(srcDir, "feature-a") + require.NoError(t, os.MkdirAll(featureADir, 0o700)) + require.NoError(t, os.WriteFile( + filepath.Join(featureADir, "devcontainer-feature.json"), + []byte(`{"id":"feature-a","version":"1.0.0","name":"Feature A"}`), + 0o600, + )) + + featureBDir := filepath.Join(srcDir, "feature-b") + require.NoError(t, os.MkdirAll(featureBDir, 0o700)) + require.NoError(t, os.WriteFile( + filepath.Join(featureBDir, "devcontainer-feature.json"), + []byte(`{"id":"feature-b","version":"2.0.0","name":"Feature B"}`), + 0o600, + )) + + require.NoError(t, os.WriteFile( + filepath.Join(srcDir, "not-a-dir.txt"), + []byte("ignored"), + 0o600, + )) + + cmd := &TestCmd{} + features, err := cmd.discoverFeatures(srcDir) + require.NoError(t, err) + assert.Len(t, features, 2) + + ids := make(map[string]bool) + for _, f := range features { + ids[f.id] = true + } + assert.True(t, ids["feature-a"]) + assert.True(t, ids["feature-b"]) +} + +func TestTestCmd_DiscoverFeatures_EmptyDir(t *testing.T) { + projectDir := t.TempDir() + srcDir := filepath.Join(projectDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0o700)) + + cmd := &TestCmd{} + _, err := cmd.discoverFeatures(srcDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "no features found") +} + +func TestTestCmd_DiscoverFeatures_NonexistentDir(t *testing.T) { + cmd := &TestCmd{} + _, err := cmd.discoverFeatures("/nonexistent/path/src") + require.Error(t, err) + assert.Contains(t, err.Error(), "read src/ directory") +} + +func TestTestCmd_FilterFeatures(t *testing.T) { + features := []featureEntry{ + {id: "go", config: nil}, + {id: "node", config: nil}, + {id: "python", config: nil}, + } + + t.Run("empty filter returns all", func(t *testing.T) { + cmd := &TestCmd{Features: ""} + result := cmd.filterFeatures(features) + assert.Len(t, result, 3) + }) + + t.Run("single filter", func(t *testing.T) { + cmd := &TestCmd{Features: "go"} + result := cmd.filterFeatures(features) + assert.Len(t, result, 1) + assert.Equal(t, "go", result[0].id) + }) + + t.Run("multi filter", func(t *testing.T) { + cmd := &TestCmd{Features: "go,python"} + result := cmd.filterFeatures(features) + assert.Len(t, result, 2) + }) + + t.Run("filter with spaces", func(t *testing.T) { + cmd := &TestCmd{Features: " go , node "} + result := cmd.filterFeatures(features) + assert.Len(t, result, 2) + }) + + t.Run("no match", func(t *testing.T) { + cmd := &TestCmd{Features: "rust"} + result := cmd.filterFeatures(features) + assert.Empty(t, result) + }) +} + +func TestTestCmd_GenerateDockerfile(t *testing.T) { + t.Run("basic dockerfile", func(t *testing.T) { + df := GenerateDockerfileForTest("my-feature", "ubuntu:22.04", "root", nil) + assert.Contains(t, df, "FROM ubuntu:22.04") + assert.Contains(t, df, "COPY src/my-feature /tmp/build-features/my-feature") + assert.Contains(t, df, "RUN chmod +x /tmp/build-features/my-feature/install.sh") + assert.NotContains(t, df, "USER") + }) + + t.Run("with remote user", func(t *testing.T) { + df := GenerateDockerfileForTest("my-feature", "ubuntu:22.04", "vscode", nil) + assert.Contains(t, df, "USER vscode") + }) + + t.Run("with options", func(t *testing.T) { + opts := map[string]string{"version": "1.21"} + df := GenerateDockerfileForTest("go", "ubuntu:22.04", "root", opts) + assert.Contains(t, df, "ENV GO_VERSION=1.21") + }) + + t.Run("default base image", func(t *testing.T) { + df := GenerateDockerfileForTest("feat", defaultBaseImage, "root", nil) + assert.Contains(t, df, "FROM "+defaultBaseImage) + }) +} + +func TestTestCmd_LoadScenarioOptions(t *testing.T) { + t.Run("valid scenario.json", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "scenario.json"), + []byte(`{"options":{"version":"3.11","installTools":"true"}}`), + 0o600, + )) + + cmd := &TestCmd{} + opts := cmd.loadScenarioOptions(dir) + assert.Equal(t, "3.11", opts["version"]) + assert.Equal(t, "true", opts["installTools"]) + }) + + t.Run("missing scenario.json", func(t *testing.T) { + dir := t.TempDir() + cmd := &TestCmd{} + opts := cmd.loadScenarioOptions(dir) + assert.Nil(t, opts) + }) + + t.Run("invalid JSON", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "scenario.json"), + []byte(`not json`), + 0o600, + )) + + cmd := &TestCmd{} + opts := cmd.loadScenarioOptions(dir) + assert.Nil(t, opts) + }) +} + +func TestTestCmd_TestDiscovery(t *testing.T) { + projectDir := t.TempDir() + + srcDir := filepath.Join(projectDir, "src", "my-feature") + require.NoError(t, os.MkdirAll(srcDir, 0o700)) + require.NoError(t, os.WriteFile( + filepath.Join(srcDir, "devcontainer-feature.json"), + []byte(`{"id":"my-feature","version":"1.0.0","name":"My Feature"}`), + 0o600, + )) + require.NoError(t, os.WriteFile( // #nosec G306 -- test scripts need executable permission + filepath.Join(srcDir, "install.sh"), + []byte("#!/bin/bash\necho installed"), + 0o700, + )) + + testDir := filepath.Join(projectDir, "test", "my-feature") + require.NoError(t, os.MkdirAll(testDir, 0o700)) + require.NoError(t, os.WriteFile( // #nosec G306 -- test scripts need executable permission + filepath.Join(testDir, "test.sh"), + []byte("#!/bin/bash\necho test passed"), + 0o700, + )) + + scenarioDir := filepath.Join(testDir, "scenarios", "custom-options") + require.NoError(t, os.MkdirAll(scenarioDir, 0o700)) + require.NoError(t, os.WriteFile( // #nosec G306 -- test scripts need executable permission + filepath.Join(scenarioDir, "test.sh"), + []byte("#!/bin/bash\necho scenario test"), + 0o700, + )) + require.NoError(t, os.WriteFile( + filepath.Join(scenarioDir, "scenario.json"), + []byte(`{"options":{"version":"3.11"}}`), + 0o600, + )) + + feat := featureEntry{id: "my-feature", config: nil} + cmd := &TestCmd{ + BaseImage: defaultBaseImage, + RemoteUser: "root", + } + + featureTestDir := filepath.Join(projectDir, "test", feat.id) + globalTestScript := filepath.Join(featureTestDir, "test.sh") + + _, err := os.Stat(globalTestScript) + assert.NoError(t, err, "global test script should exist") + + scenariosPath := filepath.Join(featureTestDir, "scenarios") + scenarios, err := os.ReadDir(scenariosPath) + require.NoError(t, err) + assert.Len(t, scenarios, 1) + assert.Equal(t, "custom-options", scenarios[0].Name()) + + opts := cmd.loadScenarioOptions(scenarioDir) + assert.Equal(t, "3.11", opts["version"]) + + df := strings.TrimSpace(cmd.generateDockerfile(feat, opts)) + assert.Contains(t, df, "FROM "+defaultBaseImage) + assert.Contains(t, df, "MY-FEATURE_VERSION=3.11") +} diff --git a/e2e/tests/features/features_test_cmd.go b/e2e/tests/features/features_test_cmd.go new file mode 100644 index 000000000..35e9a8d5f --- /dev/null +++ b/e2e/tests/features/features_test_cmd.go @@ -0,0 +1,161 @@ +package features + +import ( + "context" + "os" + "os/exec" + "path/filepath" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("features test", ginkgo.Label("features", "features-test"), func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + + if _, lookErr := exec.LookPath("docker"); lookErr != nil { + ginkgo.Skip("docker not available") + } + }) + + ginkgo.It( + "runs test scripts for discovered features", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + projectDir, err := os.MkdirTemp("", "e2e-features-test-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(projectDir) }) + + srcDir := filepath.Join(projectDir, "src", "my-feature") + framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + filepath.Join(srcDir, "devcontainer-feature.json"), + []byte(`{"id":"my-feature","version":"1.0.0","name":"My Feature"}`), + 0o600, + )) + // #nosec G306 -- test scripts must be executable + framework.ExpectNoError(os.WriteFile( + filepath.Join(srcDir, "install.sh"), + []byte("#!/bin/bash\necho 'feature installed'\n"), + 0o750, + )) + + testDir := filepath.Join(projectDir, "test", "my-feature") + framework.ExpectNoError(os.MkdirAll(testDir, 0o750)) + // #nosec G306 -- test scripts must be executable + framework.ExpectNoError(os.WriteFile( + filepath.Join(testDir, "test.sh"), + []byte("#!/bin/bash\necho 'test passed'\nexit 0\n"), + 0o750, + )) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "test", + "--project-folder", projectDir, + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring("Feature Test Results")) + gomega.Expect(stdout).To(gomega.ContainSubstring("PASS")) + gomega.Expect(stdout).To(gomega.ContainSubstring("my-feature")) + }, + ginkgo.SpecTimeout(framework.TimeoutModerate()), + ) + + ginkgo.It( + "filters features with --features flag", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + projectDir, err := os.MkdirTemp("", "e2e-features-test-filter-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(projectDir) }) + + for _, feat := range []string{"feat-a", "feat-b"} { + srcDir := filepath.Join(projectDir, "src", feat) + framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + filepath.Join(srcDir, "devcontainer-feature.json"), + []byte(`{"id":"`+feat+`","version":"1.0.0","name":"`+feat+`"}`), + 0o600, + )) + // #nosec G306 -- test scripts must be executable + framework.ExpectNoError(os.WriteFile( + filepath.Join(srcDir, "install.sh"), + []byte("#!/bin/bash\necho installed\n"), + 0o750, + )) + + testDir := filepath.Join(projectDir, "test", feat) + framework.ExpectNoError(os.MkdirAll(testDir, 0o750)) + // #nosec G306 -- test scripts must be executable + framework.ExpectNoError(os.WriteFile( + filepath.Join(testDir, "test.sh"), + []byte("#!/bin/bash\nexit 0\n"), + 0o750, + )) + } + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "test", + "--project-folder", projectDir, + "--features", "feat-a", + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring("feat-a")) + gomega.Expect(stdout).NotTo(gomega.ContainSubstring("feat-b")) + }, + ginkgo.SpecTimeout(framework.TimeoutModerate()), + ) + + ginkgo.It( + "reports failure when test script exits non-zero", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + projectDir, err := os.MkdirTemp("", "e2e-features-test-fail-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(projectDir) }) + + srcDir := filepath.Join(projectDir, "src", "bad-feature") + framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + filepath.Join(srcDir, "devcontainer-feature.json"), + []byte(`{"id":"bad-feature","version":"1.0.0","name":"Bad Feature"}`), + 0o600, + )) + // #nosec G306 -- test scripts must be executable + framework.ExpectNoError(os.WriteFile( + filepath.Join(srcDir, "install.sh"), + []byte("#!/bin/bash\necho installed\n"), + 0o750, + )) + + testDir := filepath.Join(projectDir, "test", "bad-feature") + framework.ExpectNoError(os.MkdirAll(testDir, 0o750)) + // #nosec G306 -- test scripts must be executable + framework.ExpectNoError(os.WriteFile( + filepath.Join(testDir, "test.sh"), + []byte("#!/bin/bash\necho 'test failed'\nexit 1\n"), + 0o750, + )) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "test", + "--project-folder", projectDir, + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(stdout).To(gomega.ContainSubstring("FAIL")) + gomega.Expect(stdout).To(gomega.ContainSubstring("bad-feature")) + }, + ginkgo.SpecTimeout(framework.TimeoutModerate()), + ) +}) From 95eb7ea39564344b6d49ad7e1867613fb81f424a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 01:07:08 -0500 Subject: [PATCH 2/5] fix(cli): address code review blockers and warnings on features test - Make --rm conditional on !PreserveTestContainers (was unconditional) - Remove exported GenerateDockerfileForTest; tests use unexported method - Reuse scanFeatures() from generatedocs.go instead of duplicating logic - Quote ENV values with %q to handle spaces in option values - Copy test script into container via COPY instead of bash -c argument - Extract constants for repeated string literals (goconst) --- cmd/features/test.go | 90 +++++++++++++++++---------------------- cmd/features/test_test.go | 37 ++++++++++------ 2 files changed, 63 insertions(+), 64 deletions(-) diff --git a/cmd/features/test.go b/cmd/features/test.go index 8197565df..f8672cab0 100644 --- a/cmd/features/test.go +++ b/cmd/features/test.go @@ -13,7 +13,10 @@ import ( "github.com/spf13/cobra" ) -const defaultBaseImage = "mcr.microsoft.com/devcontainers/base:ubuntu" +const ( + defaultBaseImage = "mcr.microsoft.com/devcontainers/base:ubuntu" + defaultRemoteUser = "root" +) type TestCmd struct { *flags.GlobalFlags @@ -64,7 +67,7 @@ test scripts from the test/ directory.`, "Base Docker image for test containers", ) testCmd.Flags().StringVar( - &cmd.RemoteUser, "remote-user", "root", + &cmd.RemoteUser, "remote-user", defaultRemoteUser, "User to run tests as", ) testCmd.Flags().BoolVar( @@ -125,32 +128,18 @@ type featureEntry struct { } func (cmd *TestCmd) discoverFeatures(srcDir string) ([]featureEntry, error) { - entries, err := os.ReadDir(srcDir) + docs, err := scanFeatures(srcDir) if err != nil { - return nil, fmt.Errorf("read src/ directory: %w", err) + return nil, err } - var features []featureEntry - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - featureDir := filepath.Join(srcDir, entry.Name()) - featureCfg, parseErr := config.ParseDevContainerFeature(featureDir) - if parseErr != nil { - continue - } - + features := make([]featureEntry, 0, len(docs)) + for _, doc := range docs { features = append(features, featureEntry{ - id: entry.Name(), - config: featureCfg, + id: doc.dir, + config: doc.config, }) } - - if len(features) == 0 { - return nil, fmt.Errorf("no features found in %s", srcDir) - } return features, nil } @@ -249,7 +238,14 @@ func (cmd *TestCmd) runTest( Scenario: tc.scenario, } - dockerfile := cmd.generateDockerfile(feat, tc.options) + testScriptRel, relErr := filepath.Rel(projectFolder, tc.script) + if relErr != nil { + result.Passed = false + result.Error = relErr.Error() + return result + } + + dockerfile := cmd.generateDockerfileWithTest(feat, tc.options, testScriptRel) containerName := fmt.Sprintf("devsy-test-%s", feat.id) if tc.scenario != "" { @@ -265,7 +261,7 @@ func (cmd *TestCmd) runTest( return result } - runErr := cmd.dockerRun(imageName, containerName, tc.script) + runErr := cmd.dockerRun(imageName, containerName) if runErr != nil { result.Passed = false result.Error = runErr.Error() @@ -282,6 +278,12 @@ func (cmd *TestCmd) runTest( func (cmd *TestCmd) generateDockerfile( feat featureEntry, options map[string]string, +) string { + return cmd.generateDockerfileWithTest(feat, options, "") +} + +func (cmd *TestCmd) generateDockerfileWithTest( + feat featureEntry, options map[string]string, testScriptRelPath string, ) string { var b strings.Builder @@ -292,7 +294,7 @@ func (cmd *TestCmd) generateDockerfile( for k, v := range options { envKey := strings.ToUpper(feat.id) + "_" + strings.ToUpper(k) - fmt.Fprintf(&b, "ENV %s=%s\n", envKey, v) + fmt.Fprintf(&b, "ENV %s=%q\n", envKey, v) } fmt.Fprintf( @@ -301,7 +303,12 @@ func (cmd *TestCmd) generateDockerfile( feat.id, feat.id, ) - if cmd.RemoteUser != "root" { + if testScriptRelPath != "" { + fmt.Fprintf(&b, "COPY %s /tmp/test.sh\n", testScriptRelPath) + b.WriteString("RUN chmod +x /tmp/test.sh\n") + } + + if cmd.RemoteUser != defaultRemoteUser { fmt.Fprintf(&b, "USER %s\n", cmd.RemoteUser) } @@ -321,18 +328,13 @@ func (cmd *TestCmd) dockerBuild(dockerfile, contextDir, imageName string) error return dockerCmd.Run() } -func (cmd *TestCmd) dockerRun(imageName, containerName, testScript string) error { - testContent, err := os.ReadFile(testScript) // #nosec G304 -- path from project test directory - if err != nil { - return fmt.Errorf("read test script: %w", err) +func (cmd *TestCmd) dockerRun(imageName, containerName string) error { + args := []string{"run", "--name", containerName} + if !cmd.PreserveTestContainers { + args = append(args, "--rm") } + args = append(args, imageName, "/tmp/test.sh") - args := []string{ - "run", "--name", containerName, - "--rm", - imageName, - "bash", "-c", string(testContent), - } dockerCmd := exec.Command("docker", args...) // #nosec G204 -- args built from trusted inputs if !cmd.Quiet { @@ -374,19 +376,3 @@ func (cmd *TestCmd) printResults(results []testResult) { _, _ = fmt.Fprintf(w, "\nTotal: %d passed, %d failed\n", passed, failed) } - -// GenerateDockerfileForTest exposes Dockerfile generation for unit testing. -func GenerateDockerfileForTest( - featureID, baseImage, remoteUser string, - options map[string]string, -) string { - cmd := &TestCmd{ - BaseImage: baseImage, - RemoteUser: remoteUser, - } - feat := featureEntry{ - id: featureID, - config: &config.FeatureConfig{ID: featureID}, - } - return cmd.generateDockerfile(feat, options) -} diff --git a/cmd/features/test_test.go b/cmd/features/test_test.go index 0fae44f68..7d0e727bb 100644 --- a/cmd/features/test_test.go +++ b/cmd/features/test_test.go @@ -10,6 +10,11 @@ import ( "github.com/stretchr/testify/require" ) +const ( + testBaseImage = "ubuntu:22.04" + testFeatureID = "my-feature" +) + func TestTestCmd_FlagDefaults(t *testing.T) { cmd := NewTestCmd(nil) @@ -27,7 +32,7 @@ func TestTestCmd_FlagDefaults(t *testing.T) { remoteUserFlag := cmd.Flags().Lookup("remote-user") require.NotNil(t, remoteUserFlag) - assert.Equal(t, "root", remoteUserFlag.DefValue) + assert.Equal(t, defaultRemoteUser, remoteUserFlag.DefValue) skipScenariosFlag := cmd.Flags().Lookup("skip-scenarios") require.NotNil(t, skipScenariosFlag) @@ -156,26 +161,34 @@ func TestTestCmd_FilterFeatures(t *testing.T) { func TestTestCmd_GenerateDockerfile(t *testing.T) { t.Run("basic dockerfile", func(t *testing.T) { - df := GenerateDockerfileForTest("my-feature", "ubuntu:22.04", "root", nil) - assert.Contains(t, df, "FROM ubuntu:22.04") + cmd := &TestCmd{BaseImage: testBaseImage, RemoteUser: defaultRemoteUser} + feat := featureEntry{id: testFeatureID} + df := cmd.generateDockerfile(feat, nil) + assert.Contains(t, df, "FROM "+testBaseImage) assert.Contains(t, df, "COPY src/my-feature /tmp/build-features/my-feature") assert.Contains(t, df, "RUN chmod +x /tmp/build-features/my-feature/install.sh") assert.NotContains(t, df, "USER") }) t.Run("with remote user", func(t *testing.T) { - df := GenerateDockerfileForTest("my-feature", "ubuntu:22.04", "vscode", nil) + cmd := &TestCmd{BaseImage: testBaseImage, RemoteUser: "vscode"} + feat := featureEntry{id: testFeatureID} + df := cmd.generateDockerfile(feat, nil) assert.Contains(t, df, "USER vscode") }) t.Run("with options", func(t *testing.T) { + cmd := &TestCmd{BaseImage: testBaseImage, RemoteUser: defaultRemoteUser} + feat := featureEntry{id: "go"} opts := map[string]string{"version": "1.21"} - df := GenerateDockerfileForTest("go", "ubuntu:22.04", "root", opts) - assert.Contains(t, df, "ENV GO_VERSION=1.21") + df := cmd.generateDockerfile(feat, opts) + assert.Contains(t, df, "ENV GO_VERSION=\"1.21\"") }) t.Run("default base image", func(t *testing.T) { - df := GenerateDockerfileForTest("feat", defaultBaseImage, "root", nil) + cmd := &TestCmd{BaseImage: defaultBaseImage, RemoteUser: defaultRemoteUser} + feat := featureEntry{id: "feat"} + df := cmd.generateDockerfile(feat, nil) assert.Contains(t, df, "FROM "+defaultBaseImage) }) } @@ -219,7 +232,7 @@ func TestTestCmd_LoadScenarioOptions(t *testing.T) { func TestTestCmd_TestDiscovery(t *testing.T) { projectDir := t.TempDir() - srcDir := filepath.Join(projectDir, "src", "my-feature") + srcDir := filepath.Join(projectDir, "src", testFeatureID) require.NoError(t, os.MkdirAll(srcDir, 0o700)) require.NoError(t, os.WriteFile( filepath.Join(srcDir, "devcontainer-feature.json"), @@ -232,7 +245,7 @@ func TestTestCmd_TestDiscovery(t *testing.T) { 0o700, )) - testDir := filepath.Join(projectDir, "test", "my-feature") + testDir := filepath.Join(projectDir, "test", testFeatureID) require.NoError(t, os.MkdirAll(testDir, 0o700)) require.NoError(t, os.WriteFile( // #nosec G306 -- test scripts need executable permission filepath.Join(testDir, "test.sh"), @@ -253,10 +266,10 @@ func TestTestCmd_TestDiscovery(t *testing.T) { 0o600, )) - feat := featureEntry{id: "my-feature", config: nil} + feat := featureEntry{id: testFeatureID, config: nil} cmd := &TestCmd{ BaseImage: defaultBaseImage, - RemoteUser: "root", + RemoteUser: defaultRemoteUser, } featureTestDir := filepath.Join(projectDir, "test", feat.id) @@ -276,5 +289,5 @@ func TestTestCmd_TestDiscovery(t *testing.T) { df := strings.TrimSpace(cmd.generateDockerfile(feat, opts)) assert.Contains(t, df, "FROM "+defaultBaseImage) - assert.Contains(t, df, "MY-FEATURE_VERSION=3.11") + assert.Contains(t, df, `MY-FEATURE_VERSION="3.11"`) } From b7a97d9f02e8c9d75a058adb8ceaebd90e9f52ef Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 01:14:45 -0500 Subject: [PATCH 3/5] fix(cli): sanitize hyphenated ENV keys, move test helper to test file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hyphens with underscores in feature ID for ENV key generation (my-feature + version → MY_FEATURE_VERSION, not MY-FEATURE_VERSION) - Move generateDockerfile wrapper to test_test.go as test-only helper --- cmd/features/test.go | 8 +------- cmd/features/test_test.go | 16 ++++++++++------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/cmd/features/test.go b/cmd/features/test.go index f8672cab0..508af84b4 100644 --- a/cmd/features/test.go +++ b/cmd/features/test.go @@ -276,12 +276,6 @@ func (cmd *TestCmd) runTest( return result } -func (cmd *TestCmd) generateDockerfile( - feat featureEntry, options map[string]string, -) string { - return cmd.generateDockerfileWithTest(feat, options, "") -} - func (cmd *TestCmd) generateDockerfileWithTest( feat featureEntry, options map[string]string, testScriptRelPath string, ) string { @@ -293,7 +287,7 @@ func (cmd *TestCmd) generateDockerfileWithTest( fmt.Fprintf(&b, "COPY %s /tmp/build-features/%s\n", featureSrcDir, feat.id) for k, v := range options { - envKey := strings.ToUpper(feat.id) + "_" + strings.ToUpper(k) + envKey := strings.ReplaceAll(strings.ToUpper(feat.id), "-", "_") + "_" + strings.ToUpper(k) fmt.Fprintf(&b, "ENV %s=%q\n", envKey, v) } diff --git a/cmd/features/test_test.go b/cmd/features/test_test.go index 7d0e727bb..9457c57db 100644 --- a/cmd/features/test_test.go +++ b/cmd/features/test_test.go @@ -163,7 +163,7 @@ func TestTestCmd_GenerateDockerfile(t *testing.T) { t.Run("basic dockerfile", func(t *testing.T) { cmd := &TestCmd{BaseImage: testBaseImage, RemoteUser: defaultRemoteUser} feat := featureEntry{id: testFeatureID} - df := cmd.generateDockerfile(feat, nil) + df := generateDockerfileForTest(cmd, feat, nil) assert.Contains(t, df, "FROM "+testBaseImage) assert.Contains(t, df, "COPY src/my-feature /tmp/build-features/my-feature") assert.Contains(t, df, "RUN chmod +x /tmp/build-features/my-feature/install.sh") @@ -173,7 +173,7 @@ func TestTestCmd_GenerateDockerfile(t *testing.T) { t.Run("with remote user", func(t *testing.T) { cmd := &TestCmd{BaseImage: testBaseImage, RemoteUser: "vscode"} feat := featureEntry{id: testFeatureID} - df := cmd.generateDockerfile(feat, nil) + df := generateDockerfileForTest(cmd, feat, nil) assert.Contains(t, df, "USER vscode") }) @@ -181,14 +181,14 @@ func TestTestCmd_GenerateDockerfile(t *testing.T) { cmd := &TestCmd{BaseImage: testBaseImage, RemoteUser: defaultRemoteUser} feat := featureEntry{id: "go"} opts := map[string]string{"version": "1.21"} - df := cmd.generateDockerfile(feat, opts) + df := generateDockerfileForTest(cmd, feat, opts) assert.Contains(t, df, "ENV GO_VERSION=\"1.21\"") }) t.Run("default base image", func(t *testing.T) { cmd := &TestCmd{BaseImage: defaultBaseImage, RemoteUser: defaultRemoteUser} feat := featureEntry{id: "feat"} - df := cmd.generateDockerfile(feat, nil) + df := generateDockerfileForTest(cmd, feat, nil) assert.Contains(t, df, "FROM "+defaultBaseImage) }) } @@ -287,7 +287,11 @@ func TestTestCmd_TestDiscovery(t *testing.T) { opts := cmd.loadScenarioOptions(scenarioDir) assert.Equal(t, "3.11", opts["version"]) - df := strings.TrimSpace(cmd.generateDockerfile(feat, opts)) + df := strings.TrimSpace(generateDockerfileForTest(cmd, feat, opts)) assert.Contains(t, df, "FROM "+defaultBaseImage) - assert.Contains(t, df, `MY-FEATURE_VERSION="3.11"`) + assert.Contains(t, df, `MY_FEATURE_VERSION="3.11"`) +} + +func generateDockerfileForTest(cmd *TestCmd, feat featureEntry, options map[string]string) string { + return cmd.generateDockerfileWithTest(feat, options, "") } From d5de376d4ac2083c7ebd21a7174bc2975a1f9aa4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 01:21:18 -0500 Subject: [PATCH 4/5] fix(e2e): extract repeated string literals into constants to satisfy goconst Extracts "features" and "--project-folder" into package-level constants to resolve golangci-lint goconst violations. --- e2e/tests/features/features_test_cmd.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/e2e/tests/features/features_test_cmd.go b/e2e/tests/features/features_test_cmd.go index 35e9a8d5f..45e438cba 100644 --- a/e2e/tests/features/features_test_cmd.go +++ b/e2e/tests/features/features_test_cmd.go @@ -11,6 +11,11 @@ import ( "github.com/onsi/gomega" ) +const ( + cmdFeatures = "features" + flagProjectFolder = "--project-folder" +) + var _ = ginkgo.Describe("features test", ginkgo.Label("features", "features-test"), func() { var initialDir string @@ -57,8 +62,8 @@ var _ = ginkgo.Describe("features test", ginkgo.Label("features", "features-test )) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "test", - "--project-folder", projectDir, + cmdFeatures, "test", + flagProjectFolder, projectDir, }) framework.ExpectNoError(err) @@ -104,8 +109,8 @@ var _ = ginkgo.Describe("features test", ginkgo.Label("features", "features-test } stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "test", - "--project-folder", projectDir, + cmdFeatures, "test", + flagProjectFolder, projectDir, "--features", "feat-a", }) framework.ExpectNoError(err) @@ -149,8 +154,8 @@ var _ = ginkgo.Describe("features test", ginkgo.Label("features", "features-test )) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "test", - "--project-folder", projectDir, + cmdFeatures, "test", + flagProjectFolder, projectDir, }) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(stdout).To(gomega.ContainSubstring("FAIL")) From 6e7f87ccb08d9a49109bdf131d69735b5c8e7188 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 01:29:51 -0500 Subject: [PATCH 5/5] fix(e2e): extract all repeated string literals into constants for goconst Extracts "test", "feat-a", "src", "/bin", "devcontainer-feature.json", "install.sh", "test.sh", "my-feature", and "bad-feature" into package-level constants to resolve all remaining golangci-lint goconst violations. --- e2e/tests/features/features_test_cmd.go | 65 ++++++++++++++----------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/e2e/tests/features/features_test_cmd.go b/e2e/tests/features/features_test_cmd.go index 45e438cba..9e8e55e71 100644 --- a/e2e/tests/features/features_test_cmd.go +++ b/e2e/tests/features/features_test_cmd.go @@ -12,8 +12,17 @@ import ( ) const ( - cmdFeatures = "features" - flagProjectFolder = "--project-folder" + cmdFeatures = "features" + cmdTest = "test" + featureNameA = "feat-a" + flagProjectFolder = "--project-folder" + dirSrc = "src" + fileBinSuffix = "/bin" + fileDevcontainerJSON = "devcontainer-feature.json" + fileInstallSh = "install.sh" + fileTestSh = "test.sh" + featureNameMyFeature = "my-feature" + featureNameBadFeature = "bad-feature" ) var _ = ginkgo.Describe("features test", ginkgo.Label("features", "features-test"), func() { @@ -32,44 +41,44 @@ var _ = ginkgo.Describe("features test", ginkgo.Label("features", "features-test ginkgo.It( "runs test scripts for discovered features", func(ctx context.Context) { - f := framework.NewDefaultFramework(initialDir + "/bin") + f := framework.NewDefaultFramework(initialDir + fileBinSuffix) projectDir, err := os.MkdirTemp("", "e2e-features-test-*") framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(projectDir) }) - srcDir := filepath.Join(projectDir, "src", "my-feature") + srcDir := filepath.Join(projectDir, dirSrc, featureNameMyFeature) framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) framework.ExpectNoError(os.WriteFile( - filepath.Join(srcDir, "devcontainer-feature.json"), + filepath.Join(srcDir, fileDevcontainerJSON), []byte(`{"id":"my-feature","version":"1.0.0","name":"My Feature"}`), 0o600, )) // #nosec G306 -- test scripts must be executable framework.ExpectNoError(os.WriteFile( - filepath.Join(srcDir, "install.sh"), + filepath.Join(srcDir, fileInstallSh), []byte("#!/bin/bash\necho 'feature installed'\n"), 0o750, )) - testDir := filepath.Join(projectDir, "test", "my-feature") + testDir := filepath.Join(projectDir, cmdTest, featureNameMyFeature) framework.ExpectNoError(os.MkdirAll(testDir, 0o750)) // #nosec G306 -- test scripts must be executable framework.ExpectNoError(os.WriteFile( - filepath.Join(testDir, "test.sh"), + filepath.Join(testDir, fileTestSh), []byte("#!/bin/bash\necho 'test passed'\nexit 0\n"), 0o750, )) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - cmdFeatures, "test", + cmdFeatures, cmdTest, flagProjectFolder, projectDir, }) framework.ExpectNoError(err) gomega.Expect(stdout).To(gomega.ContainSubstring("Feature Test Results")) gomega.Expect(stdout).To(gomega.ContainSubstring("PASS")) - gomega.Expect(stdout).To(gomega.ContainSubstring("my-feature")) + gomega.Expect(stdout).To(gomega.ContainSubstring(featureNameMyFeature)) }, ginkgo.SpecTimeout(framework.TimeoutModerate()), ) @@ -77,45 +86,45 @@ var _ = ginkgo.Describe("features test", ginkgo.Label("features", "features-test ginkgo.It( "filters features with --features flag", func(ctx context.Context) { - f := framework.NewDefaultFramework(initialDir + "/bin") + f := framework.NewDefaultFramework(initialDir + fileBinSuffix) projectDir, err := os.MkdirTemp("", "e2e-features-test-filter-*") framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(projectDir) }) - for _, feat := range []string{"feat-a", "feat-b"} { - srcDir := filepath.Join(projectDir, "src", feat) + for _, feat := range []string{featureNameA, "feat-b"} { + srcDir := filepath.Join(projectDir, dirSrc, feat) framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) framework.ExpectNoError(os.WriteFile( - filepath.Join(srcDir, "devcontainer-feature.json"), + filepath.Join(srcDir, fileDevcontainerJSON), []byte(`{"id":"`+feat+`","version":"1.0.0","name":"`+feat+`"}`), 0o600, )) // #nosec G306 -- test scripts must be executable framework.ExpectNoError(os.WriteFile( - filepath.Join(srcDir, "install.sh"), + filepath.Join(srcDir, fileInstallSh), []byte("#!/bin/bash\necho installed\n"), 0o750, )) - testDir := filepath.Join(projectDir, "test", feat) + testDir := filepath.Join(projectDir, cmdTest, feat) framework.ExpectNoError(os.MkdirAll(testDir, 0o750)) // #nosec G306 -- test scripts must be executable framework.ExpectNoError(os.WriteFile( - filepath.Join(testDir, "test.sh"), + filepath.Join(testDir, fileTestSh), []byte("#!/bin/bash\nexit 0\n"), 0o750, )) } stdout, _, err := f.ExecCommandCapture(ctx, []string{ - cmdFeatures, "test", + cmdFeatures, cmdTest, flagProjectFolder, projectDir, - "--features", "feat-a", + "--features", featureNameA, }) framework.ExpectNoError(err) - gomega.Expect(stdout).To(gomega.ContainSubstring("feat-a")) + gomega.Expect(stdout).To(gomega.ContainSubstring(featureNameA)) gomega.Expect(stdout).NotTo(gomega.ContainSubstring("feat-b")) }, ginkgo.SpecTimeout(framework.TimeoutModerate()), @@ -124,42 +133,42 @@ var _ = ginkgo.Describe("features test", ginkgo.Label("features", "features-test ginkgo.It( "reports failure when test script exits non-zero", func(ctx context.Context) { - f := framework.NewDefaultFramework(initialDir + "/bin") + f := framework.NewDefaultFramework(initialDir + fileBinSuffix) projectDir, err := os.MkdirTemp("", "e2e-features-test-fail-*") framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(projectDir) }) - srcDir := filepath.Join(projectDir, "src", "bad-feature") + srcDir := filepath.Join(projectDir, dirSrc, featureNameBadFeature) framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) framework.ExpectNoError(os.WriteFile( - filepath.Join(srcDir, "devcontainer-feature.json"), + filepath.Join(srcDir, fileDevcontainerJSON), []byte(`{"id":"bad-feature","version":"1.0.0","name":"Bad Feature"}`), 0o600, )) // #nosec G306 -- test scripts must be executable framework.ExpectNoError(os.WriteFile( - filepath.Join(srcDir, "install.sh"), + filepath.Join(srcDir, fileInstallSh), []byte("#!/bin/bash\necho installed\n"), 0o750, )) - testDir := filepath.Join(projectDir, "test", "bad-feature") + testDir := filepath.Join(projectDir, cmdTest, featureNameBadFeature) framework.ExpectNoError(os.MkdirAll(testDir, 0o750)) // #nosec G306 -- test scripts must be executable framework.ExpectNoError(os.WriteFile( - filepath.Join(testDir, "test.sh"), + filepath.Join(testDir, fileTestSh), []byte("#!/bin/bash\necho 'test failed'\nexit 1\n"), 0o750, )) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - cmdFeatures, "test", + cmdFeatures, cmdTest, flagProjectFolder, projectDir, }) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(stdout).To(gomega.ContainSubstring("FAIL")) - gomega.Expect(stdout).To(gomega.ContainSubstring("bad-feature")) + gomega.Expect(stdout).To(gomega.ContainSubstring(featureNameBadFeature)) }, ginkgo.SpecTimeout(framework.TimeoutModerate()), )