a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
516 lines
14 KiB
Go
516 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
toml "github.com/pelletier/go-toml/v2"
|
|
)
|
|
|
|
// TestInitDryRunJSONReportShape is the end-to-end contract test for
|
|
// the non-interactive init flags. It runs runInit with --yes
|
|
// --dry-run --json --agents=claude-code against a clean temp dir
|
|
// and asserts the JSON report is well-formed and reflects reality.
|
|
//
|
|
// Covers the slice of Step 2 not exercised by adapter-level tests:
|
|
// the cobra command wiring, flag parsing, registry filter, and JSON
|
|
// report emission.
|
|
func TestInitDryRunJSONReportShape(t *testing.T) {
|
|
// Save and restore mutable package-level init flags — tests that
|
|
// run after this one would otherwise see leftover state.
|
|
saved := struct {
|
|
yes, dryRun, json bool
|
|
agents string
|
|
}{initYes, initDryRun, initJSON, initAgents}
|
|
t.Cleanup(func() {
|
|
initYes, initDryRun, initJSON = saved.yes, saved.dryRun, saved.json
|
|
initAgents = saved.agents
|
|
})
|
|
|
|
repo := t.TempDir()
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
initYes = true
|
|
initDryRun = true
|
|
initJSON = true
|
|
initAgents = "claude-code"
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
initCmd.SetOut(&stdout)
|
|
initCmd.SetErr(&stderr)
|
|
t.Cleanup(func() {
|
|
initCmd.SetOut(nil)
|
|
initCmd.SetErr(nil)
|
|
})
|
|
|
|
if err := runInit(initCmd, []string{repo}); err != nil {
|
|
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
|
|
// stdout must be a parseable JSON object.
|
|
var report map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
|
|
t.Fatalf("non-JSON stdout: %v\n%s", err, stdout.String())
|
|
}
|
|
if report["dry_run"] != true {
|
|
t.Fatalf("dry_run flag missing from report: %v", report)
|
|
}
|
|
rawAgents, ok := report["agents"].([]any)
|
|
if !ok || len(rawAgents) != 1 {
|
|
t.Fatalf("expected exactly 1 agent in report, got %v", report["agents"])
|
|
}
|
|
agent := rawAgents[0].(map[string]any)
|
|
if agent["name"] != "claude-code" {
|
|
t.Fatalf("expected claude-code, got %v", agent["name"])
|
|
}
|
|
files, ok := agent["files"].([]any)
|
|
if !ok || len(files) == 0 {
|
|
t.Fatalf("expected non-empty files array, got %v", agent["files"])
|
|
}
|
|
for _, f := range files {
|
|
fm := f.(map[string]any)
|
|
action, _ := fm["action"].(string)
|
|
if !strings.HasPrefix(action, "would-") && action != "skip" {
|
|
t.Fatalf("dry-run emitted non-planning action %q for %v", action, fm["path"])
|
|
}
|
|
}
|
|
|
|
// Dry-run must not have created anything on disk.
|
|
for _, p := range []string{
|
|
filepath.Join(repo, ".mcp.json"),
|
|
filepath.Join(repo, "CLAUDE.md"),
|
|
} {
|
|
if _, err := os.Stat(p); err == nil {
|
|
t.Errorf("dry-run wrote %s", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInitAgentsFilterRejectsUnknownName is the hard-error contract
|
|
// for unknown --agents values — silent skips would mask typos.
|
|
func TestInitAgentsFilterRejectsUnknownName(t *testing.T) {
|
|
saved := struct {
|
|
yes, dryRun, json bool
|
|
agents string
|
|
}{initYes, initDryRun, initJSON, initAgents}
|
|
t.Cleanup(func() {
|
|
initYes, initDryRun, initJSON = saved.yes, saved.dryRun, saved.json
|
|
initAgents = saved.agents
|
|
})
|
|
|
|
repo := t.TempDir()
|
|
t.Setenv("HOME", t.TempDir())
|
|
|
|
initYes = true
|
|
initDryRun = true
|
|
initJSON = true
|
|
initAgents = "claude-code,nonexistent-agent"
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
initCmd.SetOut(&stdout)
|
|
initCmd.SetErr(&stderr)
|
|
t.Cleanup(func() {
|
|
initCmd.SetOut(nil)
|
|
initCmd.SetErr(nil)
|
|
})
|
|
|
|
err := runInit(initCmd, []string{repo})
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown agent name, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "nonexistent-agent") {
|
|
t.Fatalf("expected error to name the typo, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestInitCreatesProjectMarker pins the fix for issue #14: a fresh
|
|
// repo without `.gortex/` must end up with one after `gortex init` so
|
|
// the MCP server can resolve it as a single-project entry point.
|
|
func TestInitCreatesProjectMarker(t *testing.T) {
|
|
saved := struct {
|
|
yes, dryRun, json bool
|
|
agents string
|
|
}{initYes, initDryRun, initJSON, initAgents}
|
|
t.Cleanup(func() {
|
|
initYes, initDryRun, initJSON = saved.yes, saved.dryRun, saved.json
|
|
initAgents = saved.agents
|
|
})
|
|
|
|
repo := t.TempDir()
|
|
t.Setenv("HOME", t.TempDir())
|
|
|
|
initYes = true
|
|
initDryRun = false
|
|
initJSON = false
|
|
initAgents = "claude-code"
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
initCmd.SetOut(&stdout)
|
|
initCmd.SetErr(&stderr)
|
|
t.Cleanup(func() {
|
|
initCmd.SetOut(nil)
|
|
initCmd.SetErr(nil)
|
|
})
|
|
|
|
if err := runInit(initCmd, []string{repo}); err != nil {
|
|
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
|
|
marker := filepath.Join(repo, ".gortex")
|
|
info, err := os.Stat(marker)
|
|
if err != nil {
|
|
t.Fatalf("expected %s to exist after init, got: %v", marker, err)
|
|
}
|
|
if !info.IsDir() {
|
|
t.Fatalf("expected %s to be a directory", marker)
|
|
}
|
|
|
|
// Idempotency: a second run must not error out.
|
|
if err := runInit(initCmd, []string{repo}); err != nil {
|
|
t.Fatalf("second runInit: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestInitDryRunSkipsProjectMarker pins the inverse: dry-run is a
|
|
// planning mode and must not write the marker either, otherwise it
|
|
// would silently bind the directory.
|
|
func TestInitDryRunSkipsProjectMarker(t *testing.T) {
|
|
saved := struct {
|
|
yes, dryRun, json, dryRunIntake bool
|
|
agents string
|
|
}{initYes, initDryRun, initJSON, initDryRunIntake, initAgents}
|
|
t.Cleanup(func() {
|
|
initYes, initDryRun, initJSON, initDryRunIntake = saved.yes, saved.dryRun, saved.json, saved.dryRunIntake
|
|
initAgents = saved.agents
|
|
})
|
|
|
|
repo := t.TempDir()
|
|
t.Setenv("HOME", t.TempDir())
|
|
|
|
initYes = true
|
|
initDryRun = true
|
|
initJSON = true
|
|
initAgents = "claude-code"
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
initCmd.SetOut(&stdout)
|
|
initCmd.SetErr(&stderr)
|
|
t.Cleanup(func() {
|
|
initCmd.SetOut(nil)
|
|
initCmd.SetErr(nil)
|
|
})
|
|
|
|
if err := runInit(initCmd, []string{repo}); err != nil {
|
|
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(repo, ".gortex")); err == nil {
|
|
t.Fatal("dry-run wrote .gortex/ — must be planning-only")
|
|
}
|
|
}
|
|
|
|
func TestInitHooksOnlyRefreshesClaudeAndCodexHooks(t *testing.T) {
|
|
restore := saveInitGlobals(t)
|
|
defer restore()
|
|
|
|
repo := t.TempDir()
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
codexDir := filepath.Join(home, ".codex")
|
|
if err := os.MkdirAll(codexDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
codexConfig := filepath.Join(codexDir, "config.toml")
|
|
seed := `model = "gpt-5-codex"
|
|
|
|
[mcp_servers.gortex]
|
|
command = "custom-gortex"
|
|
args = ["mcp", "--custom"]
|
|
`
|
|
if err := os.WriteFile(codexConfig, []byte(seed), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
initYes = true
|
|
initHooksOnly = true
|
|
initHookMode = "enrich"
|
|
initDryRun = false
|
|
initJSON = false
|
|
initAgents = ""
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
initCmd.SetOut(&stdout)
|
|
initCmd.SetErr(&stderr)
|
|
t.Cleanup(func() {
|
|
initCmd.SetOut(nil)
|
|
initCmd.SetErr(nil)
|
|
})
|
|
|
|
if err := runInit(initCmd, []string{repo}); err != nil {
|
|
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(repo, ".claude", "settings.local.json")); err != nil {
|
|
t.Fatalf("expected Claude Code hooks to be refreshed: %v", err)
|
|
}
|
|
cfg := readTOMLFile(t, codexConfig)
|
|
if cfg["model"] != "gpt-5-codex" {
|
|
t.Fatalf("Codex hooks-only clobbered model: %#v", cfg)
|
|
}
|
|
servers := cfg["mcp_servers"].(map[string]any)
|
|
gortexServer := servers["gortex"].(map[string]any)
|
|
if gortexServer["command"] != "custom-gortex" {
|
|
t.Fatalf("Codex hooks-only rewrote mcp_servers.gortex: %#v", gortexServer)
|
|
}
|
|
hooks, ok := cfg["hooks"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("Codex hooks-only did not write hooks: %#v", cfg)
|
|
}
|
|
for _, event := range []string{"SessionStart", "PreToolUse", "PostToolUse"} {
|
|
if _, ok := hooks[event]; !ok {
|
|
t.Fatalf("Codex hooks-only missing %s hook: %#v", event, hooks)
|
|
}
|
|
}
|
|
|
|
for _, p := range []string{
|
|
filepath.Join(repo, ".gortex"),
|
|
filepath.Join(repo, "AGENTS.md"),
|
|
filepath.Join(repo, ".claude", "skills"),
|
|
filepath.Join(home, ".gortex", "config.yaml"),
|
|
} {
|
|
if _, err := os.Stat(p); err == nil {
|
|
t.Fatalf("hooks-only should not create %s", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInitHooksOnlyRespectsAgentsAllowlist(t *testing.T) {
|
|
restore := saveInitGlobals(t)
|
|
defer restore()
|
|
|
|
repo := t.TempDir()
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
initYes = true
|
|
initHooksOnly = true
|
|
initDryRun = false
|
|
initJSON = false
|
|
initAgents = "claude-code"
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
initCmd.SetOut(&stdout)
|
|
initCmd.SetErr(&stderr)
|
|
t.Cleanup(func() {
|
|
initCmd.SetOut(nil)
|
|
initCmd.SetErr(nil)
|
|
})
|
|
|
|
if err := runInit(initCmd, []string{repo}); err != nil {
|
|
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(repo, ".claude", "settings.local.json")); err != nil {
|
|
t.Fatalf("expected Claude Code hooks to be refreshed: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(home, ".codex", "config.toml")); !os.IsNotExist(err) {
|
|
t.Fatalf("--agents=claude-code should not write Codex config, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestInitHooksOnlyRespectsAgentsSkip(t *testing.T) {
|
|
restore := saveInitGlobals(t)
|
|
defer restore()
|
|
|
|
repo := t.TempDir()
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
codexDir := filepath.Join(home, ".codex")
|
|
if err := os.MkdirAll(codexDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
codexConfig := filepath.Join(codexDir, "config.toml")
|
|
seed := `model = "gpt-5-codex"
|
|
|
|
[mcp_servers.gortex]
|
|
command = "custom-gortex"
|
|
`
|
|
if err := os.WriteFile(codexConfig, []byte(seed), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
initYes = true
|
|
initHooksOnly = true
|
|
initDryRun = false
|
|
initJSON = false
|
|
initAgentsSkip = "codex"
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
initCmd.SetOut(&stdout)
|
|
initCmd.SetErr(&stderr)
|
|
t.Cleanup(func() {
|
|
initCmd.SetOut(nil)
|
|
initCmd.SetErr(nil)
|
|
})
|
|
|
|
if err := runInit(initCmd, []string{repo}); err != nil {
|
|
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(repo, ".claude", "settings.local.json")); err != nil {
|
|
t.Fatalf("expected Claude Code hooks to be refreshed: %v", err)
|
|
}
|
|
cfg := readTOMLFile(t, codexConfig)
|
|
if _, ok := cfg["hooks"]; ok {
|
|
t.Fatalf("--agents-skip=codex should not write Codex hooks: %#v", cfg["hooks"])
|
|
}
|
|
if cfg["model"] != "gpt-5-codex" {
|
|
t.Fatalf("Codex config was unexpectedly rewritten: %#v", cfg)
|
|
}
|
|
}
|
|
|
|
func TestInitHooksOnlyDryRunDoesNotWriteHooks(t *testing.T) {
|
|
restore := saveInitGlobals(t)
|
|
defer restore()
|
|
|
|
repo := t.TempDir()
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
initYes = true
|
|
initHooksOnly = true
|
|
initDryRun = true
|
|
initJSON = false
|
|
initAgents = ""
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
initCmd.SetOut(&stdout)
|
|
initCmd.SetErr(&stderr)
|
|
t.Cleanup(func() {
|
|
initCmd.SetOut(nil)
|
|
initCmd.SetErr(nil)
|
|
})
|
|
|
|
if err := runInit(initCmd, []string{repo}); err != nil {
|
|
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
|
|
for _, p := range []string{
|
|
filepath.Join(repo, ".claude", "settings.local.json"),
|
|
filepath.Join(home, ".codex", "config.toml"),
|
|
filepath.Join(repo, ".gortex"),
|
|
filepath.Join(repo, "AGENTS.md"),
|
|
} {
|
|
if _, err := os.Stat(p); err == nil {
|
|
t.Fatalf("dry-run hooks-only should not write %s", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInitDryRunIntakeJSONDoesNotWrite(t *testing.T) {
|
|
saved := struct {
|
|
yes, dryRun, json, dryRunIntake bool
|
|
agents string
|
|
}{initYes, initDryRun, initJSON, initDryRunIntake, initAgents}
|
|
t.Cleanup(func() {
|
|
initYes, initDryRun, initJSON, initDryRunIntake = saved.yes, saved.dryRun, saved.json, saved.dryRunIntake
|
|
initAgents = saved.agents
|
|
})
|
|
|
|
repo := t.TempDir()
|
|
t.Setenv("HOME", t.TempDir())
|
|
if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repo, "notes.txt"), []byte(strings.Repeat("x", 32)), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
initYes = true
|
|
initDryRunIntake = true
|
|
initDryRun = false
|
|
initJSON = false
|
|
initAgents = ""
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
initCmd.SetOut(&stdout)
|
|
initCmd.SetErr(&stderr)
|
|
t.Cleanup(func() {
|
|
initCmd.SetOut(nil)
|
|
initCmd.SetErr(nil)
|
|
})
|
|
|
|
if err := runInit(initCmd, []string{repo}); err != nil {
|
|
t.Fatalf("runInit: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
|
|
var report map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
|
|
t.Fatalf("non-JSON stdout: %v\n%s", err, stdout.String())
|
|
}
|
|
if report["schema_version"] != "gortex.index_intake.v1" {
|
|
t.Fatalf("unexpected schema: %v", report["schema_version"])
|
|
}
|
|
if report["raw_paths_included"] != false || report["raw_file_contents_included"] != false {
|
|
t.Fatalf("manifest must not include raw paths/content: %v", report)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(repo, ".gortex")); err == nil {
|
|
t.Fatal("dry-run-intake wrote .gortex/ — must be inspection-only")
|
|
}
|
|
}
|
|
|
|
func saveInitGlobals(t *testing.T) func() {
|
|
t.Helper()
|
|
saved := struct {
|
|
analyze, installHooks, noHooks, hooksOnly bool
|
|
hookMode string
|
|
skills, noSkills bool
|
|
skillsMinSize, skillsMaxSkills int
|
|
yes, interactive bool
|
|
agents, agentsSkip string
|
|
json, dryRun, dryRunIntake, force bool
|
|
}{
|
|
initAnalyze, initInstallHooks, initNoHooks, initHooksOnly,
|
|
initHookMode,
|
|
initSkills, initNoSkills,
|
|
initSkillsMinSize, initSkillsMaxSkills,
|
|
initYes, initInteractive,
|
|
initAgents, initAgentsSkip,
|
|
initJSON, initDryRun, initDryRunIntake, initForce,
|
|
}
|
|
return func() {
|
|
initAnalyze, initInstallHooks, initNoHooks, initHooksOnly = saved.analyze, saved.installHooks, saved.noHooks, saved.hooksOnly
|
|
initHookMode = saved.hookMode
|
|
initSkills, initNoSkills = saved.skills, saved.noSkills
|
|
initSkillsMinSize, initSkillsMaxSkills = saved.skillsMinSize, saved.skillsMaxSkills
|
|
initYes, initInteractive = saved.yes, saved.interactive
|
|
initAgents, initAgentsSkip = saved.agents, saved.agentsSkip
|
|
initJSON, initDryRun, initDryRunIntake, initForce = saved.json, saved.dryRun, saved.dryRunIntake, saved.force
|
|
}
|
|
}
|
|
|
|
func readTOMLFile(t *testing.T, path string) map[string]any {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
var out map[string]any
|
|
if err := toml.Unmarshal(data, &out); err != nil {
|
|
t.Fatalf("parse %s: %v\n%s", path, err, data)
|
|
}
|
|
return out
|
|
}
|