diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 121b924..da15611 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,16 +10,20 @@ concurrency: cancel-in-progress: true jobs: - test: + unit-tests: + name: Unit tests runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - name: Set up Go + uses: actions/setup-go@v6 with: go-version-file: go.mod - - uses: actions/cache@v5 + - name: Cache Go modules + uses: actions/cache@v5 with: path: | ~/.cache/go/pkg/mod @@ -40,3 +44,28 @@ jobs: - name: Build (smoke) run: go build -o /dev/null . + + integration-tests: + name: Integration tests + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Cache Go modules + uses: actions/cache@v5 + with: + path: | + ~/.cache/go/pkg/mod + ~/.cache/go/build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Integration tests (no network) + run: go test -tags=integration -race -count=1 ./internal/cmd/... diff --git a/internal/cmd/integration_test.go b/internal/cmd/integration_test.go new file mode 100644 index 0000000..b8c77f1 --- /dev/null +++ b/internal/cmd/integration_test.go @@ -0,0 +1,422 @@ +//go:build integration + +// Package cmd integration tests drive the cobra command tree against static +// fixtures in internal/testdata/. No network access is required; all sources +// are local paths. Run with: go test -tags=integration ./internal/cmd/... +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/specsnl/specs-cli/internal/specs" +) + +// fixtureDir returns the absolute path to a named fixture inside internal/testdata/. +// Go sets the test working directory to the package source dir (internal/cmd/), so +// "../testdata/" resolves to "internal/testdata/". +func fixtureDir(t *testing.T, name string) string { + t.Helper() + p, err := filepath.Abs(filepath.Join("..", "testdata", name)) + if err != nil { + t.Fatalf("fixtureDir: %v", err) + } + if _, err := os.Stat(p); err != nil { + t.Fatalf("fixture %q not found at %s: %v", name, p, err) + } + return p +} + +// --- specs use (one-shot, no registry) --- + +func TestIntegration_Use_Minimal(t *testing.T) { + src := fixtureDir(t, "minimal") + target := t.TempDir() + + if _, err := executeCmd("use", "--use-defaults", "file:"+src, target); err != nil { + t.Fatalf("specs use: %v", err) + } + + readme, err := os.ReadFile(filepath.Join(target, "README.md")) + if err != nil { + t.Fatalf("README.md missing: %v", err) + } + if !strings.Contains(string(readme), "my-project") { + t.Errorf("README.md: want 'my-project', got %q", string(readme)) + } + if !strings.Contains(string(readme), "John Doe") { + t.Errorf("README.md: want 'John Doe', got %q", string(readme)) + } + + config, err := os.ReadFile(filepath.Join(target, "config.txt")) + if err != nil { + t.Fatalf("config.txt missing: %v", err) + } + if !strings.Contains(string(config), "my-project") { + t.Errorf("config.txt: want 'my-project', got %q", string(config)) + } +} + +func TestIntegration_Use_Conditional_DatabaseFalse(t *testing.T) { + src := fixtureDir(t, "conditional") + target := t.TempDir() + + // Default: UseDatabase=false → conditional file and directory are skipped. + if _, err := executeCmd("use", "--use-defaults", "file:"+src, target); err != nil { + t.Fatalf("specs use: %v", err) + } + + if _, err := os.ReadFile(filepath.Join(target, "README.md")); err != nil { + t.Fatalf("README.md should always be present: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "database.env")); err == nil { + t.Error("database.env should not exist when UseDatabase=false") + } + if _, err := os.Stat(filepath.Join(target, "db", "schema.sql")); err == nil { + t.Error("db/schema.sql should not exist when UseDatabase=false") + } +} + +func TestIntegration_Use_Conditional_DatabaseTrue(t *testing.T) { + src := fixtureDir(t, "conditional") + target := t.TempDir() + + // Override: UseDatabase=true → conditional file and directory are created. + if _, err := executeCmd("use", "--use-defaults", "--arg", "UseDatabase=true", "file:"+src, target); err != nil { + t.Fatalf("specs use: %v", err) + } + + dbEnv, err := os.ReadFile(filepath.Join(target, "database.env")) + if err != nil { + t.Fatalf("database.env missing when UseDatabase=true: %v", err) + } + if !strings.Contains(string(dbEnv), "mydb") { + t.Errorf("database.env: want 'mydb', got %q", string(dbEnv)) + } + if _, err := os.Stat(filepath.Join(target, "db", "schema.sql")); err != nil { + t.Errorf("db/schema.sql should exist when UseDatabase=true: %v", err) + } +} + +func TestIntegration_Use_Computed(t *testing.T) { + src := fixtureDir(t, "computed") + target := t.TempDir() + + // ProjectShortName=acme → ProjectSlug=acme → DbName=acme_production → DbTestName=acme_test + if _, err := executeCmd("use", "--use-defaults", "file:"+src, target); err != nil { + t.Fatalf("specs use: %v", err) + } + + dbEnv, err := os.ReadFile(filepath.Join(target, "db.env")) + if err != nil { + t.Fatalf("db.env missing: %v", err) + } + content := string(dbEnv) + for _, want := range []string{"acme_production", "acme_test", "acme", "my-project"} { + if !strings.Contains(content, want) { + t.Errorf("db.env: want %q in output, got %q", want, content) + } + } +} + +func TestIntegration_Use_Hooks_Inline(t *testing.T) { + src := fixtureDir(t, "hooks") + target := t.TempDir() + + // post-use hook: echo "{{ .ProjectName }}" > hook-output.txt (runs in target dir) + if _, err := executeCmd("use", "--use-defaults", "file:"+src, target); err != nil { + t.Fatalf("specs use: %v", err) + } + + out, err := os.ReadFile(filepath.Join(target, "hook-output.txt")) + if err != nil { + t.Fatalf("hook-output.txt missing — post-use hook did not run: %v", err) + } + if got := strings.TrimSpace(string(out)); got != "my-project" { + t.Errorf("hook-output.txt = %q, want %q", got, "my-project") + } +} + +func TestIntegration_Use_HooksFile(t *testing.T) { + src := fixtureDir(t, "hooks-file") + target := t.TempDir() + + // post-use.sh: echo "$SPECS_PROJECTNAME" > hook-output.txt (runs in target dir) + if _, err := executeCmd("use", "--use-defaults", "file:"+src, target); err != nil { + t.Fatalf("specs use: %v", err) + } + + out, err := os.ReadFile(filepath.Join(target, "hook-output.txt")) + if err != nil { + t.Fatalf("hook-output.txt missing — file-based post-use hook did not run: %v", err) + } + if got := strings.TrimSpace(string(out)); got != "my-project" { + t.Errorf("hook-output.txt = %q, want %q", got, "my-project") + } +} + +func TestIntegration_Use_Verbatim(t *testing.T) { + src := fixtureDir(t, "verbatim") + target := t.TempDir() + + if _, err := executeCmd("use", "--use-defaults", "file:"+src, target); err != nil { + t.Fatalf("specs use: %v", err) + } + + // normal.txt is rendered: {{ .ProjectName }} → my-project, no delimiters remain. + normal, err := os.ReadFile(filepath.Join(target, "normal.txt")) + if err != nil { + t.Fatalf("normal.txt missing: %v", err) + } + if strings.Contains(string(normal), "{{") { + t.Errorf("normal.txt should be rendered, still contains delimiters: %q", string(normal)) + } + if !strings.Contains(string(normal), "my-project") { + t.Errorf("normal.txt: want 'my-project', got %q", string(normal)) + } + + // literal.raw matches *.raw in .specsverbatim — copied verbatim, delimiters intact. + raw, err := os.ReadFile(filepath.Join(target, "literal.raw")) + if err != nil { + t.Fatalf("literal.raw missing: %v", err) + } + if !strings.Contains(string(raw), "{{") { + t.Errorf("literal.raw should be verbatim (contain delimiters), got %q", string(raw)) + } + + // vendor/config.js matches vendor/** in .specsverbatim — copied verbatim. + vendor, err := os.ReadFile(filepath.Join(target, "vendor", "config.js")) + if err != nil { + t.Fatalf("vendor/config.js missing: %v", err) + } + if !strings.Contains(string(vendor), "{{") { + t.Errorf("vendor/config.js should be verbatim (contain delimiters), got %q", string(vendor)) + } + + // icon.bin is a binary file — copied verbatim regardless of .specsverbatim. + if _, err := os.Stat(filepath.Join(target, "icon.bin")); err != nil { + t.Errorf("icon.bin missing — binary file should be copied verbatim: %v", err) + } +} + +func TestIntegration_Use_Delimiters(t *testing.T) { + src := fixtureDir(t, "delimiters") + target := t.TempDir() + + // Custom delimiters [[ ]]: [[ .ProjectName ]] is rendered, {{ }} passes through. + if _, err := executeCmd("use", "--use-defaults", "file:"+src, target); err != nil { + t.Fatalf("specs use: %v", err) + } + + config, err := os.ReadFile(filepath.Join(target, "config.yaml")) + if err != nil { + t.Fatalf("config.yaml missing: %v", err) + } + content := string(config) + + if strings.Contains(content, "[[") { + t.Errorf("config.yaml: [[ ]] delimiters should have been rendered, still present: %q", content) + } + if !strings.Contains(content, "my-project") { + t.Errorf("config.yaml: want 'my-project', got %q", content) + } + // ${{ github.repository }} is not valid [[ ]] syntax, so it passes through unchanged. + if !strings.Contains(content, "${{ github.repository }}") { + t.Errorf("config.yaml: literal ${{ github.repository }} should be preserved, got %q", content) + } +} + +// --- Registry-level: template save + template use --- + +func TestIntegration_TemplateUse_Minimal(t *testing.T) { + withTempRegistry(t) + src := fixtureDir(t, "minimal") + target := t.TempDir() + + if err := saveAndUse(t, src, "minimal", target, "--use-defaults"); err != nil { + t.Fatalf("template use: %v", err) + } + + readme, err := os.ReadFile(filepath.Join(target, "README.md")) + if err != nil { + t.Fatalf("README.md missing: %v", err) + } + if !strings.Contains(string(readme), "my-project") { + t.Errorf("README.md: want 'my-project', got %q", string(readme)) + } +} + +func TestIntegration_TemplateUse_Computed(t *testing.T) { + withTempRegistry(t) + src := fixtureDir(t, "computed") + target := t.TempDir() + + if err := saveAndUse(t, src, "computed", target, "--use-defaults"); err != nil { + t.Fatalf("template use: %v", err) + } + + dbEnv, err := os.ReadFile(filepath.Join(target, "db.env")) + if err != nil { + t.Fatalf("db.env missing: %v", err) + } + if !strings.Contains(string(dbEnv), "acme_production") { + t.Errorf("db.env: want 'acme_production', got %q", string(dbEnv)) + } + if !strings.Contains(string(dbEnv), "acme_test") { + t.Errorf("db.env: want 'acme_test', got %q", string(dbEnv)) + } +} + +func TestIntegration_TemplateUse_Conditional_ArgOverride(t *testing.T) { + withTempRegistry(t) + src := fixtureDir(t, "conditional") + target := t.TempDir() + + if err := saveAndUse(t, src, "conditional", target, "--use-defaults", "--arg", "UseDatabase=true"); err != nil { + t.Fatalf("template use: %v", err) + } + + if _, err := os.Stat(filepath.Join(target, "database.env")); err != nil { + t.Errorf("database.env should exist when UseDatabase=true: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "db", "schema.sql")); err != nil { + t.Errorf("db/schema.sql should exist when UseDatabase=true: %v", err) + } +} + +// --- template list --- + +func TestIntegration_TemplateList_WithFixtures(t *testing.T) { + withTempRegistry(t) + + names := []string{"minimal", "computed", "delimiters"} + for _, name := range names { + src := fixtureDir(t, name) + if _, err := executeCmd("template", "save", src, name); err != nil { + t.Fatalf("template save %s: %v", name, err) + } + } + + out, err := executeCmd("template", "list") + if err != nil { + t.Fatalf("template list: %v", err) + } + for _, name := range names { + if !strings.Contains(out, name) { + t.Errorf("template list: want %q in output, got %q", name, out) + } + } +} + +// --- template validate --- + +func TestIntegration_TemplateValidate_Minimal(t *testing.T) { + src := fixtureDir(t, "minimal") + out, err := executeCmd("template", "validate", src) + if err != nil { + t.Fatalf("template validate minimal: %v", err) + } + if !strings.Contains(out, "template is valid") { + t.Errorf("expected 'template is valid', got %q", out) + } +} + +func TestIntegration_TemplateValidate_Computed(t *testing.T) { + src := fixtureDir(t, "computed") + _, err := executeCmd("template", "validate", src) + if err != nil { + t.Fatalf("template validate computed: %v", err) + } +} + +func TestIntegration_TemplateValidate_Conditional(t *testing.T) { + src := fixtureDir(t, "conditional") + _, err := executeCmd("template", "validate", src) + if err != nil { + t.Fatalf("template validate conditional: %v", err) + } +} + +func TestIntegration_TemplateValidate_Verbatim(t *testing.T) { + src := fixtureDir(t, "verbatim") + _, err := executeCmd("template", "validate", src) + if err != nil { + t.Fatalf("template validate verbatim: %v", err) + } +} + +func TestIntegration_TemplateValidate_Delimiters(t *testing.T) { + src := fixtureDir(t, "delimiters") + _, err := executeCmd("template", "validate", src) + if err != nil { + t.Fatalf("template validate delimiters: %v", err) + } +} + +// --- template delete --- + +func TestIntegration_TemplateDelete(t *testing.T) { + withTempRegistry(t) + src := fixtureDir(t, "minimal") + + if _, err := executeCmd("template", "save", src, "to-delete"); err != nil { + t.Fatalf("template save: %v", err) + } + if _, err := executeCmd("template", "delete", "to-delete"); err != nil { + t.Fatalf("template delete: %v", err) + } + if _, err := os.Stat(specs.TemplatePath("to-delete")); !os.IsNotExist(err) { + t.Error("template should be absent after delete") + } +} + +// --- template rename --- + +func TestIntegration_TemplateRename(t *testing.T) { + withTempRegistry(t) + src := fixtureDir(t, "minimal") + + if _, err := executeCmd("template", "save", src, "old-name"); err != nil { + t.Fatalf("template save: %v", err) + } + if _, err := executeCmd("template", "rename", "old-name", "new-name"); err != nil { + t.Fatalf("template rename: %v", err) + } + + target := t.TempDir() + if _, err := executeCmd("template", "use", "--use-defaults", "new-name", target); err != nil { + t.Fatalf("template use after rename: %v", err) + } + if _, err := os.ReadFile(filepath.Join(target, "README.md")); err != nil { + t.Fatalf("README.md missing after rename: %v", err) + } +} + +// --- template upgrade (local — skipped without network) --- + +func TestIntegration_TemplateUpgrade_Local(t *testing.T) { + withTempRegistry(t) + src := fixtureDir(t, "minimal") + + if _, err := executeCmd("template", "save", src, "local-minimal"); err != nil { + t.Fatalf("template save: %v", err) + } + // Local templates are skipped on upgrade — must succeed without network access. + if _, err := executeCmd("template", "upgrade", "local-minimal"); err != nil { + t.Fatalf("template upgrade local: %v", err) + } +} + +// --- template download (local path rejected) --- + +func TestIntegration_TemplateDownload_RejectsLocalPath(t *testing.T) { + withTempRegistry(t) + src := fixtureDir(t, "minimal") + + _, err := executeCmd("template", "download", src, "should-fail") + if err == nil { + t.Fatal("expected error when downloading a local path, got nil") + } +} diff --git a/internal/testdata/computed/project.yaml b/internal/testdata/computed/project.yaml new file mode 100644 index 0000000..619b68d --- /dev/null +++ b/internal/testdata/computed/project.yaml @@ -0,0 +1,6 @@ +ProjectName: my-project +ProjectShortName: acme +computed: + ProjectSlug: "{{ .ProjectShortName | toKebabCase }}" + DbName: "{{ .ProjectSlug }}_production" + DbTestName: "{{ .DbName | replace \"_production\" \"_test\" }}" diff --git a/internal/testdata/computed/template/db.env b/internal/testdata/computed/template/db.env new file mode 100644 index 0000000..99abbff --- /dev/null +++ b/internal/testdata/computed/template/db.env @@ -0,0 +1,4 @@ +PROJECT={{ .ProjectName }} +SLUG={{ .ProjectSlug }} +DB_NAME={{ .DbName }} +DB_TEST={{ .DbTestName }} diff --git a/internal/testdata/conditional/project.yaml b/internal/testdata/conditional/project.yaml new file mode 100644 index 0000000..897f825 --- /dev/null +++ b/internal/testdata/conditional/project.yaml @@ -0,0 +1,3 @@ +ProjectName: my-project +UseDatabase: false +DatabaseName: mydb diff --git a/internal/testdata/conditional/template/README.md b/internal/testdata/conditional/template/README.md new file mode 100644 index 0000000..f1fc97f --- /dev/null +++ b/internal/testdata/conditional/template/README.md @@ -0,0 +1 @@ +# {{ .ProjectName }} diff --git a/internal/testdata/conditional/template/{{ if .UseDatabase }}database.env{{ end }} b/internal/testdata/conditional/template/{{ if .UseDatabase }}database.env{{ end }} new file mode 100644 index 0000000..5741b53 --- /dev/null +++ b/internal/testdata/conditional/template/{{ if .UseDatabase }}database.env{{ end }} @@ -0,0 +1 @@ +DB_NAME={{ .DatabaseName }} diff --git a/internal/testdata/conditional/template/{{ if .UseDatabase }}db{{ end }}/schema.sql b/internal/testdata/conditional/template/{{ if .UseDatabase }}db{{ end }}/schema.sql new file mode 100644 index 0000000..4a29204 --- /dev/null +++ b/internal/testdata/conditional/template/{{ if .UseDatabase }}db{{ end }}/schema.sql @@ -0,0 +1 @@ +CREATE TABLE users (id INT PRIMARY KEY); diff --git a/internal/testdata/delimiters/project.yaml b/internal/testdata/delimiters/project.yaml new file mode 100644 index 0000000..73cce28 --- /dev/null +++ b/internal/testdata/delimiters/project.yaml @@ -0,0 +1,4 @@ +__delimiters: + left: "[[" + right: "]]" +ProjectName: my-project diff --git a/internal/testdata/delimiters/template/config.yaml b/internal/testdata/delimiters/template/config.yaml new file mode 100644 index 0000000..0bdf282 --- /dev/null +++ b/internal/testdata/delimiters/template/config.yaml @@ -0,0 +1,6 @@ +# Standard GitHub Actions syntax passes through unchanged with custom delimiters: +name: ${{ github.repository }} +ref: ${{ github.ref }} + +# Rendered with custom delimiters: +project: [[ .ProjectName ]] diff --git a/internal/testdata/hooks-file/hooks/post-use.sh b/internal/testdata/hooks-file/hooks/post-use.sh new file mode 100755 index 0000000..de479a0 --- /dev/null +++ b/internal/testdata/hooks-file/hooks/post-use.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo "$SPECS_PROJECTNAME" > hook-output.txt diff --git a/internal/testdata/hooks-file/project.yaml b/internal/testdata/hooks-file/project.yaml new file mode 100644 index 0000000..08940e3 --- /dev/null +++ b/internal/testdata/hooks-file/project.yaml @@ -0,0 +1 @@ +ProjectName: my-project diff --git a/internal/testdata/hooks-file/template/main.txt b/internal/testdata/hooks-file/template/main.txt new file mode 100644 index 0000000..7368268 --- /dev/null +++ b/internal/testdata/hooks-file/template/main.txt @@ -0,0 +1 @@ +{{ .ProjectName }} diff --git a/internal/testdata/hooks/project.yaml b/internal/testdata/hooks/project.yaml new file mode 100644 index 0000000..65a2ae7 --- /dev/null +++ b/internal/testdata/hooks/project.yaml @@ -0,0 +1,4 @@ +ProjectName: my-project +hooks: + post-use: + - echo "{{ .ProjectName }}" > hook-output.txt diff --git a/internal/testdata/hooks/template/main.txt b/internal/testdata/hooks/template/main.txt new file mode 100644 index 0000000..7368268 --- /dev/null +++ b/internal/testdata/hooks/template/main.txt @@ -0,0 +1 @@ +{{ .ProjectName }} diff --git a/internal/testdata/minimal/project.yaml b/internal/testdata/minimal/project.yaml new file mode 100644 index 0000000..38d39a2 --- /dev/null +++ b/internal/testdata/minimal/project.yaml @@ -0,0 +1,2 @@ +ProjectName: my-project +Author: John Doe diff --git a/internal/testdata/minimal/template/README.md b/internal/testdata/minimal/template/README.md new file mode 100644 index 0000000..441a988 --- /dev/null +++ b/internal/testdata/minimal/template/README.md @@ -0,0 +1,3 @@ +# {{ .ProjectName }} + +Author: {{ .Author }} diff --git a/internal/testdata/minimal/template/config.txt b/internal/testdata/minimal/template/config.txt new file mode 100644 index 0000000..d531284 --- /dev/null +++ b/internal/testdata/minimal/template/config.txt @@ -0,0 +1,2 @@ +project={{ .ProjectName }} +author={{ .Author }} diff --git a/internal/testdata/verbatim/.specsverbatim b/internal/testdata/verbatim/.specsverbatim new file mode 100644 index 0000000..8f7299c --- /dev/null +++ b/internal/testdata/verbatim/.specsverbatim @@ -0,0 +1,2 @@ +*.raw +vendor/** diff --git a/internal/testdata/verbatim/project.yaml b/internal/testdata/verbatim/project.yaml new file mode 100644 index 0000000..08940e3 --- /dev/null +++ b/internal/testdata/verbatim/project.yaml @@ -0,0 +1 @@ +ProjectName: my-project diff --git a/internal/testdata/verbatim/template/icon.bin b/internal/testdata/verbatim/template/icon.bin new file mode 100644 index 0000000..d3125be Binary files /dev/null and b/internal/testdata/verbatim/template/icon.bin differ diff --git a/internal/testdata/verbatim/template/literal.raw b/internal/testdata/verbatim/template/literal.raw new file mode 100644 index 0000000..7368268 --- /dev/null +++ b/internal/testdata/verbatim/template/literal.raw @@ -0,0 +1 @@ +{{ .ProjectName }} diff --git a/internal/testdata/verbatim/template/normal.txt b/internal/testdata/verbatim/template/normal.txt new file mode 100644 index 0000000..7368268 --- /dev/null +++ b/internal/testdata/verbatim/template/normal.txt @@ -0,0 +1 @@ +{{ .ProjectName }} diff --git a/internal/testdata/verbatim/template/vendor/config.js b/internal/testdata/verbatim/template/vendor/config.js new file mode 100644 index 0000000..0fabf7f --- /dev/null +++ b/internal/testdata/verbatim/template/vendor/config.js @@ -0,0 +1,2 @@ +// {{ .ProjectName }} +const config = {}; diff --git a/internal/util/git/git_test.go b/internal/util/git/git_test.go index 9e4934d..68d0667 100644 --- a/internal/util/git/git_test.go +++ b/internal/util/git/git_test.go @@ -54,7 +54,7 @@ func TestClone_SpecificTag(t *testing.T) { t.Fatalf("Clone with tag: %v", err) } - if _, err := os.Stat(dir + "/composer.json"); os.IsNotExist(err) { + if _, err := os.Stat(dir + "/template/composer.json"); os.IsNotExist(err) { t.Error("cloned repo missing composer.json") } }