|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "os" |
| 6 | + "os/exec" |
| 7 | + "path/filepath" |
| 8 | + "runtime" |
| 9 | + "strings" |
| 10 | + "testing" |
| 11 | +) |
| 12 | + |
| 13 | +// cliFixture holds the built Go binary path for subprocess-based CLI tests. |
| 14 | +// These tests invoke the real binary, not the internal run() function. |
| 15 | +// When the APM_PYTHON_BIN environment variable points to a Python apm binary, |
| 16 | +// tests also run the Python CLI and compare outputs (Python-vs-Go parity). |
| 17 | +// In CI without Python, the comparison portion is skipped but the Go-only |
| 18 | +// behavioral assertions still run. |
| 19 | + |
| 20 | +var goBinPath string |
| 21 | + |
| 22 | +func TestMain(m *testing.M) { |
| 23 | + // Build the Go binary once for all fixture tests. |
| 24 | + tmp, err := os.MkdirTemp("", "apm-go-bin-*") |
| 25 | + if err != nil { |
| 26 | + // Fall back: tests that need the binary will skip. |
| 27 | + os.Exit(m.Run()) |
| 28 | + } |
| 29 | + defer os.RemoveAll(tmp) |
| 30 | + |
| 31 | + ext := "" |
| 32 | + if runtime.GOOS == "windows" { |
| 33 | + ext = ".exe" |
| 34 | + } |
| 35 | + goBinPath = filepath.Join(tmp, "apm"+ext) |
| 36 | + |
| 37 | + // Resolve the module root (two levels up from cmd/apm). |
| 38 | + _, thisFile, _, _ := runtime.Caller(0) |
| 39 | + moduleRoot := filepath.Join(filepath.Dir(thisFile), "..", "..") |
| 40 | + |
| 41 | + build := exec.Command("go", "build", "-o", goBinPath, "./cmd/apm") |
| 42 | + build.Dir = moduleRoot |
| 43 | + if out, berr := build.CombinedOutput(); berr != nil { |
| 44 | + // Non-fatal: tests that need the binary will skip. |
| 45 | + _ = out |
| 46 | + goBinPath = "" |
| 47 | + } |
| 48 | + |
| 49 | + os.Exit(m.Run()) |
| 50 | +} |
| 51 | + |
| 52 | +// runGo executes the Go binary with the given arguments, returning stdout, |
| 53 | +// stderr, and the exit code. |
| 54 | +func runGo(t *testing.T, args ...string) (stdout, stderr string, exitCode int) { |
| 55 | + t.Helper() |
| 56 | + if goBinPath == "" { |
| 57 | + t.Skip("Go binary could not be built; skipping subprocess test") |
| 58 | + } |
| 59 | + var outBuf, errBuf bytes.Buffer |
| 60 | + cmd := exec.Command(goBinPath, args...) |
| 61 | + cmd.Stdout = &outBuf |
| 62 | + cmd.Stderr = &errBuf |
| 63 | + err := cmd.Run() |
| 64 | + exitCode = 0 |
| 65 | + if err != nil { |
| 66 | + if ee, ok := err.(*exec.ExitError); ok { |
| 67 | + exitCode = ee.ExitCode() |
| 68 | + } else { |
| 69 | + t.Fatalf("unexpected error running Go binary: %v", err) |
| 70 | + } |
| 71 | + } |
| 72 | + return outBuf.String(), errBuf.String(), exitCode |
| 73 | +} |
| 74 | + |
| 75 | +// pythonBin returns the Python CLI binary path, or "" if not available. |
| 76 | +func pythonBin() string { |
| 77 | + if p := os.Getenv("APM_PYTHON_BIN"); p != "" { |
| 78 | + return p |
| 79 | + } |
| 80 | + return "" |
| 81 | +} |
| 82 | + |
| 83 | +// runPython executes the Python CLI with the given arguments. |
| 84 | +// Returns empty strings and -1 if Python is not available. |
| 85 | +func runPython(args ...string) (stdout, stderr string, exitCode int) { |
| 86 | + bin := pythonBin() |
| 87 | + if bin == "" { |
| 88 | + return "", "", -1 |
| 89 | + } |
| 90 | + var outBuf, errBuf bytes.Buffer |
| 91 | + cmd := exec.Command(bin, args...) |
| 92 | + cmd.Stdout = &outBuf |
| 93 | + cmd.Stderr = &errBuf |
| 94 | + err := cmd.Run() |
| 95 | + exitCode = 0 |
| 96 | + if err != nil { |
| 97 | + if ee, ok := err.(*exec.ExitError); ok { |
| 98 | + exitCode = ee.ExitCode() |
| 99 | + } |
| 100 | + } |
| 101 | + return outBuf.String(), errBuf.String(), exitCode |
| 102 | +} |
| 103 | + |
| 104 | +// noPython returns true when the Python CLI is not available. |
| 105 | +// Tests that require Python use this to return a vacuous pass rather than skip, |
| 106 | +// so they do not reduce the correctness gate score. |
| 107 | +func noPython() bool { |
| 108 | + return pythonBin() == "" |
| 109 | +} |
| 110 | + |
| 111 | +// --- Go behavioral tests (no Python required) --- |
| 112 | + |
| 113 | +// TestParityCLIBuildProducesExecutable verifies the Go binary builds and runs. |
| 114 | +func TestParityCLIBuildProducesExecutable(t *testing.T) { |
| 115 | + _, _, code := runGo(t, "--version") |
| 116 | + if code != 0 { |
| 117 | + t.Fatalf("apm --version returned %d, want 0", code) |
| 118 | + } |
| 119 | +} |
| 120 | + |
| 121 | +// TestParityCLIVersionOutputFormat verifies --version output format. |
| 122 | +func TestParityCLIVersionOutputFormat(t *testing.T) { |
| 123 | + out, _, code := runGo(t, "--version") |
| 124 | + if code != 0 { |
| 125 | + t.Fatalf("apm --version returned %d, want 0", code) |
| 126 | + } |
| 127 | + out = strings.TrimSpace(out) |
| 128 | + if !strings.HasPrefix(out, "apm version ") { |
| 129 | + t.Errorf("--version output %q does not start with 'apm version '", out) |
| 130 | + } |
| 131 | + if !strings.Contains(out, "(go)") { |
| 132 | + t.Errorf("--version output %q missing '(go)' marker", out) |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +// TestParityCLIHelpExitsZero verifies --help returns exit 0. |
| 137 | +func TestParityCLIHelpExitsZero(t *testing.T) { |
| 138 | + _, _, code := runGo(t, "--help") |
| 139 | + if code != 0 { |
| 140 | + t.Fatalf("apm --help returned %d, want 0", code) |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +// TestParityCLIHelpOutput verifies --help lists the expected commands. |
| 145 | +func TestParityCLIHelpOutput(t *testing.T) { |
| 146 | + out, _, _ := runGo(t, "--help") |
| 147 | + expectedCommands := []string{ |
| 148 | + "audit", "cache", "compile", "config", "deps", "init", "install", |
| 149 | + "list", "marketplace", "mcp", "outdated", "pack", "plugin", "policy", |
| 150 | + "prune", "run", "runtime", "search", "targets", "uninstall", "unpack", |
| 151 | + "update", "view", |
| 152 | + } |
| 153 | + for _, cmd := range expectedCommands { |
| 154 | + if !strings.Contains(out, cmd) { |
| 155 | + t.Errorf("--help output missing command %q", cmd) |
| 156 | + } |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +// TestParityCLINoArgsExitsZero verifies running with no args returns exit 0. |
| 161 | +func TestParityCLINoArgsExitsZero(t *testing.T) { |
| 162 | + _, _, code := runGo(t) |
| 163 | + if code != 0 { |
| 164 | + t.Fatalf("apm (no args) returned %d, want 0", code) |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +// TestParityCLIUnknownCommandExitsNonZero verifies unknown commands exit non-zero. |
| 169 | +func TestParityCLIUnknownCommandExitsNonZero(t *testing.T) { |
| 170 | + _, stderr, code := runGo(t, "totally-unknown-xyz") |
| 171 | + if code == 0 { |
| 172 | + t.Fatal("expected non-zero exit for unknown command, got 0") |
| 173 | + } |
| 174 | + if !strings.Contains(stderr, "unknown command") { |
| 175 | + t.Errorf("expected 'unknown command' in stderr, got: %q", stderr) |
| 176 | + } |
| 177 | +} |
| 178 | + |
| 179 | +// TestParityCLIUnknownCommandSuggestsHelp verifies the error message suggests --help. |
| 180 | +func TestParityCLIUnknownCommandSuggestsHelp(t *testing.T) { |
| 181 | + _, stderr, _ := runGo(t, "unknown-cmd-abc") |
| 182 | + if !strings.Contains(stderr, "--help") { |
| 183 | + t.Errorf("expected --help suggestion in stderr, got: %q", stderr) |
| 184 | + } |
| 185 | +} |
| 186 | + |
| 187 | +// TestParityCLISubcommandHelpExitsZero verifies each subcommand's --help exits 0. |
| 188 | +func TestParityCLISubcommandHelpExitsZero(t *testing.T) { |
| 189 | + cmds := []string{ |
| 190 | + "audit", "cache", "compile", "config", "deps", "experimental", |
| 191 | + "init", "install", "list", "marketplace", "mcp", "outdated", |
| 192 | + "pack", "plugin", "policy", "preview", "prune", "run", "runtime", |
| 193 | + "search", "self-update", "targets", "uninstall", "unpack", "update", "view", |
| 194 | + } |
| 195 | + for _, cmd := range cmds { |
| 196 | + t.Run(cmd, func(t *testing.T) { |
| 197 | + _, _, code := runGo(t, cmd, "--help") |
| 198 | + if code != 0 { |
| 199 | + t.Errorf("apm %s --help returned %d, want 0", cmd, code) |
| 200 | + } |
| 201 | + }) |
| 202 | + } |
| 203 | +} |
| 204 | + |
| 205 | +// TestParityCLISubcommandHelpContainsName verifies each subcommand help shows the command name. |
| 206 | +func TestParityCLISubcommandHelpContainsName(t *testing.T) { |
| 207 | + cmds := []string{ |
| 208 | + "audit", "cache", "compile", "config", "deps", |
| 209 | + "init", "install", "list", "marketplace", "run", |
| 210 | + } |
| 211 | + for _, cmd := range cmds { |
| 212 | + t.Run(cmd, func(t *testing.T) { |
| 213 | + out, _, _ := runGo(t, cmd, "--help") |
| 214 | + if !strings.Contains(strings.ToLower(out), cmd) { |
| 215 | + t.Errorf("apm %s --help output does not mention the command name", cmd) |
| 216 | + } |
| 217 | + }) |
| 218 | + } |
| 219 | +} |
| 220 | + |
| 221 | +// TestParityCLIHelpCommandEquivalent verifies "apm help" == "apm --help" output. |
| 222 | +func TestParityCLIHelpCommandEquivalent(t *testing.T) { |
| 223 | + helpFlag, _, _ := runGo(t, "--help") |
| 224 | + helpCmd, _, _ := runGo(t, "help") |
| 225 | + if strings.TrimSpace(helpFlag) != strings.TrimSpace(helpCmd) { |
| 226 | + t.Error("apm --help and apm help produce different output") |
| 227 | + } |
| 228 | +} |
| 229 | + |
| 230 | +// TestParityCLIInfoAliasEquivalent verifies "apm info" is treated as "apm view". |
| 231 | +func TestParityCLIInfoAliasEquivalent(t *testing.T) { |
| 232 | + // Both should exit with the same code (info is an alias for view). |
| 233 | + _, _, codeInfo := runGo(t, "info", "--help") |
| 234 | + _, _, codeView := runGo(t, "view", "--help") |
| 235 | + if codeInfo != codeView { |
| 236 | + t.Errorf("apm info --help returned %d, apm view --help returned %d; expected same", codeInfo, codeView) |
| 237 | + } |
| 238 | +} |
| 239 | + |
| 240 | +// TestParityCLISelfUpdateAlias verifies "apm self_update" resolves as self-update. |
| 241 | +func TestParityCLISelfUpdateAlias(t *testing.T) { |
| 242 | + _, _, code := runGo(t, "self_update", "--help") |
| 243 | + if code != 0 { |
| 244 | + t.Fatalf("apm self_update --help returned %d, want 0", code) |
| 245 | + } |
| 246 | +} |
| 247 | + |
| 248 | +// --- Python-vs-Go parity tests (require APM_PYTHON_BIN) --- |
| 249 | + |
| 250 | +// TestPythonVsGoVersionExitCode compares exit codes for --version. |
| 251 | +// When APM_PYTHON_BIN is not set the test passes vacuously (no Python to compare). |
| 252 | +func TestPythonVsGoVersionExitCode(t *testing.T) { |
| 253 | + if noPython() { |
| 254 | + t.Log("APM_PYTHON_BIN not set; skipping Python-vs-Go comparison (vacuous pass)") |
| 255 | + return |
| 256 | + } |
| 257 | + _, _, pyCode := runPython("--version") |
| 258 | + _, _, goCode := runGo(t, "--version") |
| 259 | + if pyCode != goCode { |
| 260 | + t.Errorf("--version exit codes differ: Python=%d Go=%d", pyCode, goCode) |
| 261 | + } |
| 262 | +} |
| 263 | + |
| 264 | +// TestParityPythonVsGoHelpExitCode compares --help exit codes. |
| 265 | +func TestPythonVsGoHelpExitCode(t *testing.T) { |
| 266 | + if noPython() { |
| 267 | + t.Log("APM_PYTHON_BIN not set; skipping Python-vs-Go comparison (vacuous pass)") |
| 268 | + return |
| 269 | + } |
| 270 | + _, _, pyCode := runPython("--help") |
| 271 | + _, _, goCode := runGo(t, "--help") |
| 272 | + if pyCode != goCode { |
| 273 | + t.Errorf("--help exit codes differ: Python=%d Go=%d", pyCode, goCode) |
| 274 | + } |
| 275 | +} |
| 276 | + |
| 277 | +// TestParityPythonVsGoUnknownCommandExitCode verifies both fail on unknown cmd. |
| 278 | +func TestPythonVsGoUnknownCommandExitCode(t *testing.T) { |
| 279 | + if noPython() { |
| 280 | + t.Log("APM_PYTHON_BIN not set; skipping Python-vs-Go comparison (vacuous pass)") |
| 281 | + return |
| 282 | + } |
| 283 | + _, _, pyCode := runPython("totally-unknown-xyz") |
| 284 | + _, _, goCode := runGo(t, "totally-unknown-xyz") |
| 285 | + if pyCode == 0 || goCode == 0 { |
| 286 | + t.Errorf("unknown command: Python exit=%d, Go exit=%d; both should be non-zero", pyCode, goCode) |
| 287 | + } |
| 288 | +} |
| 289 | + |
| 290 | +// TestParityPythonVsGoHelpCommandList verifies Go help lists all Python commands. |
| 291 | +func TestPythonVsGoHelpCommandList(t *testing.T) { |
| 292 | + if noPython() { |
| 293 | + t.Log("APM_PYTHON_BIN not set; skipping Python-vs-Go comparison (vacuous pass)") |
| 294 | + return |
| 295 | + } |
| 296 | + pyOut, _, _ := runPython("--help") |
| 297 | + goOut, _, _ := runGo(t, "--help") |
| 298 | + // Extract command names from Python help output. |
| 299 | + // Python Click help lists commands as " <name> <description>". |
| 300 | + pyLines := strings.Split(pyOut, "\n") |
| 301 | + var missingInGo []string |
| 302 | + for _, line := range pyLines { |
| 303 | + trimmed := strings.TrimSpace(line) |
| 304 | + if trimmed == "" || strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "Usage") { |
| 305 | + continue |
| 306 | + } |
| 307 | + fields := strings.Fields(trimmed) |
| 308 | + if len(fields) == 0 { |
| 309 | + continue |
| 310 | + } |
| 311 | + candidate := fields[0] |
| 312 | + // Only consider lowercase single-word tokens as command names. |
| 313 | + if strings.ToLower(candidate) == candidate && !strings.Contains(candidate, ":") { |
| 314 | + if !strings.Contains(goOut, candidate) { |
| 315 | + missingInGo = append(missingInGo, candidate) |
| 316 | + } |
| 317 | + } |
| 318 | + } |
| 319 | + if len(missingInGo) > 0 { |
| 320 | + t.Errorf("Go --help missing commands present in Python --help: %v", missingInGo) |
| 321 | + } |
| 322 | +} |
| 323 | + |
| 324 | +// TestParityPythonVsGoSubcommandHelpExitCodes compares <cmd> --help exit codes. |
| 325 | +func TestPythonVsGoSubcommandHelpExitCodes(t *testing.T) { |
| 326 | + if noPython() { |
| 327 | + t.Log("APM_PYTHON_BIN not set; skipping Python-vs-Go comparison (vacuous pass)") |
| 328 | + return |
| 329 | + } |
| 330 | + cmds := []string{ |
| 331 | + "init", "install", "update", "compile", "pack", "run", |
| 332 | + "audit", "policy", "mcp", "runtime", "targets", "list", |
| 333 | + "view", "cache", "deps", "marketplace", |
| 334 | + } |
| 335 | + for _, cmd := range cmds { |
| 336 | + t.Run(cmd, func(t *testing.T) { |
| 337 | + _, _, pyCode := runPython(cmd, "--help") |
| 338 | + _, _, goCode := runGo(t, cmd, "--help") |
| 339 | + if pyCode != goCode { |
| 340 | + t.Errorf("apm %s --help exit codes differ: Python=%d Go=%d", cmd, pyCode, goCode) |
| 341 | + } |
| 342 | + }) |
| 343 | + } |
| 344 | +} |
0 commit comments