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

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")
}
}
}