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..508af84b4 --- /dev/null +++ b/cmd/features/test.go @@ -0,0 +1,372 @@ +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" + defaultRemoteUser = "root" +) + +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", defaultRemoteUser, + "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) { + docs, err := scanFeatures(srcDir) + if err != nil { + return nil, err + } + + features := make([]featureEntry, 0, len(docs)) + for _, doc := range docs { + features = append(features, featureEntry{ + id: doc.dir, + config: doc.config, + }) + } + 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, + } + + 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 != "" { + 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) + 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) generateDockerfileWithTest( + feat featureEntry, options map[string]string, testScriptRelPath 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.ReplaceAll(strings.ToUpper(feat.id), "-", "_") + "_" + strings.ToUpper(k) + fmt.Fprintf(&b, "ENV %s=%q\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 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) + } + + 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 string) error { + args := []string{"run", "--name", containerName} + if !cmd.PreserveTestContainers { + args = append(args, "--rm") + } + args = append(args, imageName, "/tmp/test.sh") + + 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) +} diff --git a/cmd/features/test_test.go b/cmd/features/test_test.go new file mode 100644 index 000000000..9457c57db --- /dev/null +++ b/cmd/features/test_test.go @@ -0,0 +1,297 @@ +package features + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testBaseImage = "ubuntu:22.04" + testFeatureID = "my-feature" +) + +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, defaultRemoteUser, 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) { + cmd := &TestCmd{BaseImage: testBaseImage, RemoteUser: defaultRemoteUser} + feat := featureEntry{id: testFeatureID} + 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") + assert.NotContains(t, df, "USER") + }) + + t.Run("with remote user", func(t *testing.T) { + cmd := &TestCmd{BaseImage: testBaseImage, RemoteUser: "vscode"} + feat := featureEntry{id: testFeatureID} + df := generateDockerfileForTest(cmd, 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(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 := generateDockerfileForTest(cmd, feat, 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", testFeatureID) + 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", 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"), + []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: testFeatureID, config: nil} + cmd := &TestCmd{ + BaseImage: defaultBaseImage, + RemoteUser: defaultRemoteUser, + } + + 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(generateDockerfileForTest(cmd, feat, opts)) + assert.Contains(t, df, "FROM "+defaultBaseImage) + 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, "") +} diff --git a/e2e/tests/features/features_test_cmd.go b/e2e/tests/features/features_test_cmd.go new file mode 100644 index 000000000..9e8e55e71 --- /dev/null +++ b/e2e/tests/features/features_test_cmd.go @@ -0,0 +1,175 @@ +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" +) + +const ( + 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() { + 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 + fileBinSuffix) + + projectDir, err := os.MkdirTemp("", "e2e-features-test-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(projectDir) }) + + srcDir := filepath.Join(projectDir, dirSrc, featureNameMyFeature) + framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + 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, fileInstallSh), + []byte("#!/bin/bash\necho 'feature installed'\n"), + 0o750, + )) + + 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, fileTestSh), + []byte("#!/bin/bash\necho 'test passed'\nexit 0\n"), + 0o750, + )) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + 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(featureNameMyFeature)) + }, + ginkgo.SpecTimeout(framework.TimeoutModerate()), + ) + + ginkgo.It( + "filters features with --features flag", + func(ctx context.Context) { + 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{featureNameA, "feat-b"} { + srcDir := filepath.Join(projectDir, dirSrc, feat) + framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + 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, fileInstallSh), + []byte("#!/bin/bash\necho installed\n"), + 0o750, + )) + + 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, fileTestSh), + []byte("#!/bin/bash\nexit 0\n"), + 0o750, + )) + } + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdTest, + flagProjectFolder, projectDir, + "--features", featureNameA, + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring(featureNameA)) + 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 + fileBinSuffix) + + projectDir, err := os.MkdirTemp("", "e2e-features-test-fail-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(projectDir) }) + + srcDir := filepath.Join(projectDir, dirSrc, featureNameBadFeature) + framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + 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, fileInstallSh), + []byte("#!/bin/bash\necho installed\n"), + 0o750, + )) + + 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, fileTestSh), + []byte("#!/bin/bash\necho 'test failed'\nexit 1\n"), + 0o750, + )) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdTest, + flagProjectFolder, projectDir, + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(stdout).To(gomega.ContainSubstring("FAIL")) + gomega.Expect(stdout).To(gomega.ContainSubstring(featureNameBadFeature)) + }, + ginkgo.SpecTimeout(framework.TimeoutModerate()), + ) +})