Files
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

821 lines
24 KiB
Go

package codex
import (
"os"
"path/filepath"
"strings"
"testing"
toml "github.com/pelletier/go-toml/v2"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
const testCodexHookCommand = "/tmp/test-gortex hook --agent=codex --mode=enrich"
// TestCodexWritesMcpServersTOMLTable verifies we produce the
// documented [mcp_servers.gortex] table — not a legacy
// [mcp.gortex] or [mcpServers.gortex].
func TestCodexWritesMcpServersTOMLTable(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Detection sentinel: ~/.codex/ exists.
if err := os.MkdirAll(filepath.Join(env.Home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: ~/.codex/config.toml for MCP plus AGENTS.md, the
// per-repo instructions file Codex CLI reads on every task.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
data, err := os.ReadFile(filepath.Join(env.Home, ".codex", "config.toml"))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
got := string(data)
if !strings.Contains(got, "mcp_servers") {
t.Fatalf("expected mcp_servers table: %s", got)
}
if !strings.Contains(got, "gortex") {
t.Fatalf("expected gortex entry: %s", got)
}
cfg := readCodexConfig(t, env)
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("expected one Gortex SessionStart hook, got %d: %#v", count, cfg["hooks"])
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("expected one Gortex PostToolUse hook, got %d: %#v", count, cfg["hooks"])
}
agentstest.AssertIdempotent(t, a, env)
}
func TestCodexInstallsSessionStartHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := sessionStartEntries(t, cfg)
if len(entries) != 1 {
t.Fatalf("SessionStart entries=%d want 1: %#v", len(entries), entries)
}
entry := entries[0].(map[string]any)
if entry["matcher"] != codexSessionStartMatcher {
t.Fatalf("matcher=%v want %q", entry["matcher"], codexSessionStartMatcher)
}
handlers, ok := codexHookList(entry["hooks"])
if !ok || len(handlers) != 1 {
t.Fatalf("handlers=%#v", entry["hooks"])
}
handler := handlers[0].(map[string]any)
if handler["type"] != "command" {
t.Errorf("hook type=%v want command", handler["type"])
}
if handler["command"] != codexSessionStartCommand {
t.Errorf("command=%v want %q", handler["command"], codexSessionStartCommand)
}
if handler["command_windows"] != codexSessionStartWindowsCommand {
t.Errorf("command_windows=%v want %q", handler["command_windows"], codexSessionStartWindowsCommand)
}
command := handler["command"].(string)
if !strings.Contains(command, "IMPORTANT: Prefer Gortex MCP tools") {
t.Errorf("command should emit the graph-tools orientation: %v", handler["command"])
}
if !strings.Contains(command, "edit_file") {
t.Errorf("command should mention edit_file: %v", handler["command"])
}
if strings.Contains(command, "[Gortex]") {
t.Errorf("command should not duplicate the Gortex label: %v", handler["command"])
}
}
func TestCodexInstallsPreToolUseHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := preToolUseEntries(t, cfg)
if len(entries) != 2 {
t.Fatalf("PreToolUse entries=%d want Bash+MCP read entries: %#v", len(entries), entries)
}
assertGortexPreToolUseHooks(t, cfg)
bashHandler := requireHookEntry(t, cfg, "PreToolUse", codexPreToolUseMatcher, testCodexHookCommand)
mcpHandler := requireHookEntry(t, cfg, "PreToolUse", codexMCPReadPreToolUseMatcher, testCodexHookCommand)
for name, handler := range map[string]map[string]any{"Bash": bashHandler, "MCP read": mcpHandler} {
if handler["type"] != "command" {
t.Errorf("%s hook type=%v want command", name, handler["type"])
}
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
t.Errorf("%s timeout=%v want %d", name, handler["timeout"], codexHookTimeoutSeconds)
}
}
if bashHandler["statusMessage"] != "Loading Gortex Bash guidance..." {
t.Errorf("Bash statusMessage=%v", bashHandler["statusMessage"])
}
if mcpHandler["statusMessage"] != "Loading Gortex read guidance..." {
t.Errorf("MCP read statusMessage=%v", mcpHandler["statusMessage"])
}
}
func TestCodexInstallsPostToolUseHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := postToolUseEntries(t, cfg)
if len(entries) != 1 {
t.Fatalf("PostToolUse entries=%d want 1: %#v", len(entries), entries)
}
entry := entries[0].(map[string]any)
if entry["matcher"] != codexPostToolUseMatcher {
t.Fatalf("matcher=%v want %q", entry["matcher"], codexPostToolUseMatcher)
}
handlers, ok := codexHookList(entry["hooks"])
if !ok || len(handlers) != 1 {
t.Fatalf("handlers=%#v", entry["hooks"])
}
handler := handlers[0].(map[string]any)
if handler["type"] != "command" {
t.Errorf("hook type=%v want command", handler["type"])
}
command := handler["command"].(string)
if command != testCodexHookCommand {
t.Errorf("command=%v want test hook command with --agent=codex --mode=enrich", command)
}
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
t.Errorf("timeout=%v want %d", handler["timeout"], codexHookTimeoutSeconds)
}
}
func TestCodexInstallsUserPromptSubmitHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := userPromptSubmitEntries(t, cfg)
if len(entries) != 1 {
t.Fatalf("UserPromptSubmit entries=%d want 1: %#v", len(entries), entries)
}
entry := entries[0].(map[string]any)
// UserPromptSubmit takes no matcher — Codex can't filter it by tool name.
if _, hasMatcher := entry["matcher"]; hasMatcher {
t.Fatalf("UserPromptSubmit entry should carry no matcher: %#v", entry)
}
handlers, ok := codexHookList(entry["hooks"])
if !ok || len(handlers) != 1 {
t.Fatalf("handlers=%#v", entry["hooks"])
}
handler := handlers[0].(map[string]any)
if handler["type"] != "command" {
t.Errorf("hook type=%v want command", handler["type"])
}
if handler["command"] != testCodexHookCommand {
t.Errorf("command=%v want %q", handler["command"], testCodexHookCommand)
}
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
t.Errorf("timeout=%v want %d", handler["timeout"], codexHookTimeoutSeconds)
}
if count := gortexUserPromptSubmitHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex UserPromptSubmit hooks=%d want 1", count)
}
}
func TestCodexInstallHooksOnlyCreatesOnlyHooks(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("install hooks only: %v", err)
}
if action.Action != agents.ActionCreate {
t.Fatalf("action=%s want create", action.Action)
}
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
t.Fatalf("keys=%#v want hooks only", action.Keys)
}
cfg := readCodexConfig(t, env)
if _, ok := cfg["mcp_servers"]; ok {
t.Fatalf("hooks-only should not write mcp_servers: %#v", cfg["mcp_servers"])
}
if _, err := os.Stat(filepath.Join(env.Root, "AGENTS.md")); !os.IsNotExist(err) {
t.Fatalf("hooks-only should not write AGENTS.md, stat err=%v", err)
}
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("SessionStart hooks=%d want 1", count)
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("PostToolUse hooks=%d want 1", count)
}
action, err = InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second install hooks only: %v", err)
}
if action.Action != agents.ActionSkip {
t.Fatalf("second action=%s want skip", action.Action)
}
}
func TestCodexInstallHooksOnlyPreservesExistingConfig(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `model = "gpt-5-codex"
[mcp_servers.gortex]
command = "custom-gortex"
args = ["mcp", "--custom"]
[mcp_servers.other]
command = "other"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "echo user-posttooluse"
statusMessage = "User PostToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("install hooks only: %v", err)
}
if action.Action != agents.ActionMerge {
t.Fatalf("action=%s want merge", action.Action)
}
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
t.Fatalf("keys=%#v want hooks only", action.Keys)
}
cfg := readCodexConfig(t, env)
if cfg["model"] != "gpt-5-codex" {
t.Fatalf("unrelated top-level key was clobbered: %#v", cfg)
}
servers := cfg["mcp_servers"].(map[string]any)
gortexServer := servers["gortex"].(map[string]any)
if gortexServer["command"] != "custom-gortex" {
t.Fatalf("hooks-only rewrote mcp_servers.gortex: %#v", gortexServer)
}
if _, ok := servers["other"]; !ok {
t.Fatalf("existing MCP server was clobbered: %#v", servers)
}
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("user PreToolUse hook was not preserved: %#v", preToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
t.Fatalf("user PostToolUse hook was not preserved: %#v", postToolUseEntries(t, cfg))
}
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("SessionStart hooks=%d want 1", count)
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("PostToolUse hooks=%d want 1", count)
}
}
func TestCodexInstallHooksOnlyForceReplacesOnlyGortexHooks(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex PreToolUse"
[[hooks.PreToolUse]]
matcher = "^mcp__gortex__(read_file|get_editing_context)$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex MCP Read PreToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "echo user-posttooluse"
statusMessage = "User PostToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex PostToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
if _, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{Force: true}); err != nil {
t.Fatalf("install hooks only: %v", err)
}
cfg := readCodexConfig(t, env)
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("Force removed user PreToolUse hook: %#v", preToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
t.Fatalf("Force removed user PostToolUse hook: %#v", postToolUseEntries(t, cfg))
}
if hasHookCommand(t, cfg, "PreToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
t.Fatalf("Force kept stale Gortex PreToolUse hook: %#v", preToolUseEntries(t, cfg))
}
if hasHookCommand(t, cfg, "PostToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
t.Fatalf("Force kept stale Gortex PostToolUse hook: %#v", postToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PreToolUse", testCodexHookCommand) {
t.Fatalf("Force did not install current Gortex PreToolUse hook: %#v", preToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PostToolUse", testCodexHookCommand) {
t.Fatalf("Force did not install current Gortex PostToolUse hook: %#v", postToolUseEntries(t, cfg))
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex PostToolUse hooks=%d want 1", count)
}
}
func TestCodexInstallHooksOnlyDryRunDoesNotWrite(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("install hooks only dry-run: %v", err)
}
if action.Action != agents.ActionWouldCreate {
t.Fatalf("action=%s want would-create", action.Action)
}
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
t.Fatalf("keys=%#v want hooks only", action.Keys)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("dry-run should not write config.toml, stat err=%v", err)
}
}
func TestCodexPreToolUseCommandFallsBackToGortexHook(t *testing.T) {
command := codexPreToolUseCommand(agents.Env{})
if command != "gortex hook --agent=codex --mode=enrich" {
t.Fatalf("fallback command=%q", command)
}
}
func TestCodexSessionStartHookIdempotent(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("first apply: %v", err)
}
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionSkip: 1})
cfg := readCodexConfig(t, env)
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("re-run duplicated Gortex SessionStart hook: got %d", count)
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("re-run duplicated Gortex PostToolUse hook: got %d", count)
}
}
func TestCodexSessionStartHookPreservesExistingConfig(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `model = "gpt-5-codex"
[mcp_servers.other]
command = "other"
[[hooks.SessionStart]]
matcher = "startup"
[[hooks.SessionStart.hooks]]
type = "command"
command = "echo user-session-start"
statusMessage = "User hook"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "echo user-posttooluse"
statusMessage = "User PostToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1})
cfg := readCodexConfig(t, env)
if cfg["model"] != "gpt-5-codex" {
t.Fatalf("unrelated top-level key was clobbered: %#v", cfg)
}
servers := cfg["mcp_servers"].(map[string]any)
if _, ok := servers["other"]; !ok {
t.Fatalf("existing MCP server was clobbered: %#v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex MCP server missing after merge: %#v", servers)
}
entries := sessionStartEntries(t, cfg)
if len(entries) != 2 {
t.Fatalf("SessionStart entries=%d want user+gortex entries: %#v", len(entries), entries)
}
if !hasSessionStartCommand(t, cfg, "echo user-session-start") {
t.Fatalf("user SessionStart hook was not preserved: %#v", entries)
}
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex SessionStart hooks=%d want 1", count)
}
preEntries := preToolUseEntries(t, cfg)
if len(preEntries) != 3 {
t.Fatalf("PreToolUse entries=%d want user+Bash+MCP read entries: %#v", len(preEntries), preEntries)
}
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("user PreToolUse hook was not preserved: %#v", preEntries)
}
assertGortexPreToolUseHooks(t, cfg)
postEntries := postToolUseEntries(t, cfg)
if len(postEntries) != 2 {
t.Fatalf("PostToolUse entries=%d want user+gortex entries: %#v", len(postEntries), postEntries)
}
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
t.Fatalf("user PostToolUse hook was not preserved: %#v", postEntries)
}
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex PostToolUse hooks=%d want 1", count)
}
}
func TestCodexForceReplacesOnlyGortexPreToolUseHook(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex PreToolUse"
[[hooks.PreToolUse]]
matcher = "^mcp__gortex__(read_file|get_editing_context)$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex MCP Read PreToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{Force: true})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1})
cfg := readCodexConfig(t, env)
preEntries := preToolUseEntries(t, cfg)
if len(preEntries) != 3 {
t.Fatalf("PreToolUse entries=%d want user+Bash+MCP read entries: %#v", len(preEntries), preEntries)
}
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("Force removed user PreToolUse hook: %#v", preEntries)
}
if hasHookCommand(t, cfg, "PreToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
t.Fatalf("Force kept stale Gortex PreToolUse hook: %#v", preEntries)
}
if !hasHookCommand(t, cfg, "PreToolUse", testCodexHookCommand) {
t.Fatalf("Force did not install current Gortex PreToolUse hook: %#v", preEntries)
}
assertGortexPreToolUseHooks(t, cfg)
}
func TestCodexNoHooksSkipsSessionStartHook(t *testing.T) {
env := codexGlobalEnv(t)
env.InstallHooks = false
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
cfg := readCodexConfig(t, env)
if _, ok := cfg["hooks"]; ok {
t.Fatalf("--no-hooks should not write Codex hooks: %#v", cfg["hooks"])
}
if _, ok := cfg["mcp_servers"].(map[string]any)["gortex"]; !ok {
t.Fatal("mcp_servers.gortex should still be written under --no-hooks")
}
plan, err := a.Plan(env)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(plan.Files) != 1 {
t.Fatalf("plan files=%d want 1", len(plan.Files))
}
for _, key := range plan.Files[0].Keys {
if key == "hooks" {
t.Fatalf("Plan should not report hooks under --no-hooks: %#v", plan.Files[0].Keys)
}
}
}
func codexGlobalEnv(t *testing.T) agents.Env {
t.Helper()
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
if err := os.MkdirAll(filepath.Join(env.Home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}
return env
}
func codexConfigPath(env agents.Env) string {
return filepath.Join(env.Home, ".codex", "config.toml")
}
func readCodexConfig(t *testing.T, env agents.Env) map[string]any {
t.Helper()
data, err := os.ReadFile(codexConfigPath(env))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
var out map[string]any
if err := toml.Unmarshal(data, &out); err != nil {
t.Fatalf("parse config.toml: %v\n%s", err, data)
}
return out
}
func sessionStartEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "SessionStart")
}
func preToolUseEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "PreToolUse")
}
func postToolUseEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "PostToolUse")
}
func userPromptSubmitEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "UserPromptSubmit")
}
func hookEntries(t *testing.T, cfg map[string]any, event string) []any {
t.Helper()
hooks, ok := cfg["hooks"].(map[string]any)
if !ok {
t.Fatalf("missing hooks map: %#v", cfg)
}
entries, ok := codexHookList(hooks[event])
if !ok {
t.Fatalf("hooks.%s has unexpected shape: %#v", event, hooks[event])
}
return entries
}
func gortexSessionStartHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range sessionStartEntries(t, cfg) {
if codexHookEntryIsGortexSessionStart(entry) {
count++
}
}
return count
}
func gortexPreToolUseHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range preToolUseEntries(t, cfg) {
if codexHookEntryIsGortexPreToolUse(entry) {
count++
}
}
return count
}
func assertGortexPreToolUseHooks(t *testing.T, cfg map[string]any) {
t.Helper()
if count := gortexPreToolUseHookCount(t, cfg); count != 2 {
t.Fatalf("Gortex PreToolUse hooks=%d want Bash+MCP read hooks: %#v", count, preToolUseEntries(t, cfg))
}
if count := hookMatcherCommandCount(t, cfg, "PreToolUse", codexPreToolUseMatcher, testCodexHookCommand); count != 1 {
t.Fatalf("Bash PreToolUse hook count=%d want 1: %#v", count, preToolUseEntries(t, cfg))
}
if count := hookMatcherCommandCount(t, cfg, "PreToolUse", codexMCPReadPreToolUseMatcher, testCodexHookCommand); count != 1 {
t.Fatalf("MCP read PreToolUse hook count=%d want 1: %#v", count, preToolUseEntries(t, cfg))
}
}
func gortexPostToolUseHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range postToolUseEntries(t, cfg) {
if codexHookEntryIsGortexPostToolUse(entry) {
count++
}
}
return count
}
func gortexUserPromptSubmitHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range userPromptSubmitEntries(t, cfg) {
if codexHookEntryIsGortexUserPromptSubmit(entry) {
count++
}
}
return count
}
func requireHookEntry(t *testing.T, cfg map[string]any, event, matcher, command string) map[string]any {
t.Helper()
for _, entry := range hookEntries(t, cfg, event) {
group, ok := entry.(map[string]any)
if !ok {
continue
}
gotMatcher, _ := group["matcher"].(string)
if gotMatcher != matcher {
continue
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
continue
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if got, _ := hm["command"].(string); got == command {
return hm
}
}
}
t.Fatalf("missing %s hook matcher=%q command=%q in %#v", event, matcher, command, hookEntries(t, cfg, event))
return nil
}
func hookMatcherCommandCount(t *testing.T, cfg map[string]any, event, matcher, command string) int {
t.Helper()
count := 0
for _, entry := range hookEntries(t, cfg, event) {
group, ok := entry.(map[string]any)
if !ok {
continue
}
gotMatcher, _ := group["matcher"].(string)
if gotMatcher != matcher {
continue
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
continue
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if got, _ := hm["command"].(string); got == command {
count++
}
}
}
return count
}
func hasSessionStartCommand(t *testing.T, cfg map[string]any, command string) bool {
t.Helper()
return hasHookCommand(t, cfg, "SessionStart", command)
}
func hasHookCommand(t *testing.T, cfg map[string]any, event string, command string) bool {
t.Helper()
for _, entry := range hookEntries(t, cfg, event) {
group, ok := entry.(map[string]any)
if !ok {
continue
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
continue
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if got, _ := hm["command"].(string); got == command {
return true
}
}
}
return false
}