Files
zzet--gortex/cmd/gortex/init_integration_test.go
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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
}