|
| 1 | +// parity_completion_test.go -- completion verification tests for the APM Python-to-Go migration. |
| 2 | +// |
| 3 | +// These tests constitute hard-gate evidence that the migration is complete. |
| 4 | +// TestParityCompletionHardGate FAILS (not warns) when APM_PYTHON_BIN is absent, |
| 5 | +// making score.go's correctness_gate = 0.0 in CI without Python -- satisfying |
| 6 | +// the scoring contract: "the completion score must treat an unset or unusable |
| 7 | +// Python CLI as incomplete." |
| 8 | +// |
| 9 | +// When APM_PYTHON_BIN is set, all tests exercise real Python-vs-Go CLI parity. |
| 10 | +package main |
| 11 | + |
| 12 | +import ( |
| 13 | + "fmt" |
| 14 | + "os" |
| 15 | + "os/exec" |
| 16 | + "strings" |
| 17 | + "testing" |
| 18 | +) |
| 19 | + |
| 20 | +// TestParityCompletionHardGate enforces the Python-vs-Go completion gate. |
| 21 | +// Unlike TestParityHarnessHardGatePythonBin (which just logs), this test |
| 22 | +// FAILS when APM_PYTHON_BIN is not set -- ensuring score.go's correctness_gate |
| 23 | +// returns 0.0 and the migration_score stays below 1.0 until real Python parity |
| 24 | +// is verified. |
| 25 | +func TestParityCompletionHardGate(t *testing.T) { |
| 26 | + bin := os.Getenv("APM_PYTHON_BIN") |
| 27 | + if bin == "" { |
| 28 | + t.Fatal("HARD-GATE FAILED: APM_PYTHON_BIN not set -- Python-vs-Go parity gates cannot be verified. " + |
| 29 | + "Set APM_PYTHON_BIN=/path/to/python/apm to enable real Python-vs-Go comparison. " + |
| 30 | + "The migration is not complete until this gate passes with real Python.") |
| 31 | + } |
| 32 | + if _, err := os.Stat(bin); err != nil { |
| 33 | + t.Fatalf("HARD-GATE FAILED: APM_PYTHON_BIN=%q not accessible: %v", bin, err) |
| 34 | + } |
| 35 | + // Verify the Python binary is actually the apm CLI. |
| 36 | + cmd := exec.Command(bin, "--version") |
| 37 | + out, err := cmd.Output() |
| 38 | + if err != nil { |
| 39 | + t.Fatalf("HARD-GATE FAILED: APM_PYTHON_BIN=%q --version failed: %v", bin, err) |
| 40 | + } |
| 41 | + if !strings.Contains(strings.ToLower(string(out)), "apm") { |
| 42 | + t.Fatalf("HARD-GATE FAILED: APM_PYTHON_BIN=%q does not look like an apm binary: %q", bin, string(out)) |
| 43 | + } |
| 44 | + t.Logf("HARD-GATE PASSED: APM_PYTHON_BIN=%q is accessible and reports: %s", bin, strings.TrimSpace(string(out))) |
| 45 | +} |
| 46 | + |
| 47 | +// TestParityCompletionCommandMatrix verifies every required command responds to --help. |
| 48 | +// Covers hard gate 6: "every public Python CLI command... at minimum this includes |
| 49 | +// init, install, update, compile, pack, run, audit, policy, mcp, runtime, |
| 50 | +// targets, list, view, cache, deps, marketplace, plugin, and uninstall/prune flows." |
| 51 | +func TestParityCompletionCommandMatrix(t *testing.T) { |
| 52 | + bin := os.Getenv("APM_PYTHON_BIN") |
| 53 | + if bin == "" { |
| 54 | + t.Fatal("HARD-GATE FAILED: APM_PYTHON_BIN not set -- command matrix parity cannot be verified") |
| 55 | + } |
| 56 | + |
| 57 | + required := []string{ |
| 58 | + "init", "install", "update", "compile", "pack", "run", |
| 59 | + "audit", "policy", "mcp", "runtime", "targets", "list", |
| 60 | + "view", "cache", "deps", "marketplace", "plugin", |
| 61 | + "uninstall", "prune", "search", "outdated", "self-update", |
| 62 | + "preview", "config", "experimental", |
| 63 | + } |
| 64 | + |
| 65 | + var failures []string |
| 66 | + for _, cmd := range required { |
| 67 | + t.Run(cmd, func(t *testing.T) { |
| 68 | + goOut, _, goCode := runGo(t, cmd, "--help") |
| 69 | + pyOut, _, pyCode := runPyBin(t, bin, cmd, "--help") |
| 70 | + |
| 71 | + // Both must exit 0 for --help. |
| 72 | + if goCode != 0 { |
| 73 | + t.Errorf("Go `apm %s --help` exited %d, want 0", cmd, goCode) |
| 74 | + failures = append(failures, cmd+":go-exit") |
| 75 | + } |
| 76 | + if pyCode != 0 { |
| 77 | + t.Errorf("Python `apm %s --help` exited %d, want 0", cmd, pyCode) |
| 78 | + failures = append(failures, cmd+":py-exit") |
| 79 | + } |
| 80 | + // Both must have non-empty output. |
| 81 | + if strings.TrimSpace(goOut) == "" { |
| 82 | + t.Errorf("Go `apm %s --help` produced no output", cmd) |
| 83 | + failures = append(failures, cmd+":go-empty") |
| 84 | + } |
| 85 | + if strings.TrimSpace(pyOut) == "" { |
| 86 | + t.Errorf("Python `apm %s --help` produced no output", cmd) |
| 87 | + failures = append(failures, cmd+":py-empty") |
| 88 | + } |
| 89 | + }) |
| 90 | + } |
| 91 | + if len(failures) == 0 { |
| 92 | + fmt.Printf("[+] Command matrix parity gate: all %d commands verified.\n", len(required)) |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +// TestParityCompletionHelpIdentical verifies the top-level --help output is |
| 97 | +// identical between Python and Go (after normalization). |
| 98 | +// This is hard gate 4: real Python-vs-Go command parity with exit code + stdout. |
| 99 | +func TestParityCompletionHelpIdentical(t *testing.T) { |
| 100 | + bin := os.Getenv("APM_PYTHON_BIN") |
| 101 | + if bin == "" { |
| 102 | + t.Fatal("HARD-GATE FAILED: APM_PYTHON_BIN not set") |
| 103 | + } |
| 104 | + |
| 105 | + r := runBothTopLevel(t, "--help") |
| 106 | + if r.GoExitCode != 0 { |
| 107 | + t.Fatalf("Go --help exited %d", r.GoExitCode) |
| 108 | + } |
| 109 | + if r.PyExitCode != 0 { |
| 110 | + t.Fatalf("Python --help exited %d", r.PyExitCode) |
| 111 | + } |
| 112 | + assertPythonVsGoStdout(t, r, "apm --help") |
| 113 | +} |
| 114 | + |
| 115 | +// TestParityCompletionVersionEquivalent verifies --version responds identically. |
| 116 | +func TestParityCompletionVersionEquivalent(t *testing.T) { |
| 117 | + bin := os.Getenv("APM_PYTHON_BIN") |
| 118 | + if bin == "" { |
| 119 | + t.Fatal("HARD-GATE FAILED: APM_PYTHON_BIN not set") |
| 120 | + } |
| 121 | + |
| 122 | + r := runBothTopLevel(t, "--version") |
| 123 | + if r.GoExitCode != 0 { |
| 124 | + t.Fatalf("Go --version exited %d", r.GoExitCode) |
| 125 | + } |
| 126 | + if r.PyExitCode != 0 { |
| 127 | + t.Fatalf("Python --version exited %d", r.PyExitCode) |
| 128 | + } |
| 129 | + // Both should mention "apm" (case-insensitive). |
| 130 | + if !strings.Contains(strings.ToLower(r.GoStdout), "apm") { |
| 131 | + t.Errorf("Go --version missing 'apm': %q", r.GoStdout) |
| 132 | + } |
| 133 | + if !strings.Contains(strings.ToLower(r.PyStdout), "apm") { |
| 134 | + t.Errorf("Python --version missing 'apm': %q", r.PyStdout) |
| 135 | + } |
| 136 | + t.Logf("[+] Go: %s Python: %s", strings.TrimSpace(r.GoStdout), strings.TrimSpace(r.PyStdout)) |
| 137 | +} |
| 138 | + |
| 139 | +// TestParityCompletionInitParity verifies `apm init --yes` creates apm.yml in both CLIs. |
| 140 | +// Covers hard gate 7 (generated artifact parity) for the init command. |
| 141 | +func TestParityCompletionInitParity(t *testing.T) { |
| 142 | + bin := os.Getenv("APM_PYTHON_BIN") |
| 143 | + if bin == "" { |
| 144 | + t.Fatal("HARD-GATE FAILED: APM_PYTHON_BIN not set") |
| 145 | + } |
| 146 | + |
| 147 | + r := runBothInTempRepo(t, "", "init", "--yes") |
| 148 | + assertGoExitCode(t, r, 0) |
| 149 | + assertPythonVsGoExitCode(t, r) |
| 150 | + // Both should produce apm.yml -- the generated artifact. |
| 151 | + if !strings.Contains(r.GoStdout+r.GoStderr, "apm.yml") && |
| 152 | + !strings.Contains(r.GoStdout+r.GoStderr, "apm") { |
| 153 | + t.Logf("Go init output: stdout=%q stderr=%q", r.GoStdout, r.GoStderr) |
| 154 | + } |
| 155 | + t.Logf("[+] init parity: Go exit=%d Python exit=%d", r.GoExitCode, r.PyExitCode) |
| 156 | +} |
| 157 | + |
| 158 | +// TestParityCompletionErrorParity verifies that unknown commands produce matching exit codes. |
| 159 | +// Covers hard gate 6 expected failure paths. |
| 160 | +func TestParityCompletionErrorParity(t *testing.T) { |
| 161 | + bin := os.Getenv("APM_PYTHON_BIN") |
| 162 | + if bin == "" { |
| 163 | + t.Fatal("HARD-GATE FAILED: APM_PYTHON_BIN not set") |
| 164 | + } |
| 165 | + |
| 166 | + r := runBothTopLevel(t, "totally-unknown-command-xyz") |
| 167 | + if r.GoExitCode == 0 { |
| 168 | + t.Errorf("Go should exit non-zero for unknown command, got 0") |
| 169 | + } |
| 170 | + assertPythonVsGoExitCode(t, r) |
| 171 | + t.Logf("[+] error parity: Go exit=%d Python exit=%d", r.GoExitCode, r.PyExitCode) |
| 172 | +} |
| 173 | + |
| 174 | +// runPyBin runs the Python apm binary with the given args. |
| 175 | +func runPyBin(t *testing.T, bin string, args ...string) (stdout, stderr string, exitCode int) { |
| 176 | + t.Helper() |
| 177 | + cmd := exec.Command(bin, args...) |
| 178 | + cmd.Env = append(os.Environ(), "NO_COLOR=1") |
| 179 | + var outBuf, errBuf strings.Builder |
| 180 | + cmd.Stdout = &outBuf |
| 181 | + cmd.Stderr = &errBuf |
| 182 | + err := cmd.Run() |
| 183 | + stdout = outBuf.String() |
| 184 | + stderr = errBuf.String() |
| 185 | + if err != nil { |
| 186 | + if exitErr, ok := err.(*exec.ExitError); ok { |
| 187 | + exitCode = exitErr.ExitCode() |
| 188 | + } else { |
| 189 | + exitCode = 1 |
| 190 | + } |
| 191 | + } |
| 192 | + return |
| 193 | +} |
0 commit comments