core/cmd/ninja/cmd/plugin_test.go
Alex Dunmow 7615bd92ca feat(cli): multi-account login, private-plugin SDK, publish dirty handling
- ninja login forces account selection (interactive when >1); creds now
  carry ActiveAccountID/Slug. New `ninja account` group.
- ninja plugin list / delete / delete-version split public vs active-account
  @private sections; `publish --private` is sticky in plugin.mod.
- GetPluginRequest gains active_account_id so @private resolution works
  alongside the public (scope, name) path.
- publish auto-commits a dirty plugin.mod (path-scoped, leaves other staged
  paths alone) so the bump→publish loop never trips the dirty check.
  --allow-dirty is replaced with --strict (default now ships dirty trees
  via stash-create).
- bump auto-commits its plugin.mod write with `bump to X.Y.Z`; --no-commit
  opts out.
- Design doc updated to match the new defaults.
2026-06-04 08:49:23 +08:00

566 lines
16 KiB
Go

package cmd
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
core "git.dev.alexdunmow.com/block/core/plugin"
)
func TestCheckRepoHasHEAD_NoCommitsReturnsFriendlyError(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
err := checkRepoHasHEAD(dir)
if err == nil {
t.Fatal("expected error for repo with no commits, got nil")
}
if !strings.Contains(err.Error(), "no commits in repository") {
t.Errorf("error %q should mention 'no commits in repository'", err.Error())
}
if !strings.Contains(err.Error(), "git commit") {
t.Errorf("error %q should suggest `git commit`", err.Error())
}
}
func TestCheckRepoHasHEAD_WithCommitReturnsNil(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "f")
runGit(t, dir, "commit", "-qm", "init")
if err := checkRepoHasHEAD(dir); err != nil {
t.Errorf("expected nil for repo with a commit, got %v", err)
}
}
func TestAutoCommitPluginMod_CommitsWhenDirty(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
subject := gitLogSubject(t, dir)
if subject != "Add plugin.mod" {
t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject)
}
}
func TestAutoCommitPluginMod_NoopWhenClean(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "plugin.mod")
runGit(t, dir, "commit", "-qm", "seed")
beforeSHA := gitHeadSHA(t, dir)
t.Chdir(dir)
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
afterSHA := gitHeadSHA(t, dir)
if afterSHA != beforeSHA {
t.Errorf("expected no new commit, HEAD moved %s -> %s", beforeSHA, afterSHA)
}
}
func TestAutoCommitPluginMod_WorksOnDetachedHEAD(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
initialSHA := gitHeadSHA(t, dir)
runGit(t, dir, "checkout", "-q", initialSHA)
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod on detached HEAD: %v", err)
}
afterSHA := gitHeadSHA(t, dir)
if afterSHA == initialSHA {
t.Fatalf("expected new commit on detached HEAD, HEAD still at %s", afterSHA)
}
subject := gitLogSubject(t, dir)
if subject != "Add plugin.mod" {
t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject)
}
parentCmd := exec.Command("git", "rev-parse", "HEAD^")
parentCmd.Dir = dir
parentOut, err := parentCmd.CombinedOutput()
if err != nil {
t.Fatalf("git rev-parse HEAD^: %v\n%s", err, parentOut)
}
parentSHA := strings.TrimSpace(string(parentOut))
if parentSHA != initialSHA {
t.Errorf("expected new commit parent to be %s, got %s", initialSHA, parentSHA)
}
}
func TestAutoCommitPluginMod_UsesProvidedMessage(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("bump to 0.3.0"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
if got := gitLogSubject(t, dir); got != "bump to 0.3.0" {
t.Errorf("expected commit subject 'bump to 0.3.0', got %q", got)
}
}
func TestAutoCommitPluginMod_LeavesOtherStagedPathsAlone(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
// Stage an unrelated change that publish should NOT sweep up into the
// plugin.mod auto-commit.
if err := os.WriteFile(filepath.Join(dir, "other.txt"), []byte("scratch"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "other.txt")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("bump to 0.3.0"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
// The new commit should touch plugin.mod only.
filesCmd := exec.Command("git", "show", "--name-only", "--pretty=", "HEAD")
filesCmd.Dir = dir
filesOut, err := filesCmd.CombinedOutput()
if err != nil {
t.Fatalf("git show: %v\n%s", err, filesOut)
}
files := strings.Fields(strings.TrimSpace(string(filesOut)))
if len(files) != 1 || files[0] != "plugin.mod" {
t.Errorf("expected commit to touch only plugin.mod, got %v", files)
}
// other.txt should still be staged (waiting for the developer to deal with).
statusCmd := exec.Command("git", "status", "--porcelain", "other.txt")
statusCmd.Dir = dir
statusOut, _ := statusCmd.Output()
if !strings.HasPrefix(strings.TrimSpace(string(statusOut)), "A ") {
t.Errorf("expected other.txt to remain staged ('A '), got %q", string(statusOut))
}
}
func TestAutoCommitPluginMod_ErrorsWhenGitMissing(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
t.Setenv("PATH", "")
err := autoCommitPluginMod("Add plugin.mod")
if err == nil {
t.Fatal("expected error when git is missing from PATH, got nil")
}
if !strings.Contains(err.Error(), "git") {
t.Errorf("error %q should mention 'git'", err.Error())
}
}
func gitLogSubject(t *testing.T, dir string) string {
t.Helper()
cmd := exec.Command("git", "log", "-1", "--pretty=%s")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git log: %v\n%s", err, out)
}
return strings.TrimSpace(string(out))
}
func gitHeadSHA(t *testing.T, dir string) string {
t.Helper()
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git rev-parse: %v\n%s", err, out)
}
return strings.TrimSpace(string(out))
}
func TestGitignoredTrackedWarning_FiresWhenTrackedFileMatchesGitignore(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "secret.env")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitignore")
runGit(t, dir, "commit", "-qm", "ignore")
var buf bytes.Buffer
gitignoredTrackedWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "secret.env") {
t.Errorf("warning should list secret.env, got: %q", out)
}
if !strings.Contains(out, "git rm --cached") {
t.Errorf("warning should suggest `git rm --cached`, got: %q", out)
}
if !strings.HasSuffix(out, "\n") {
t.Errorf("warning should end with a newline (Fprintln), got: %q", out)
}
}
func TestGitignoredTrackedWarning_NoopWhenNothingMatches(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
var buf bytes.Buffer
gitignoredTrackedWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output for clean repo, got: %q", buf.String())
}
}
func TestUntrackedFilesWarning_FiresWithUntrackedFile(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("scratch"), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
untrackedFilesWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "notes.txt") {
t.Errorf("warning should list notes.txt, got: %q", out)
}
if !strings.Contains(out, "git add") {
t.Errorf("warning should suggest `git add`, got: %q", out)
}
}
func TestUntrackedFilesWarning_NoopWithNoUntracked(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
var buf bytes.Buffer
untrackedFilesWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output, got: %q", buf.String())
}
}
func TestSubmoduleWarning_FiresWithGitmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
gitmodules := `[submodule "vendor/foo"]
path = vendor/foo
url = https://example.com/foo.git
`
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
submoduleWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "vendor/foo") {
t.Errorf("warning should mention vendor/foo, got: %q", out)
}
if !strings.Contains(out, "submodules") {
t.Errorf("warning should mention submodules, got: %q", out)
}
}
func TestSubmoduleWarning_NoopWithoutGitmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
var buf bytes.Buffer
submoduleWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output, got: %q", buf.String())
}
}
func TestEmitPublishWarnings_WarnsAboutGitignoreTracked(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "secret.env")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitignore")
runGit(t, dir, "commit", "-qm", "ignore")
var buf bytes.Buffer
if err := emitPublishWarnings(dir, false, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "secret.env") {
t.Errorf("expected gitignored-tracked warning to mention secret.env, got: %q", out)
}
if !strings.Contains(out, "match .gitignore") {
t.Errorf("expected gitignored-tracked warning fragment, got: %q", out)
}
}
func TestEmitPublishWarnings_WarnsAboutSubmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
gitmodules := `[submodule "vendor/foo"]
path = vendor/foo
url = https://example.com/foo.git
`
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitmodules")
runGit(t, dir, "commit", "-qm", "add submodule decl")
var buf bytes.Buffer
if err := emitPublishWarnings(dir, false, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "vendor/foo") {
t.Errorf("expected submodule warning to mention vendor/foo, got: %q", out)
}
if !strings.Contains(out, "submodules") {
t.Errorf("expected submodule warning fragment, got: %q", out)
}
}
func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "scratch.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
t.Run("allowDirty=true surfaces untracked warning", func(t *testing.T) {
var buf bytes.Buffer
if err := emitPublishWarnings(dir, true, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "scratch.txt") {
t.Errorf("expected untracked-files warning to mention scratch.txt, got: %q", out)
}
if !strings.Contains(out, "NOT be in the archive") {
t.Errorf("expected untracked-files warning fragment, got: %q", out)
}
})
t.Run("allowDirty=false aborts before untracked warning", func(t *testing.T) {
var buf bytes.Buffer
err := emitPublishWarnings(dir, false, &buf)
if err == nil {
t.Fatal("expected dirty-tree error, got nil")
}
if !strings.Contains(err.Error(), "working tree dirty") {
t.Errorf("expected dirty-tree error, got: %v", err)
}
if !strings.Contains(err.Error(), "--strict") {
t.Errorf("expected dirty-tree error to reference --strict, got: %v", err)
}
if strings.Contains(buf.String(), "untracked files will NOT be in the archive") {
t.Errorf("untracked-files warning should not fire on dirty-abort path, got: %q", buf.String())
}
})
}
func TestWriteMod_PrivateTrueSerializes(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "plugin.mod")
m := &core.ModFile{Plugin: core.ModPlugin{
Name: "myplugin",
Scope: "themes",
Version: "0.1.0",
Private: true,
}}
if err := writeMod(path, m); err != nil {
t.Fatalf("writeMod: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if !strings.Contains(string(got), "private = true") {
t.Errorf("expected `private = true` line in plugin.mod, got:\n%s", got)
}
}
func TestWriteMod_PrivateFalseOmitted(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "plugin.mod")
m := &core.ModFile{Plugin: core.ModPlugin{
Name: "publicthing",
Scope: "themes",
Version: "0.1.0",
}}
if err := writeMod(path, m); err != nil {
t.Fatalf("writeMod: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if strings.Contains(string(got), "private") {
t.Errorf("expected no `private` line, got:\n%s", got)
}
}
func TestParsePrivateCoord(t *testing.T) {
cases := []struct {
in string
want string
wantErr bool
}{
{in: "myplugin", want: "myplugin"},
{in: "@private/myplugin", want: "myplugin"},
{in: " myplugin ", want: "myplugin"},
{in: "@themes/myplugin", wantErr: true},
{in: "@private", wantErr: true},
}
for _, c := range cases {
got, err := parsePrivateCoord(c.in)
if c.wantErr {
if err == nil {
t.Errorf("parsePrivateCoord(%q) = %q, want error", c.in, got)
}
continue
}
if err != nil {
t.Errorf("parsePrivateCoord(%q) err: %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("parsePrivateCoord(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t",
"GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t",
"GIT_COMMITTER_EMAIL=t@t",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}