1321 lines
40 KiB
Go
1321 lines
40 KiB
Go
package preview
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/internal/prompter"
|
|
"github.com/cli/cli/v2/internal/skills/discovery"
|
|
"github.com/cli/cli/v2/internal/telemetry"
|
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
|
"github.com/cli/cli/v2/pkg/httpmock"
|
|
"github.com/cli/cli/v2/pkg/iostreams"
|
|
"github.com/google/shlex"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNewCmdPreview(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantRepo string
|
|
wantSkillName string
|
|
wantVersion string
|
|
wantAllowHiddenDirs bool
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "repo and skill",
|
|
input: "github/awesome-copilot my-skill",
|
|
wantRepo: "github/awesome-copilot",
|
|
wantSkillName: "my-skill",
|
|
},
|
|
{
|
|
name: "repo and skill with version",
|
|
input: "github/awesome-copilot my-skill@v1.2.0",
|
|
wantRepo: "github/awesome-copilot",
|
|
wantSkillName: "my-skill",
|
|
wantVersion: "v1.2.0",
|
|
},
|
|
{
|
|
name: "repo and skill with SHA",
|
|
input: "github/awesome-copilot my-skill@abc123def456",
|
|
wantRepo: "github/awesome-copilot",
|
|
wantSkillName: "my-skill",
|
|
wantVersion: "abc123def456",
|
|
},
|
|
{
|
|
name: "repo only",
|
|
input: "github/awesome-copilot",
|
|
wantRepo: "github/awesome-copilot",
|
|
},
|
|
{
|
|
name: "no args",
|
|
input: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "too many args",
|
|
input: "a b c",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "allow-hidden-dirs flag",
|
|
input: "github/awesome-copilot my-skill --allow-hidden-dirs",
|
|
wantRepo: "github/awesome-copilot",
|
|
wantSkillName: "my-skill",
|
|
wantAllowHiddenDirs: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
f := &cmdutil.Factory{
|
|
IOStreams: ios,
|
|
Prompter: &prompter.PrompterMock{},
|
|
}
|
|
|
|
var gotOpts *PreviewOptions
|
|
cmd := NewCmdPreview(f, &telemetry.NoOpService{}, func(opts *PreviewOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
|
|
args, _ := shlex.Split(tt.input)
|
|
cmd.SetArgs(args)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantRepo, gotOpts.RepoArg)
|
|
assert.Equal(t, tt.wantSkillName, gotOpts.SkillName)
|
|
assert.Equal(t, tt.wantVersion, gotOpts.Version)
|
|
assert.Equal(t, tt.wantAllowHiddenDirs, gotOpts.AllowHiddenDirs)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPreviewRun(t *testing.T) {
|
|
skillContent := heredoc.Doc(`
|
|
---
|
|
name: my-skill
|
|
description: A test skill
|
|
---
|
|
# My Skill
|
|
|
|
This is the skill content.
|
|
`)
|
|
encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent))
|
|
|
|
tests := []struct {
|
|
name string
|
|
opts *PreviewOptions
|
|
tty bool
|
|
httpStubs func(*httpmock.Registry)
|
|
wantStdout string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "preview specific skill",
|
|
tty: true,
|
|
opts: &PreviewOptions{
|
|
repo: ghrepo.New("github", "awesome-copilot"),
|
|
SkillName: "my-skill",
|
|
},
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/abc123"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills", "type": "tree", "sha": "tree1"},
|
|
{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"},
|
|
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/treeSHA"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/git/blobs/blob123"),
|
|
httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
},
|
|
wantStdout: "My Skill",
|
|
},
|
|
{
|
|
name: "preview with display name match",
|
|
tty: true,
|
|
opts: &PreviewOptions{
|
|
repo: ghrepo.New("owner", "repo"),
|
|
SkillName: "ns/my-skill",
|
|
},
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills", "type": "tree", "sha": "tree1"},
|
|
{"path": "skills/ns", "type": "tree", "sha": "tree-ns"},
|
|
{"path": "skills/ns/my-skill", "type": "tree", "sha": "treeSHA2"},
|
|
{"path": "skills/ns/my-skill/SKILL.md", "type": "blob", "sha": "blob456"}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA2"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blob456", "size": 50}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blob456"),
|
|
httpmock.StringResponse(`{"sha": "blob456", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
},
|
|
wantStdout: "My Skill",
|
|
},
|
|
{
|
|
name: "preview plugins skill matched by install name",
|
|
tty: true,
|
|
opts: &PreviewOptions{
|
|
repo: ghrepo.New("owner", "repo"),
|
|
SkillName: "aws-common/aws-mcp-setup",
|
|
},
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "plugins", "type": "tree", "sha": "tree-plugins"},
|
|
{"path": "plugins/aws-common", "type": "tree", "sha": "tree-awscommon"},
|
|
{"path": "plugins/aws-common/skills", "type": "tree", "sha": "tree-awsskills"},
|
|
{"path": "plugins/aws-common/skills/aws-mcp-setup", "type": "tree", "sha": "treeSHA3"},
|
|
{"path": "plugins/aws-common/skills/aws-mcp-setup/SKILL.md", "type": "blob", "sha": "blob789"}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA3"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blob789", "size": 50}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blob789"),
|
|
httpmock.StringResponse(`{"sha": "blob789", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
},
|
|
wantStdout: "My Skill",
|
|
},
|
|
{
|
|
name: "skill not found",
|
|
tty: true,
|
|
opts: &PreviewOptions{
|
|
repo: ghrepo.New("owner", "repo"),
|
|
SkillName: "nonexistent",
|
|
},
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills/my-skill", "type": "tree", "sha": "tree2"},
|
|
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"}
|
|
]
|
|
}`),
|
|
)
|
|
},
|
|
wantErr: `skill "nonexistent" not found in owner/repo`,
|
|
},
|
|
{
|
|
name: "no skill name non-interactive errors",
|
|
tty: false,
|
|
opts: &PreviewOptions{
|
|
repo: ghrepo.New("owner", "repo"),
|
|
},
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills/my-skill", "type": "tree", "sha": "tree2"},
|
|
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"}
|
|
]
|
|
}`),
|
|
)
|
|
},
|
|
wantErr: "must specify a skill name when not running interactively",
|
|
},
|
|
{
|
|
name: "preview with explicit version",
|
|
tty: true,
|
|
opts: &PreviewOptions{
|
|
repo: ghrepo.New("github", "awesome-copilot"),
|
|
SkillName: "my-skill",
|
|
Version: "abc123def456",
|
|
},
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
// ResolveRef with explicit version tries branch first, then tag, then commit
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/heads/abc123def456"),
|
|
httpmock.StatusStringResponse(404, "not found"),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/abc123def456"),
|
|
httpmock.StatusStringResponse(404, "not found"),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/commits/abc123def456"),
|
|
httpmock.StringResponse(`{"sha": "abc123def456789012345678901234567890abcd"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/abc123def456789012345678901234567890abcd"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123def456789012345678901234567890abcd",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills", "type": "tree", "sha": "tree1"},
|
|
{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"},
|
|
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/treeSHA"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/github/awesome-copilot/git/blobs/blob123"),
|
|
httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
},
|
|
wantStdout: "My Skill",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
if tt.httpStubs != nil {
|
|
tt.httpStubs(reg)
|
|
}
|
|
tt.opts.HttpClient = func() (*http.Client, error) {
|
|
return &http.Client{Transport: reg}, nil
|
|
}
|
|
|
|
ios, _, stdout, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(tt.tty)
|
|
ios.SetStdinTTY(tt.tty)
|
|
tt.opts.IO = ios
|
|
|
|
tt.opts.Prompter = &prompter.PrompterMock{}
|
|
tt.opts.Telemetry = &telemetry.NoOpService{}
|
|
|
|
err := previewRun(tt.opts)
|
|
|
|
if tt.wantErr != "" {
|
|
require.EqualError(t, err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
if tt.wantStdout != "" {
|
|
assert.Contains(t, stdout.String(), tt.wantStdout)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPreviewRun_UnsupportedHost(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
err := previewRun(&PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{}, nil },
|
|
repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"),
|
|
Telemetry: &telemetry.NoOpService{},
|
|
})
|
|
require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server")
|
|
}
|
|
|
|
func TestPreviewRun_Interactive(t *testing.T) {
|
|
skillContent := "# Selected Skill\n\nContent here."
|
|
encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent))
|
|
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills/alpha", "type": "tree", "sha": "tree-a"},
|
|
{"path": "skills/alpha/SKILL.md", "type": "blob", "sha": "blob-a"},
|
|
{"path": "skills/beta", "type": "tree", "sha": "tree-b"},
|
|
{"path": "skills/beta/SKILL.md", "type": "blob", "sha": "blob-b"}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/tree-b"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blob-b", "size": 40}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blob-b"),
|
|
httpmock.StringResponse(`{"sha": "blob-b", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
|
|
ios, _, stdout, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(true)
|
|
ios.SetStdinTTY(true)
|
|
|
|
pm := &prompter.PrompterMock{
|
|
SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) {
|
|
assert.Equal(t, "Select a skill to preview:", prompt)
|
|
assert.Equal(t, []string{"alpha", "beta"}, options)
|
|
return 1, nil // select "beta"
|
|
},
|
|
}
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: pm,
|
|
repo: ghrepo.New("owner", "repo"),
|
|
Telemetry: &telemetry.NoOpService{},
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, stdout.String(), "Selected Skill")
|
|
}
|
|
|
|
func TestPreviewRun_ShowsFileTree(t *testing.T) {
|
|
skillContent := heredoc.Doc(`
|
|
---
|
|
name: my-skill
|
|
description: test
|
|
---
|
|
# My Skill
|
|
Body.
|
|
`)
|
|
encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent))
|
|
|
|
scriptContent := "#!/bin/bash\necho hello"
|
|
encodedScript := base64.StdEncoding.EncodeToString([]byte(scriptContent))
|
|
|
|
makeReg := func() *httpmock.Registry {
|
|
reg := &httpmock.Registry{}
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"},
|
|
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"},
|
|
{"path": "skills/my-skill/scripts", "type": "tree", "sha": "treeScripts"},
|
|
{"path": "skills/my-skill/scripts/run.sh", "type": "blob", "sha": "blobScript"}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50},
|
|
{"path": "scripts", "type": "tree", "sha": "treeScripts"},
|
|
{"path": "scripts/run.sh", "type": "blob", "sha": "blobScript", "size": 20}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"),
|
|
httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blobScript"),
|
|
httpmock.StringResponse(`{"sha": "blobScript", "content": "`+encodedScript+`", "encoding": "base64"}`),
|
|
)
|
|
return reg
|
|
}
|
|
|
|
t.Run("interactive file picker", func(t *testing.T) {
|
|
reg := makeReg()
|
|
defer reg.Verify(t)
|
|
ios, _, stdout, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(true)
|
|
ios.SetStdinTTY(true)
|
|
ios.SetColorEnabled(false)
|
|
|
|
selectCalls := 0
|
|
pm := &prompter.PrompterMock{
|
|
SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) {
|
|
selectCalls++
|
|
if selectCalls == 1 {
|
|
// Options: ["SKILL.md", "scripts/run.sh"]
|
|
assert.Equal(t, "SKILL.md", options[0])
|
|
assert.Equal(t, "scripts/run.sh", options[1])
|
|
// Select "scripts/run.sh"
|
|
return 1, nil
|
|
}
|
|
// Simulate Esc/Ctrl-C to exit
|
|
return 0, fmt.Errorf("user cancelled")
|
|
},
|
|
}
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: pm,
|
|
repo: ghrepo.New("owner", "repo"),
|
|
SkillName: "my-skill",
|
|
Telemetry: &telemetry.NoOpService{},
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
|
|
out := stdout.String()
|
|
assert.Contains(t, out, "echo hello")
|
|
assert.Equal(t, 2, selectCalls)
|
|
})
|
|
|
|
t.Run("interactive markdown file uses markdown renderer", func(t *testing.T) {
|
|
readmeContent := "# Usage\n\nUse **carefully**."
|
|
encodedReadme := base64.StdEncoding.EncodeToString([]byte(readmeContent))
|
|
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"},
|
|
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"},
|
|
{"path": "skills/my-skill/README.md", "type": "blob", "sha": "blobREADME"}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50},
|
|
{"path": "README.md", "type": "blob", "sha": "blobREADME", "size": 28}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"),
|
|
httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blobREADME"),
|
|
httpmock.StringResponse(`{"sha": "blobREADME", "content": "`+encodedReadme+`", "encoding": "base64"}`),
|
|
)
|
|
|
|
ios, _, stdout, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(true)
|
|
ios.SetStdinTTY(true)
|
|
ios.SetColorEnabled(false)
|
|
|
|
renderCalls := 0
|
|
|
|
selectCalls := 0
|
|
pm := &prompter.PrompterMock{
|
|
SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) {
|
|
selectCalls++
|
|
if selectCalls == 1 {
|
|
assert.Equal(t, []string{"SKILL.md", "README.md"}, options)
|
|
return 1, nil
|
|
}
|
|
return 0, fmt.Errorf("user cancelled")
|
|
},
|
|
}
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: pm,
|
|
repo: ghrepo.New("owner", "repo"),
|
|
SkillName: "my-skill",
|
|
RenderFile: func(filePath, content string) string {
|
|
renderCalls++
|
|
return fmt.Sprintf("rendered:%s", filePath)
|
|
},
|
|
Telemetry: &telemetry.NoOpService{},
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
|
|
out := stdout.String()
|
|
assert.Contains(t, out, "rendered:README.md")
|
|
assert.Equal(t, 2, selectCalls)
|
|
assert.Equal(t, 2, renderCalls)
|
|
})
|
|
|
|
t.Run("non-interactive dumps all files", func(t *testing.T) {
|
|
reg := makeReg()
|
|
defer reg.Verify(t)
|
|
ios, _, stdout, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(false)
|
|
ios.SetStdinTTY(false)
|
|
ios.SetColorEnabled(false)
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: &prompter.PrompterMock{},
|
|
repo: ghrepo.New("owner", "repo"),
|
|
SkillName: "my-skill",
|
|
Telemetry: &telemetry.NoOpService{},
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
|
|
out := stdout.String()
|
|
assert.Contains(t, out, "my-skill/")
|
|
assert.Contains(t, out, "My Skill")
|
|
assert.Contains(t, out, "scripts/run.sh")
|
|
assert.Contains(t, out, "echo hello")
|
|
})
|
|
}
|
|
|
|
func TestPreviewRun_RenderLimits(t *testing.T) {
|
|
skillContent := heredoc.Doc(`
|
|
---
|
|
name: my-skill
|
|
description: test
|
|
---
|
|
# My Skill
|
|
`)
|
|
encodedSkill := base64.StdEncoding.EncodeToString([]byte(skillContent))
|
|
|
|
// Helper: build a tree JSON with N extra files (beyond SKILL.md)
|
|
buildTree := func(n int) string {
|
|
entries := []string{
|
|
`{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}`,
|
|
`{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}`,
|
|
}
|
|
for i := range n {
|
|
entries = append(entries, fmt.Sprintf(
|
|
`{"path": "skills/my-skill/file%03d.txt", "type": "blob", "sha": "blob%03d"}`, i, i))
|
|
}
|
|
return fmt.Sprintf(`{"sha":"abc123","truncated":false,"tree":[%s]}`,
|
|
strings.Join(entries, ","))
|
|
}
|
|
|
|
// Helper: build subtree JSON with N extra files
|
|
buildSubtree := func(n int, sizes []int) string {
|
|
entries := []string{
|
|
`{"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}`,
|
|
}
|
|
for i := range n {
|
|
sz := 10
|
|
if i < len(sizes) {
|
|
sz = sizes[i]
|
|
}
|
|
entries = append(entries, fmt.Sprintf(
|
|
`{"path": "file%03d.txt", "type": "blob", "sha": "blob%03d", "size": %d}`, i, i, sz))
|
|
}
|
|
return fmt.Sprintf(`{"tree":[%s]}`, strings.Join(entries, ","))
|
|
}
|
|
|
|
// Common stubs for resolve + discover
|
|
registerBase := func(reg *httpmock.Registry, treeJSON, subtreeJSON string) {
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/monalisa/skills-repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/abc123"),
|
|
httpmock.StringResponse(treeJSON),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/treeSHA"),
|
|
httpmock.StringResponse(subtreeJSON),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobSKILL"),
|
|
httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedSkill+`", "encoding": "base64"}`),
|
|
)
|
|
}
|
|
|
|
t.Run("maxFiles cap truncates at 20", func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
|
|
n := 22
|
|
treeJSON := buildTree(n)
|
|
subtreeJSON := buildSubtree(n, nil)
|
|
registerBase(reg, treeJSON, subtreeJSON)
|
|
|
|
// Register blob stubs for files 0-19 (first 20 get fetched)
|
|
tinyContent := base64.StdEncoding.EncodeToString([]byte("tiny"))
|
|
for i := range 20 {
|
|
reg.Register(
|
|
httpmock.REST("GET", fmt.Sprintf("repos/monalisa/skills-repo/git/blobs/blob%03d", i)),
|
|
httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob%03d", "content": "%s", "encoding": "base64"}`, i, tinyContent)),
|
|
)
|
|
}
|
|
// Files 20 and 21 should NOT be fetched
|
|
|
|
ios, _, stdout, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(false)
|
|
ios.SetStdinTTY(false)
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: &prompter.PrompterMock{},
|
|
repo: ghrepo.New("monalisa", "skills-repo"),
|
|
SkillName: "my-skill",
|
|
Telemetry: &telemetry.NoOpService{},
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
|
|
out := stdout.String()
|
|
assert.Contains(t, out, "showing first 20")
|
|
assert.Contains(t, out, "file019.txt") // last fetched
|
|
})
|
|
|
|
t.Run("maxBytes cap stops fetching", func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
|
|
// Two files: first is 500KB, second would exceed 512KB cap
|
|
sizes := []int{500 * 1024, 100 * 1024}
|
|
treeJSON := buildTree(2)
|
|
subtreeJSON := buildSubtree(2, sizes)
|
|
registerBase(reg, treeJSON, subtreeJSON)
|
|
|
|
bigContent := base64.StdEncoding.EncodeToString(make([]byte, 500*1024))
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"),
|
|
httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob000", "content": "%s", "encoding": "base64"}`, bigContent)),
|
|
)
|
|
// blob001 should NOT be fetched (size limit reached)
|
|
|
|
ios, _, stdout, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(false)
|
|
ios.SetStdinTTY(false)
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: &prompter.PrompterMock{},
|
|
repo: ghrepo.New("monalisa", "skills-repo"),
|
|
SkillName: "my-skill",
|
|
Telemetry: &telemetry.NoOpService{},
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
|
|
out := stdout.String()
|
|
assert.Contains(t, out, "size limit reached")
|
|
})
|
|
|
|
t.Run("blob fetch error shows fallback message", func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
|
|
treeJSON := buildTree(1)
|
|
subtreeJSON := buildSubtree(1, nil)
|
|
registerBase(reg, treeJSON, subtreeJSON)
|
|
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"),
|
|
httpmock.StatusStringResponse(500, "server error"),
|
|
)
|
|
|
|
ios, _, stdout, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(false)
|
|
ios.SetStdinTTY(false)
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: &prompter.PrompterMock{},
|
|
repo: ghrepo.New("monalisa", "skills-repo"),
|
|
SkillName: "my-skill",
|
|
Telemetry: &telemetry.NoOpService{},
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
|
|
out := stdout.String()
|
|
assert.Contains(t, out, "could not fetch file")
|
|
})
|
|
}
|
|
|
|
func TestPreviewRun_InteractiveTelemetryCapturesSelectedSkillName(t *testing.T) {
|
|
skillContent := "# Selected Skill\n\nContent here."
|
|
encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent))
|
|
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills/alpha", "type": "tree", "sha": "tree-a"},
|
|
{"path": "skills/alpha/SKILL.md", "type": "blob", "sha": "blob-a"},
|
|
{"path": "skills/beta", "type": "tree", "sha": "tree-b"},
|
|
{"path": "skills/beta/SKILL.md", "type": "blob", "sha": "blob-b"}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/tree-b"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blob-b", "size": 40}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blob-b"),
|
|
httpmock.StringResponse(`{"sha": "blob-b", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo"),
|
|
httpmock.JSONResponse(map[string]interface{}{
|
|
"visibility": "public",
|
|
}),
|
|
)
|
|
|
|
ios, _, _, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(true)
|
|
ios.SetStdinTTY(true)
|
|
|
|
pm := &prompter.PrompterMock{
|
|
SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) {
|
|
return 1, nil // select "beta"
|
|
},
|
|
}
|
|
|
|
recorder := &telemetry.EventRecorderSpy{}
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: pm,
|
|
Telemetry: recorder,
|
|
repo: ghrepo.New("owner", "repo"),
|
|
// SkillName intentionally left empty to simulate interactive selection
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the telemetry event captured the interactively-selected skill name, not empty string
|
|
require.Len(t, recorder.Events, 1)
|
|
event := recorder.Events[0]
|
|
assert.Equal(t, "skill_preview", event.Type)
|
|
assert.Equal(t, "beta", event.Dimensions["skill_name"], "telemetry should capture the selected skill name, not the empty opts.SkillName")
|
|
}
|
|
|
|
func TestPreviewRun_TelemetryVisibility(t *testing.T) {
|
|
skillContent := heredoc.Doc(`
|
|
---
|
|
name: my-skill
|
|
description: test
|
|
---
|
|
# My Skill
|
|
Body.
|
|
`)
|
|
encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent))
|
|
|
|
tests := []struct {
|
|
name string
|
|
visibility string
|
|
visibilityErr bool
|
|
wantSkillNames string
|
|
}{
|
|
{
|
|
name: "public repo includes skill names",
|
|
visibility: "public",
|
|
wantSkillNames: "my-skill",
|
|
},
|
|
{
|
|
name: "private repo excludes skill names",
|
|
visibility: "private",
|
|
},
|
|
{
|
|
name: "internal repo excludes skill names",
|
|
visibility: "internal",
|
|
},
|
|
{
|
|
name: "API error omits visibility and skill names",
|
|
visibilityErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(`{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"},
|
|
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"),
|
|
httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
if tt.visibilityErr {
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo"),
|
|
httpmock.StatusStringResponse(500, "server error"),
|
|
)
|
|
} else {
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo"),
|
|
httpmock.JSONResponse(map[string]interface{}{
|
|
"visibility": tt.visibility,
|
|
}),
|
|
)
|
|
}
|
|
|
|
ios, _, _, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(false)
|
|
ios.SetStdinTTY(false)
|
|
|
|
recorder := &telemetry.EventRecorderSpy{}
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: &prompter.PrompterMock{},
|
|
Telemetry: recorder,
|
|
repo: ghrepo.New("owner", "repo"),
|
|
SkillName: "my-skill",
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, recorder.Events, 1)
|
|
event := recorder.Events[0]
|
|
assert.Equal(t, "skill_preview", event.Type)
|
|
|
|
// skill_host_type is always recorded (categorized, no raw hostname for enterprise/tenancy).
|
|
assert.Equal(t, "github.com", event.Dimensions["skill_host_type"])
|
|
|
|
if tt.visibilityErr {
|
|
assert.Equal(t, "unknown", event.Dimensions["repo_visibility"],
|
|
"visibility fetch errors should emit repo_visibility=\"unknown\" so the fallback is distinguishable from a successful fetch")
|
|
} else {
|
|
assert.Equal(t, tt.visibility, event.Dimensions["repo_visibility"])
|
|
}
|
|
|
|
// Owner, repo, and skill name are only included when the repo
|
|
// is public; for private/internal/unknown they are omitted to
|
|
// avoid leaking identifiers of non-public repositories.
|
|
if tt.wantSkillNames != "" {
|
|
assert.Equal(t, "owner", event.Dimensions["skill_owner"])
|
|
assert.Equal(t, "repo", event.Dimensions["skill_repo"])
|
|
assert.Equal(t, tt.wantSkillNames, event.Dimensions["skill_name"])
|
|
} else {
|
|
assert.Empty(t, event.Dimensions["skill_owner"])
|
|
assert.Empty(t, event.Dimensions["skill_repo"])
|
|
assert.Empty(t, event.Dimensions["skill_name"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFilterHiddenDirSkills(t *testing.T) {
|
|
standardSkill := discovery.Skill{Name: "my-skill", Convention: "standard"}
|
|
hiddenSkill := discovery.Skill{Name: "hidden-skill", Convention: "hidden-dir"}
|
|
hiddenNS := discovery.Skill{Name: "ns-skill", Convention: "hidden-dir-namespaced"}
|
|
|
|
tests := []struct {
|
|
name string
|
|
allowHiddenDirs bool
|
|
skills []discovery.Skill
|
|
wantCount int
|
|
wantErr string
|
|
wantStderr string
|
|
}{
|
|
{
|
|
name: "no hidden skills returns all",
|
|
skills: []discovery.Skill{standardSkill},
|
|
wantCount: 1,
|
|
},
|
|
{
|
|
name: "hidden skills excluded by default",
|
|
skills: []discovery.Skill{standardSkill, hiddenSkill},
|
|
wantCount: 1,
|
|
wantStderr: "1 skill(s) in hidden directories were excluded",
|
|
},
|
|
{
|
|
name: "multiple hidden skills excluded with hint",
|
|
skills: []discovery.Skill{standardSkill, hiddenSkill, hiddenNS},
|
|
wantCount: 1,
|
|
wantStderr: "2 skill(s) in hidden directories were excluded",
|
|
},
|
|
{
|
|
name: "only hidden skills returns error",
|
|
skills: []discovery.Skill{hiddenSkill, hiddenNS},
|
|
wantErr: "no standard skills found, but 2 skill(s) exist in hidden directories",
|
|
},
|
|
{
|
|
name: "allow-hidden-dirs includes all skills",
|
|
allowHiddenDirs: true,
|
|
skills: []discovery.Skill{standardSkill, hiddenSkill},
|
|
wantCount: 2,
|
|
wantStderr: "Skills in hidden directories",
|
|
},
|
|
{
|
|
name: "allow-hidden-dirs with no hidden skills",
|
|
allowHiddenDirs: true,
|
|
skills: []discovery.Skill{standardSkill},
|
|
wantCount: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ios, _, _, stderr := iostreams.Test()
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
AllowHiddenDirs: tt.allowHiddenDirs,
|
|
}
|
|
|
|
result, err := filterHiddenDirSkills(opts, tt.skills)
|
|
|
|
if tt.wantErr != "" {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErr)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.Len(t, result, tt.wantCount)
|
|
if tt.wantStderr != "" {
|
|
assert.Contains(t, stderr.String(), tt.wantStderr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) {
|
|
skillContent := heredoc.Doc(`
|
|
---
|
|
name: my-skill
|
|
description: A test skill
|
|
---
|
|
# My Skill
|
|
|
|
This is the skill content.
|
|
`)
|
|
encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent))
|
|
|
|
// Tree contains both a standard skill and a hidden-dir skill
|
|
treeJSON := `{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"},
|
|
{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"},
|
|
{"path": ".claude/skills/hidden-skill", "type": "tree", "sha": "treeHidden"},
|
|
{"path": ".claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blobHidden"}
|
|
]
|
|
}`
|
|
|
|
t.Run("hidden skills excluded by default with hint", func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(treeJSON),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blob123"),
|
|
httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
|
|
ios, _, stdout, stderr := iostreams.Test()
|
|
ios.SetStdoutTTY(false)
|
|
ios.SetStdinTTY(false)
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: &prompter.PrompterMock{},
|
|
repo: ghrepo.New("owner", "repo"),
|
|
SkillName: "my-skill",
|
|
Telemetry: &telemetry.NoOpService{},
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, stdout.String(), "My Skill")
|
|
assert.Contains(t, stderr.String(), "skill(s) in hidden directories were excluded")
|
|
assert.Contains(t, stderr.String(), "allow-hidden-dirs")
|
|
})
|
|
|
|
t.Run("allow-hidden-dirs includes hidden skills", func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(treeJSON),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/treeHidden"),
|
|
httpmock.StringResponse(`{
|
|
"tree": [
|
|
{"path": "SKILL.md", "type": "blob", "sha": "blobHidden", "size": 50}
|
|
]
|
|
}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/blobs/blobHidden"),
|
|
httpmock.StringResponse(`{"sha": "blobHidden", "content": "`+encodedContent+`", "encoding": "base64"}`),
|
|
)
|
|
|
|
ios, _, stdout, stderr := iostreams.Test()
|
|
ios.SetStdoutTTY(false)
|
|
ios.SetStdinTTY(false)
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: &prompter.PrompterMock{},
|
|
repo: ghrepo.New("owner", "repo"),
|
|
SkillName: "hidden-skill",
|
|
AllowHiddenDirs: true,
|
|
Telemetry: &telemetry.NoOpService{},
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, stdout.String(), "My Skill")
|
|
assert.Contains(t, stderr.String(), "Skills in hidden directories")
|
|
assert.NotContains(t, stderr.String(), "were excluded")
|
|
})
|
|
|
|
t.Run("only hidden skills without flag returns error", func(t *testing.T) {
|
|
onlyHiddenTree := `{
|
|
"sha": "abc123",
|
|
"truncated": false,
|
|
"tree": [
|
|
{"path": ".claude/skills/hidden-skill", "type": "tree", "sha": "treeHidden"},
|
|
{"path": ".claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blobHidden"}
|
|
]
|
|
}`
|
|
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/releases/latest"),
|
|
httpmock.StringResponse(`{"tag_name": "v1.0.0"}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"),
|
|
httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`),
|
|
)
|
|
reg.Register(
|
|
httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"),
|
|
httpmock.StringResponse(onlyHiddenTree),
|
|
)
|
|
|
|
ios, _, _, _ := iostreams.Test()
|
|
ios.SetStdoutTTY(false)
|
|
ios.SetStdinTTY(false)
|
|
|
|
opts := &PreviewOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
|
|
Prompter: &prompter.PrompterMock{},
|
|
repo: ghrepo.New("owner", "repo"),
|
|
SkillName: "hidden-skill",
|
|
Telemetry: &telemetry.NoOpService{},
|
|
}
|
|
|
|
err := previewRun(opts)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "no standard skills found")
|
|
assert.Contains(t, err.Error(), "--allow-hidden-dirs")
|
|
})
|
|
}
|