| 1 | package pluginpkg |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "path/filepath" |
| 6 | "reflect" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "unicode/utf8" |
| 10 | ) |
| 11 | |
| 12 | func TestHookJSONExecutionFormPresenceMatrix(t *testing.T) { |
| 13 | tests := []struct { |
| 14 | name string |
| 15 | body string |
| 16 | wantArgsSet bool |
| 17 | wantArgs []string |
| 18 | }{ |
| 19 | {name: "omitted", body: `{"command":"echo ok"}`}, |
| 20 | {name: "explicit empty", body: `{"command":"echo","args":[]}`, wantArgsSet: true, wantArgs: []string{}}, |
| 21 | {name: "case insensitive field", body: `{"command":"echo","Args":[]}`, wantArgsSet: true, wantArgs: []string{}}, |
| 22 | {name: "literal values", body: `{"command":"echo","args":[""," spaced ","$HOME","a && b"]}`, wantArgsSet: true, wantArgs: []string{"", " spaced ", "$HOME", "a && b"}}, |
| 23 | } |
| 24 | for _, tt := range tests { |
| 25 | t.Run(tt.name, func(t *testing.T) { |
| 26 | var got Hook |
| 27 | if err := json.Unmarshal([]byte(tt.body), &got); err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | if got.ArgsSet != tt.wantArgsSet || !reflect.DeepEqual(got.Args, tt.wantArgs) { |
| 31 | t.Fatalf("hook args = %#v set=%v, want %#v set=%v", got.Args, got.ArgsSet, tt.wantArgs, tt.wantArgsSet) |
| 32 | } |
| 33 | }) |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func TestHookJSONRoundTripPreservesExplicitEmptyArgs(t *testing.T) { |
| 38 | before := Hook{Command: "bin/check", Args: []string{}, ArgsSet: true} |
| 39 | body, err := json.Marshal(before) |
| 40 | if err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if !strings.Contains(string(body), `"args":[]`) { |
| 44 | t.Fatalf("explicit empty args disappeared from JSON: %s", body) |
| 45 | } |
| 46 | var after Hook |
| 47 | if err := json.Unmarshal(body, &after); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | if !after.ArgsSet || after.Args == nil || len(after.Args) != 0 { |
| 51 | t.Fatalf("round-tripped hook = %#v, want explicit empty exec form", after) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func TestHookJSONRoundTripKeepsShellFormWithoutArgs(t *testing.T) { |
| 56 | before := Hook{Command: "echo ok && echo done", ShellCommand: true, Shell: "bash"} |
| 57 | body, err := json.Marshal(before) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if strings.Contains(string(body), `"args"`) { |
| 62 | t.Fatalf("shell-form JSON unexpectedly gained args: %s", body) |
| 63 | } |
| 64 | var after Hook |
| 65 | if err := json.Unmarshal(body, &after); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | if after.ArgsSet || after.Args != nil || after.Shell != "bash" { |
| 69 | t.Fatalf("round-tripped shell hook = %#v", after) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func TestNormalizeHooksExecutionContractMatrix(t *testing.T) { |
| 74 | got := normalizeHooks(map[string][]Hook{ |
| 75 | " SessionStart ": { |
| 76 | {Command: " echo legacy "}, |
| 77 | {Command: " echo shell ", Shell: " PoWeRsHeLl "}, |
| 78 | {Command: "bin/check", Args: []string{}, ArgsSet: true, Shell: "BASH"}, |
| 79 | {ContextFile: " CLAUDE.md "}, |
| 80 | {Command: " "}, |
| 81 | }, |
| 82 | }) |
| 83 | hooks := got["SessionStart"] |
| 84 | if len(hooks) != 4 { |
| 85 | t.Fatalf("normalized hooks = %#v, want four non-empty hooks", hooks) |
| 86 | } |
| 87 | if hooks[0].Command != "echo legacy" || hooks[0].ShellCommand { |
| 88 | t.Fatalf("legacy hook changed mode: %#v", hooks[0]) |
| 89 | } |
| 90 | if hooks[1].Command != "echo shell" || hooks[1].Shell != "powershell" || !hooks[1].ShellCommand { |
| 91 | t.Fatalf("shell hook normalization = %#v", hooks[1]) |
| 92 | } |
| 93 | if !hooks[2].ArgsSet || hooks[2].ShellCommand || hooks[2].Shell != "bash" { |
| 94 | t.Fatalf("exec hook did not take precedence over shell: %#v", hooks[2]) |
| 95 | } |
| 96 | if hooks[3].ContextFile != "CLAUDE.md" { |
| 97 | t.Fatalf("context hook normalization = %#v", hooks[3]) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestValidHookShellMatrix(t *testing.T) { |
| 102 | for _, shell := range []string{"", "auto", "AUTO", "bash", "powershell", "pwsh", "cmd", " CMD "} { |
| 103 | if !validHookShell(shell) { |
| 104 | t.Errorf("validHookShell(%q) = false", shell) |
| 105 | } |
| 106 | } |
| 107 | for _, shell := range []string{"sh", "zsh", "fish", "cmd.exe", "powershell.exe"} { |
| 108 | if validHookShell(shell) { |
| 109 | t.Errorf("validHookShell(%q) = true", shell) |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestNativeManifestRejectsUnsupportedHookShell(t *testing.T) { |
| 115 | root := t.TempDir() |
| 116 | path := filepath.Join(root, NativeManifest) |
| 117 | writeTestFile(t, path, `{ |
| 118 | "name":"bad-shell", |
| 119 | "hooks":{"SessionStart":[{"command":"echo ok","shell":"fish"}]} |
| 120 | }`) |
| 121 | if _, _, err := parseNative(path, root); err == nil || !strings.Contains(err.Error(), `hook shell "fish" is not supported`) { |
| 122 | t.Fatalf("parseNative unsupported shell error = %v", err) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func FuzzHookJSONExecFormRoundTrip(f *testing.F) { |
| 127 | for _, seed := range []string{"", " spaced ", "$HOME", `%PATH%`, `a && b | c`, `quote"'`, "中文🧪"} { |
| 128 | f.Add(seed) |
| 129 | } |
| 130 | f.Fuzz(func(t *testing.T, arg string) { |
| 131 | if !utf8.ValidString(arg) { |
| 132 | t.Skip() |
| 133 | } |
| 134 | before := Hook{Command: "tool", Args: []string{arg, "", "tail"}, ArgsSet: true} |
| 135 | body, err := json.Marshal(before) |
| 136 | if err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | var after Hook |
| 140 | if err := json.Unmarshal(body, &after); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | if !after.ArgsSet || !reflect.DeepEqual(after.Args, before.Args) { |
| 144 | t.Fatalf("exec-form args changed:\n got %#v\nwant %#v\njson %s", after.Args, before.Args, body) |
| 145 | } |
| 146 | }) |
| 147 | } |
| 148 |