package integration_tests import ( "bufio" "context" "encoding/json" "flag" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/google/uuid" "github.com/railwayapp/railpack/buildkit" "github.com/railwayapp/railpack/core" "github.com/railwayapp/railpack/core/app" "github.com/stretchr/testify/require" ) var buildkitCacheImport = flag.String("buildkit-cache-import", "", "BuildKit cache import configuration") var buildkitCacheExport = flag.String("buildkit-cache-export", "", "BuildKit cache export configuration") type TestCase struct { ExpectedOutput string `json:"expectedOutput"` Envs map[string]string `json:"envs"` ConfigFilePath string `json:"configFile"` JustBuild bool `json:"justBuild"` } func TestExamplesIntegration(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } wd, err := os.Getwd() require.NoError(t, err) examplesDir := filepath.Join(filepath.Dir(wd), "examples") entries, err := os.ReadDir(examplesDir) require.NoError(t, err) for _, entry := range entries { entry := entry // capture for parallel execution if !entry.IsDir() { continue } testConfigPath := filepath.Join(examplesDir, entry.Name(), "test.json") if _, err := os.Stat(testConfigPath); os.IsNotExist(err) { continue } testConfigBytes, err := os.ReadFile(testConfigPath) require.NoError(t, err) var testCases []TestCase err = json.Unmarshal(testConfigBytes, &testCases) require.NoError(t, err) for i, testCase := range testCases { testCase := testCase // capture for parallel execution i := i testName := fmt.Sprintf("%s/case-%d", entry.Name(), i) t.Run(testName, func(t *testing.T) { t.Parallel() examplePath := filepath.Join(examplesDir, entry.Name()) userApp, err := app.NewApp(examplePath) if err != nil { t.Fatalf("failed to create app: %v", err) } env := app.NewEnvironment(&testCase.Envs) buildResult := core.GenerateBuildPlan(userApp, env, &core.GenerateBuildPlanOptions{ ConfigFilePath: testCase.ConfigFilePath, }) if !buildResult.Success { t.Fatalf("failed to generate build plan: %v", buildResult.Logs) } if buildResult == nil { t.Fatal("build result is nil") } imageName := fmt.Sprintf("railpack-test-%s-%s", strings.ToLower(strings.ReplaceAll(testName, "/", "-")), strings.ToLower(uuid.New().String())) if err := buildkit.BuildWithBuildkitClient(examplePath, buildResult.Plan, buildkit.BuildWithBuildkitClientOptions{ ImageName: imageName, ImportCache: *buildkitCacheImport, ExportCache: *buildkitCacheExport, Secrets: testCase.Envs, }); err != nil { t.Fatalf("failed to build image: %v", err) } if testCase.JustBuild { return } if err := runContainerWithTimeout(t, imageName, testCase.ExpectedOutput, testCase.Envs); err != nil { t.Fatal(err) } }) } } } func cmdDoneChan(cmd *exec.Cmd) chan error { ch := make(chan error, 1) go func() { ch <- cmd.Wait() }() return ch } func runContainerWithTimeout(t *testing.T, imageName, expectedOutput string, envs map[string]string) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() // Generate a unique container name so we can reference it later for cleanup containerName := fmt.Sprintf("railpack-test-%s", uuid.New().String()) // Build docker run command with environment variables args := []string{"run", "--rm", "--name", containerName} for key, value := range envs { args = append(args, "-e", fmt.Sprintf("%s=%s", key, value)) } args = append(args, imageName) cmd := exec.CommandContext(ctx, "docker", args...) stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("failed to create stdout pipe: %v", err) } stderr, err := cmd.StderrPipe() if err != nil { return fmt.Errorf("failed to create stderr pipe: %v", err) } if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start container: %v", err) } // Ensure cleanup on function exit defer func() { // Stop the container if it's still running stopCmd := exec.Command("docker", "stop", containerName) _ = stopCmd.Run() // Remove the container if it still exists rmCmd := exec.Command("docker", "rm", "-f", containerName) _ = rmCmd.Run() }() var output, errOutput strings.Builder done := make(chan error, 1) go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() output.WriteString(line + "\n") if strings.Contains(line, expectedOutput) { done <- nil return } } if err := scanner.Err(); err != nil { done <- fmt.Errorf("error reading stdout: %v", err) return } done <- fmt.Errorf("container output:\n%s\nErrors:\n%s", output.String(), errOutput.String()) }() go func() { scanner := bufio.NewScanner(stderr) for scanner.Scan() { errOutput.WriteString(scanner.Text() + "\n") } }() select { case <-ctx.Done(): return fmt.Errorf("container timed out after 2 minutes") case err := <-done: if err != nil { require.Contains(t, output.String(), expectedOutput, "container output did not contain expected string") return err } return nil case err := <-cmdDoneChan(cmd): if err != nil && !strings.Contains(err.Error(), "signal: killed") { return fmt.Errorf("container failed: %v", err) } require.Contains(t, output.String(), expectedOutput, "container output did not contain expected string") return nil } }