diff --git a/acceptance/sandbox_image_test.go b/acceptance/sandbox_image_test.go new file mode 100644 index 00000000..c27a6dd7 --- /dev/null +++ b/acceptance/sandbox_image_test.go @@ -0,0 +1,119 @@ +package acceptance + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +// TestSandboxImageCommands verifies that the commands chunk would run in the +// sandbox actually work inside the sandbox image. +// +// Enable with: CHUNK_SANDBOX_IMAGE_TEST=1 +// Requires: docker accessible without sudo. +func TestSandboxImageCommands(t *testing.T) { + if os.Getenv("CHUNK_SANDBOX_IMAGE_TEST") == "" { + t.Skip("set CHUNK_SANDBOX_IMAGE_TEST=1 to run") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not found in PATH") + } + if err := exec.Command("docker", "info").Run(); err != nil { + t.Skip("docker daemon not available") + } + + repoRoot, err := filepath.Abs("..") + assert.NilError(t, err) + + imageTag := "chunk-sandbox-image-test" + + t.Log("Building Dockerfile.sandbox...") + buildCtx, buildCancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer buildCancel() + + buildCmd := exec.CommandContext(buildCtx, "docker", "build", + "-f", "Dockerfile.sandbox", + "-t", imageTag, + ".", + ) + buildCmd.Dir = repoRoot + out, err := buildCmd.CombinedOutput() + assert.NilError(t, err, "docker build failed:\n%s", string(out)) + + t.Cleanup(func() { + _ = exec.Command("docker", "rmi", imageTag).Run() + }) + + tests := []struct { + name string + command []string + wantOutput string + }{ + { + name: "apt-get", + command: []string{"apt-get", "--version"}, + wantOutput: "apt", + }, + { + name: "git", + command: []string{"git", "--version"}, + wantOutput: "git version", + }, + { + name: "go", + command: []string{"go", "version"}, + wantOutput: "go version go", + }, + { + name: "gofmt", + command: []string{"sh", "-c", `echo "package main" | gofmt`}, + wantOutput: "package main", + }, + { + name: "task", + command: []string{"task", "--help"}, + wantOutput: "task", + }, + { + name: "sh", + command: []string{"sh", "-c", "echo ok"}, + wantOutput: "ok", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := append([]string{"run", "--rm", imageTag}, tt.command...) + + runCtx, runCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer runCancel() + + cmd := exec.CommandContext(runCtx, "docker", args...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + + runErr := cmd.Run() + exitCode := 0 + if runErr != nil { + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + exitCode = exitErr.ExitCode() + } + } + + assert.Equal(t, exitCode, 0, + "%v exited %d\noutput: %s", tt.command, exitCode, buf.String()) + assert.Assert(t, strings.Contains(buf.String(), tt.wantOutput), + "expected %q in output of %v\ngot: %s", tt.wantOutput, tt.command, buf.String()) + }) + } +} diff --git a/internal/validate/setup_test.go b/internal/validate/setup_test.go new file mode 100644 index 00000000..8361dd3d --- /dev/null +++ b/internal/validate/setup_test.go @@ -0,0 +1,237 @@ +package validate + +import ( + "context" + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" + + "github.com/CircleCI-Public/chunk-cli/internal/config" +) + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + assert.NilError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) +} + +func TestDetectCommands(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + wantRuns []string + wantRoles []string + }{ + { + name: "taskfile + go", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "Taskfile.yml", "version: '3'\n") + writeFile(t, dir, "go.mod", "module example.com/test\n\ngo 1.22\n") + }, + wantRuns: []string{"task test", "task test -- {{CHANGED_PACKAGES}}", "task lint", "task fmt"}, + wantRoles: []string{config.RoleGate, config.RolePrecheck, config.RoleGate, config.RoleAutofix}, + }, + { + name: "taskfile only", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "Taskfile.yml", "version: '3'\n") + }, + wantRuns: []string{"task test"}, + wantRoles: []string{config.RoleGate}, + }, + { + name: "taskfile yaml extension", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "Taskfile.yaml", "version: '3'\n") + }, + wantRuns: []string{"task test"}, + wantRoles: []string{config.RoleGate}, + }, + { + name: "makefile + go", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "Makefile", "test:\n\tgo test ./...\n") + writeFile(t, dir, "go.mod", "module example.com/test\n\ngo 1.22\n") + }, + wantRuns: []string{"make test", "make lint"}, + wantRoles: []string{config.RoleGate, config.RoleGate}, + }, + { + name: "makefile only", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "Makefile", "test:\n\techo ok\n") + }, + wantRuns: []string{"make test"}, + wantRoles: []string{config.RoleGate}, + }, + { + name: "go only", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "go.mod", "module example.com/test\n\ngo 1.22\n") + }, + wantRuns: []string{"go test ./...", "golangci-lint run ./...", "gofmt -w ."}, + wantRoles: []string{config.RoleGate, config.RoleGate, config.RoleAutofix}, + }, + { + name: "rust", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "Cargo.toml", "[package]\nname = \"test\"\n") + }, + wantRuns: []string{"cargo test"}, + wantRoles: []string{config.RoleGate}, + }, + { + name: "python", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "pyproject.toml", "[tool.pytest.ini_options]\n") + }, + wantRuns: []string{"pytest"}, + wantRoles: []string{config.RoleGate}, + }, + { + name: "node npm", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "package.json", `{"name":"test"}`) + writeFile(t, dir, "package-lock.json", `{"lockfileVersion":3}`) + }, + wantRuns: []string{"npm test"}, + wantRoles: []string{config.RoleGate}, + }, + { + name: "node pnpm", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "package.json", `{"name":"test"}`) + writeFile(t, dir, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") + }, + wantRuns: []string{"pnpm test"}, + wantRoles: []string{config.RoleGate}, + }, + { + name: "node yarn", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "package.json", `{"name":"test"}`) + writeFile(t, dir, "yarn.lock", "# yarn lockfile v1\n") + }, + wantRuns: []string{"yarn test"}, + wantRoles: []string{config.RoleGate}, + }, + { + name: "node bun", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "package.json", `{"name":"test"}`) + writeFile(t, dir, "bun.lock", "# bun lockfile\n") + }, + wantRuns: []string{"bun test"}, + wantRoles: []string{config.RoleGate}, + }, + { + name: "default fallback", + setup: func(t *testing.T, dir string) {}, + wantRuns: []string{"npm test"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + + cmds, err := DetectCommands(context.Background(), nil, dir) + assert.NilError(t, err) + assert.Equal(t, len(cmds), len(tt.wantRuns), + "expected %d commands, got %d: %v", len(tt.wantRuns), len(cmds), cmds) + + for i, wantRun := range tt.wantRuns { + assert.Equal(t, cmds[i].Run, wantRun, + "command[%d].Run: expected %q, got %q", i, wantRun, cmds[i].Run) + } + for i, wantRole := range tt.wantRoles { + assert.Equal(t, cmds[i].Role, wantRole, + "command[%d].Role: expected %q, got %q", i, wantRole, cmds[i].Role) + } + }) + } +} + +func TestDetectCommandsGoFileExt(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "go.mod", "module example.com/test\n\ngo 1.22\n") + + cmds, err := DetectCommands(context.Background(), nil, dir) + assert.NilError(t, err) + + for _, c := range cmds { + if c.Role != config.RoleAutofix { + assert.Equal(t, c.FileExt, ".go", + "command %q: expected FileExt=.go, got %q", c.Run, c.FileExt) + } + } +} + +func TestDetectPackageManager(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + wantName string + }{ + { + name: "pnpm", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") + }, + wantName: "pnpm", + }, + { + name: "yarn", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "yarn.lock", "# yarn lockfile v1\n") + }, + wantName: "yarn", + }, + { + name: "bun lockb", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "bun.lockb", "") + }, + wantName: "bun", + }, + { + name: "npm", + setup: func(t *testing.T, dir string) { + writeFile(t, dir, "package-lock.json", `{"lockfileVersion":3}`) + }, + wantName: "npm", + }, + { + name: "monorepo subdir", + setup: func(t *testing.T, dir string) { + // DetectPackageManager searches one level deep; lockfile in a direct subdir. + subdir := filepath.Join(dir, "packages") + assert.NilError(t, os.MkdirAll(subdir, 0o755)) + writeFile(t, subdir, "yarn.lock", "# yarn lockfile v1\n") + }, + wantName: "yarn", + }, + { + name: "none", + setup: func(t *testing.T, dir string) {}, + wantName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + + pm := DetectPackageManager(dir) + if tt.wantName == "" { + assert.Assert(t, pm == nil, "expected nil PackageManager, got: %v", pm) + } else { + assert.Assert(t, pm != nil, "expected PackageManager %q, got nil", tt.wantName) + assert.Equal(t, pm.Name, tt.wantName) + } + }) + } +} diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index b1a31a66..eff3fdf5 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -501,6 +501,111 @@ func TestRunRemoteSSH(t *testing.T) { }) } +// TestDetectCommandsAndRunRemoteSSH verifies the full pipeline: +// DetectCommands → RunRemote → SSH, confirming detected commands arrive at the +// sandbox correctly formatted. +func TestDetectCommandsAndRunRemoteSSH(t *testing.T) { + newCCIClient := func(t *testing.T, serverURL string) *circleci.Client { + t.Helper() + t.Setenv("CIRCLECI_BASE_URL", serverURL) + t.Setenv("CIRCLE_TOKEN", "test-token") + client, err := circleci.NewClient() + assert.NilError(t, err) + return client + } + + execCallback := func(t *testing.T, session *sandbox.Session) func(context.Context, string) (string, string, int, error) { + t.Helper() + return func(ctx context.Context, script string) (string, string, int, error) { + result, err := sandbox.ExecOverSSH(ctx, session, "sh -c "+sandbox.ShellEscape(script), nil, nil) + if err != nil { + return "", "", 0, err + } + return result.Stdout, result.Stderr, result.ExitCode, nil + } + } + + setup := func(t *testing.T) (session *sandbox.Session, sshSrv *fakes.SSHServer) { + t.Helper() + keyFile, pubKey := fakes.GenerateSSHKeypair(t) + sshSrv = fakes.NewSSHServer(t, pubKey) + sshSrv.SetResult("", 0) + + cci := fakes.NewFakeCircleCI() + cci.AddKeyURL = sshSrv.Addr() + cciSrv := httptest.NewServer(cci) + t.Cleanup(cciSrv.Close) + + t.Setenv("HOME", t.TempDir()) + client := newCCIClient(t, cciSrv.URL) + var err error + session, err = sandbox.OpenSession(context.Background(), client, "sandbox-123", keyFile, "") + assert.NilError(t, err) + return session, sshSrv + } + + t.Run("go project commands sent to sandbox", func(t *testing.T) { + dir := t.TempDir() + assert.NilError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/test\n\ngo 1.22\n"), 0o644)) + + cmds, err := DetectCommands(context.Background(), nil, dir) + assert.NilError(t, err) + assert.Equal(t, len(cmds), 3) // go test ./..., golangci-lint run ./..., gofmt -w . + + session, sshSrv := setup(t) + cfg := &config.ProjectConfig{Commands: cmds} + streams, _, _ := newStreams() + + assert.NilError(t, RunRemote(context.Background(), execCallback(t, session), cfg, "/workspace/repo", streams)) + + sent := sshSrv.Commands() + assert.Equal(t, len(sent), 3, "expected 3 SSH commands, got: %v", sent) + assert.Assert(t, strings.Contains(sent[0], "go test ./..."), "got: %s", sent[0]) + assert.Assert(t, strings.Contains(sent[0], "/workspace/repo"), "got: %s", sent[0]) + }) + + t.Run("node project commands sent to sandbox", func(t *testing.T) { + dir := t.TempDir() + assert.NilError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test"}`), 0o644)) + assert.NilError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{"lockfileVersion":3}`), 0o644)) + + cmds, err := DetectCommands(context.Background(), nil, dir) + assert.NilError(t, err) + assert.Equal(t, len(cmds), 1) + assert.Equal(t, cmds[0].Run, "npm test") + + session, sshSrv := setup(t) + cfg := &config.ProjectConfig{Commands: cmds} + streams, _, _ := newStreams() + + assert.NilError(t, RunRemote(context.Background(), execCallback(t, session), cfg, "/workspace/repo", streams)) + + sent := sshSrv.Commands() + assert.Equal(t, len(sent), 1) + assert.Assert(t, strings.Contains(sent[0], "npm test"), "got: %s", sent[0]) + }) + + t.Run("taskfile project commands sent to sandbox", func(t *testing.T) { + dir := t.TempDir() + assert.NilError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte("version: '3'\n"), 0o644)) + + cmds, err := DetectCommands(context.Background(), nil, dir) + assert.NilError(t, err) + assert.Equal(t, len(cmds), 1) + assert.Equal(t, cmds[0].Run, "task test") + + session, sshSrv := setup(t) + cfg := &config.ProjectConfig{Commands: cmds} + streams, _, _ := newStreams() + + assert.NilError(t, RunRemote(context.Background(), execCallback(t, session), cfg, "/workspace/repo", streams)) + + sent := sshSrv.Commands() + assert.Equal(t, len(sent), 1) + assert.Assert(t, strings.Contains(sent[0], "task test"), "got: %s", sent[0]) + }) +} + func initGitRepo(t *testing.T, dir string) { t.Helper() for _, args := range [][]string{