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
391 lines
11 KiB
Go
391 lines
11 KiB
Go
package claudecode
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestEmitPluginBundle_Layout(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
written, err := EmitPluginBundle(PluginBundleSpec{
|
|
TargetDir: dir,
|
|
Version: "0.18.2",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("EmitPluginBundle: %v", err)
|
|
}
|
|
|
|
// The emitter should write exactly: 1 plugin.json + 1 README +
|
|
// 1 LICENSE + 1 .mcp.json + N commands + N skills + 1 hooks.json
|
|
// + 1 hook handler script.
|
|
wantPaths := []string{
|
|
".claude-plugin/plugin.json",
|
|
".mcp.json",
|
|
"LICENSE",
|
|
"README.md",
|
|
"hooks-handlers/gortex-hook.sh",
|
|
"hooks/hooks.json",
|
|
}
|
|
for _, name := range sortedKeys(SlashCommands) {
|
|
wantPaths = append(wantPaths, filepath.Join("commands", name))
|
|
}
|
|
for _, name := range sortedKeys(GlobalSkills) {
|
|
wantPaths = append(wantPaths, filepath.Join("skills", name, "SKILL.md"))
|
|
}
|
|
for _, name := range sortedKeys(SubAgents) {
|
|
wantPaths = append(wantPaths, filepath.Join("agents", name))
|
|
}
|
|
sort.Strings(wantPaths)
|
|
|
|
got := append([]string(nil), written...)
|
|
sort.Strings(got)
|
|
|
|
if len(got) != len(wantPaths) {
|
|
t.Fatalf("file count mismatch: got %d, want %d.\ngot=%v\nwant=%v", len(got), len(wantPaths), got, wantPaths)
|
|
}
|
|
for i := range got {
|
|
if got[i] != wantPaths[i] {
|
|
t.Errorf("path[%d] mismatch: got %q, want %q", i, got[i], wantPaths[i])
|
|
}
|
|
}
|
|
|
|
// Every path the emitter claimed to write must exist on disk.
|
|
for _, rel := range written {
|
|
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
|
t.Errorf("missing file %s: %v", rel, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_PluginManifestShape(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := EmitPluginBundle(PluginBundleSpec{
|
|
TargetDir: dir,
|
|
Version: "0.18.2",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "plugin.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var manifest map[string]any
|
|
if err := json.Unmarshal(body, &manifest); err != nil {
|
|
t.Fatalf("plugin.json: invalid JSON: %v", err)
|
|
}
|
|
// Required keys per the marketplace schema we observed in
|
|
// claude-plugins-official: name, description, author. We add
|
|
// version + homepage for our own metadata.
|
|
for _, key := range []string{"name", "description", "author", "version", "homepage"} {
|
|
if _, ok := manifest[key]; !ok {
|
|
t.Errorf("plugin.json missing required key %q", key)
|
|
}
|
|
}
|
|
if got := manifest["name"]; got != "gortex" {
|
|
t.Errorf("plugin.json name = %v, want gortex", got)
|
|
}
|
|
if got := manifest["version"]; got != "0.18.2" {
|
|
t.Errorf("plugin.json version = %v, want 0.18.2", got)
|
|
}
|
|
if !strings.HasSuffix(string(body), "\n") {
|
|
t.Errorf("plugin.json should end with a newline (got %q)", string(body[len(body)-3:]))
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_MCPJSONShape(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := EmitPluginBundle(PluginBundleSpec{
|
|
TargetDir: dir,
|
|
Version: "0.18.2",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body, err := os.ReadFile(filepath.Join(dir, ".mcp.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var mcp map[string]any
|
|
if err := json.Unmarshal(body, &mcp); err != nil {
|
|
t.Fatalf(".mcp.json: invalid JSON: %v", err)
|
|
}
|
|
gx, ok := mcp["gortex"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf(".mcp.json: missing 'gortex' entry, got %v", mcp)
|
|
}
|
|
if got := gx["command"]; got != "gortex" {
|
|
t.Errorf(".mcp.json command = %v, want gortex", got)
|
|
}
|
|
args, ok := gx["args"].([]any)
|
|
if !ok || len(args) != 1 || args[0] != "mcp" {
|
|
t.Errorf(".mcp.json args = %v, want [mcp]", args)
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_HooksShape(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := EmitPluginBundle(PluginBundleSpec{
|
|
TargetDir: dir,
|
|
Version: "0.18.2",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body, err := os.ReadFile(filepath.Join(dir, "hooks", "hooks.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
t.Fatalf("hooks.json: invalid JSON: %v", err)
|
|
}
|
|
hooks, ok := doc["hooks"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("hooks.json: missing top-level 'hooks' object: %v", doc)
|
|
}
|
|
|
|
// All four event kinds must be present; each must reference the
|
|
// hooks-handlers script via ${CLAUDE_PLUGIN_ROOT}.
|
|
for _, event := range []string{"PreToolUse", "PreCompact", "Stop", "SessionStart"} {
|
|
entries, ok := hooks[event].([]any)
|
|
if !ok || len(entries) == 0 {
|
|
t.Errorf("hooks.json: event %s missing or empty", event)
|
|
continue
|
|
}
|
|
entry := entries[0].(map[string]any)
|
|
inner, ok := entry["hooks"].([]any)
|
|
if !ok || len(inner) == 0 {
|
|
t.Errorf("hooks.json: %s has no inner hooks", event)
|
|
continue
|
|
}
|
|
first := inner[0].(map[string]any)
|
|
cmd, _ := first["command"].(string)
|
|
if !strings.Contains(cmd, "${CLAUDE_PLUGIN_ROOT}") {
|
|
t.Errorf("hooks.json: %s command does not reference ${CLAUDE_PLUGIN_ROOT}: %q", event, cmd)
|
|
}
|
|
if !strings.Contains(cmd, "gortex-hook.sh") {
|
|
t.Errorf("hooks.json: %s command does not invoke gortex-hook.sh: %q", event, cmd)
|
|
}
|
|
}
|
|
|
|
// PreToolUse should carry the canonical matcher.
|
|
pre := hooks["PreToolUse"].([]any)
|
|
matcher, _ := pre[0].(map[string]any)["matcher"].(string)
|
|
if matcher != CurrentPreToolUseMatcher {
|
|
t.Errorf("hooks.json: PreToolUse matcher = %q, want %q", matcher, CurrentPreToolUseMatcher)
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_HookHandlerExecBit(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := EmitPluginBundle(PluginBundleSpec{
|
|
TargetDir: dir,
|
|
Version: "0.18.2",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info, err := os.Stat(filepath.Join(dir, "hooks-handlers", "gortex-hook.sh"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode()&0o111 == 0 {
|
|
t.Errorf("hooks-handlers/gortex-hook.sh should be executable (mode=%v)", info.Mode())
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_SkillBodiesMatchSource(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := EmitPluginBundle(PluginBundleSpec{
|
|
TargetDir: dir,
|
|
Version: "0.18.2",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Single-source-of-truth check: the bytes written under
|
|
// skills/<name>/SKILL.md must equal GlobalSkills[<name>] exactly.
|
|
// Drift means content.go was edited without re-running the
|
|
// emitter; the CI guard catches that case but we double-check
|
|
// here in unit tests.
|
|
for name, want := range GlobalSkills {
|
|
got, err := os.ReadFile(filepath.Join(dir, "skills", name, "SKILL.md"))
|
|
if err != nil {
|
|
t.Errorf("read skill %s: %v", name, err)
|
|
continue
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("skill %s body drift: bytes do not match GlobalSkills[%q]", name, name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_CommandBodiesMatchSource(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := EmitPluginBundle(PluginBundleSpec{
|
|
TargetDir: dir,
|
|
Version: "0.18.2",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for name, want := range SlashCommands {
|
|
got, err := os.ReadFile(filepath.Join(dir, "commands", name))
|
|
if err != nil {
|
|
t.Errorf("read command %s: %v", name, err)
|
|
continue
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("command %s body drift", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_Idempotent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
first, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
|
|
if err != nil {
|
|
t.Fatalf("second emit: %v", err)
|
|
}
|
|
|
|
if len(first) != len(second) {
|
|
t.Fatalf("file count drift between runs: %d vs %d", len(first), len(second))
|
|
}
|
|
// Every file's bytes must be identical across the two runs.
|
|
for _, rel := range first {
|
|
path := filepath.Join(dir, rel)
|
|
body1, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Re-emit and re-read: same path, must equal first body.
|
|
body2, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(body1) != string(body2) {
|
|
t.Errorf("non-idempotent output at %s", rel)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_RejectsBadInputs(t *testing.T) {
|
|
cases := map[string]PluginBundleSpec{
|
|
"missing-target": {Version: "0.18.2"},
|
|
"missing-version": {TargetDir: t.TempDir()},
|
|
"bad-variant": {TargetDir: t.TempDir(), Version: "0.18.2", LayoutVariant: "weird"},
|
|
}
|
|
for name, spec := range cases {
|
|
_, err := EmitPluginBundle(spec)
|
|
if err == nil {
|
|
t.Errorf("%s: expected error, got nil", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_VariantDefaultsToAnthropic(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Empty LayoutVariant should fall through to LayoutVariantAnthropic.
|
|
_, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
|
|
if err != nil {
|
|
t.Fatalf("empty variant: %v", err)
|
|
}
|
|
// Verify by checking that the Anthropic-shaped manifest exists.
|
|
if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil {
|
|
t.Errorf("default variant did not produce .claude-plugin/plugin.json: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_OverwritesExistingFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Pre-seed an old README to ensure overwrite works.
|
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("OLD\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body, err := os.ReadFile(filepath.Join(dir, "README.md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(body) == "OLD\n" {
|
|
t.Errorf("emitter did not overwrite existing README.md")
|
|
}
|
|
}
|
|
|
|
func TestPluginPathHelpers(t *testing.T) {
|
|
if PluginManifestPath() != filepath.Join(".claude-plugin", "plugin.json") {
|
|
t.Errorf("PluginManifestPath wrong: %s", PluginManifestPath())
|
|
}
|
|
if PluginMCPPath() != ".mcp.json" {
|
|
t.Errorf("PluginMCPPath wrong: %s", PluginMCPPath())
|
|
}
|
|
if PluginHooksPath() != filepath.Join("hooks", "hooks.json") {
|
|
t.Errorf("PluginHooksPath wrong: %s", PluginHooksPath())
|
|
}
|
|
|
|
cmds := PluginCommandPaths()
|
|
if len(cmds) != len(SlashCommands) {
|
|
t.Errorf("PluginCommandPaths count: got %d, want %d", len(cmds), len(SlashCommands))
|
|
}
|
|
skills := PluginSkillPaths()
|
|
if len(skills) != len(GlobalSkills) {
|
|
t.Errorf("PluginSkillPaths count: got %d, want %d", len(skills), len(GlobalSkills))
|
|
}
|
|
subagents := PluginSubAgentPaths()
|
|
if len(subagents) != len(SubAgents) {
|
|
t.Errorf("PluginSubAgentPaths count: got %d, want %d", len(subagents), len(SubAgents))
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_SubAgentBodiesMatchSource(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := EmitPluginBundle(PluginBundleSpec{
|
|
TargetDir: dir,
|
|
Version: "0.18.2",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for name, want := range SubAgents {
|
|
got, err := os.ReadFile(filepath.Join(dir, "agents", name))
|
|
if err != nil {
|
|
t.Errorf("read sub-agent %s: %v", name, err)
|
|
continue
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("sub-agent %s body drift: bytes do not match SubAgents[%q]", name, name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmitPluginBundle_MissingTargetParent(t *testing.T) {
|
|
// MkdirAll handles non-existent parents — verify that.
|
|
dir := filepath.Join(t.TempDir(), "deep", "nested", "path")
|
|
_, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
|
|
if err != nil {
|
|
t.Errorf("EmitPluginBundle should create parent dirs: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("emitter did not create plugin.json at deep target")
|
|
}
|
|
}
|
|
}
|