Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ jobs:
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AI_PROVIDER: gemini
AI_MODEL: gemini-2.5-flash
AI_MODEL: gemini-3.1-flash-lite-preview
AI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
SHOW_TOKEN_USAGE: true
INCREMENTAL: false
Expand Down
11 changes: 9 additions & 2 deletions actions/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package actions

import (
"fmt"
"sort"
"strings"

"github.com/AxeForging/yamlspec/services"
Expand Down Expand Up @@ -71,10 +72,16 @@ func (a *ListAction) listTags(testDir string) error {
return nil
}

tags := make([]string, 0, len(tagSet))
for tag := range tagSet {
tags = append(tags, tag)
}
sort.Strings(tags)

fmt.Printf("%-30s %s\n", "TAG", "SPECS")
fmt.Printf("%-30s %s\n", strings.Repeat("-", 28), strings.Repeat("-", 8))
for tag, count := range tagSet {
fmt.Printf("%-30s %d\n", tag, count)
for _, tag := range tags {
fmt.Printf("%-30s %d\n", tag, tagSet[tag])
}

return nil
Expand Down
6 changes: 6 additions & 0 deletions actions/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func (a *ValidateAction) Execute(c *cli.Context) error {
Tags: c.StringSlice("tag"),
Workers: c.Int("workers"),
FailFast: c.Bool("fail-fast"),
PreRunTimeout: c.Duration("pre-run-timeout"),
Verbose: c.GlobalBool("verbose"),
Quiet: c.Bool("quiet"),
JSONOutput: c.String("json-output"),
Expand All @@ -40,6 +41,11 @@ func (a *ValidateAction) Execute(c *cli.Context) error {
JUnitOutput: c.String("junit-output"),
}

if config.FailFast && config.Workers > 1 {
return fmt.Errorf("--fail-fast cannot be used with --workers > 1 " +
"(parallel workers can't honor ordered short-circuit; pick one)")
}

specs, err := a.discovery.DiscoverSpecs(config.TestDir, config.Tags)
if err != nil {
return fmt.Errorf("discovery failed: %w", err)
Expand Down
20 changes: 12 additions & 8 deletions domain/config.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package domain

import "time"

// Config holds all CLI configuration
type Config struct {
TestDir string
Tags []string
Workers int
FailFast bool
Verbose bool
Quiet bool
TestDir string
Tags []string
Workers int
FailFast bool
Verbose bool
Quiet bool
PreRunTimeout time.Duration

// Output format flags (file paths; empty means skip)
JSONOutput string
Expand All @@ -20,7 +23,8 @@ type Config struct {
// DefaultConfig returns a Config with sensible defaults
func DefaultConfig() *Config {
return &Config{
TestDir: "tests",
Workers: 1,
TestDir: "tests",
Workers: 1,
PreRunTimeout: 60 * time.Second,
}
}
6 changes: 6 additions & 0 deletions domain/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ type Assertion struct {
ToHaveMaxLength *int `yaml:"toHaveMaxLength,omitempty"`
}

// HasAnyOperator returns true if the assertion has any operator set,
// including existence/null checks.
func (a *Assertion) HasAnyOperator() bool {
return a.ToExist != nil || a.ToBeNull != nil || a.HasValueOperators()
}

// HasValueOperators returns true if the assertion has any value-based operators set
func (a *Assertion) HasValueOperators() bool {
return a.ToEqual != nil ||
Expand Down
13 changes: 11 additions & 2 deletions flags.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package main

import "github.com/urfave/cli"
import (
"time"

"github.com/urfave/cli"
)

var (
verboseFlag = cli.BoolFlag{
Expand All @@ -27,7 +31,12 @@ var (
}
failFastFlag = cli.BoolFlag{
Name: "fail-fast",
Usage: "Stop on first failure",
Usage: "Stop on first failure (not compatible with --workers > 1)",
}
preRunTimeoutFlag = cli.DurationFlag{
Name: "pre-run-timeout",
Usage: "Max duration for each pre_run command (e.g. 30s, 2m)",
Value: 60 * time.Second,
}
jsonOutputFlag = cli.StringFlag{
Name: "json-output",
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/AxeForging/yamlspec

go 1.25.0
go 1.25.8

require (
github.com/itchyny/gojq v0.12.18
Expand Down
33 changes: 33 additions & 0 deletions integration/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,36 @@ func TestList_Tags(t *testing.T) {
t.Error("expected 'staging' tag in output")
}
}

func TestList_TagsSorted(t *testing.T) {
bin := buildBinary(t)

output, exitCode := run(t, bin, "list", "--test-dir", "integration/testdata", "--tags")
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}

// Extract tag column (first token of each data line)
var tags []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "TAG") || strings.HasPrefix(line, "-") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
tags = append(tags, fields[0])
}

if len(tags) < 2 {
t.Fatalf("expected multiple tags, got: %v", tags)
}
for i := 1; i < len(tags); i++ {
if tags[i] < tags[i-1] {
t.Errorf("tags not sorted: %v (out of order: %q before %q)", tags, tags[i-1], tags[i])
break
}
}
}
63 changes: 63 additions & 0 deletions integration/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)

func repoRoot(t *testing.T) string {
Expand Down Expand Up @@ -959,3 +960,65 @@ func TestE2E_Help(t *testing.T) {
t.Error("expected 'init' command in help")
}
}

// --- Correctness hardening ---

func TestE2E_FailFastWithWorkersRejected(t *testing.T) {
bin := buildBinary(t)

output, exitCode := run(t, bin, "validate",
"--test-dir", "integration/testdata",
"--fail-fast", "--workers", "4",
)

if exitCode == 0 {
t.Errorf("expected non-zero exit when combining --fail-fast and --workers>1, got 0\n%s", output)
}
if !strings.Contains(output, "fail-fast") || !strings.Contains(output, "workers") {
t.Errorf("expected error to mention both flags, got:\n%s", output)
}
}

func TestE2E_PreRunTimeout(t *testing.T) {
bin := buildBinary(t)
tmpDir := t.TempDir()
specDir := filepath.Join(tmpDir, "slow-prerun")
if err := os.MkdirAll(specDir, 0755); err != nil {
t.Fatal(err)
}
spec := `name: "Slow pre-run"
tags: ["slow"]
pre_run:
- sleep 5
describe:
- name: "never runs"
select: 'select(.kind == "Deployment")'
it:
- should: "placeholder"
expect: metadata.name
toExist: true
`
if err := os.WriteFile(filepath.Join(specDir, "spec.yaml"), []byte(spec), 0644); err != nil {
t.Fatal(err)
}
// A manifest has to exist for discovery; it won't be reached.
if err := os.WriteFile(filepath.Join(specDir, "dep.yaml"), []byte("kind: Deployment\nmetadata:\n name: foo\n"), 0644); err != nil {
t.Fatal(err)
}

start := time.Now()
output, exitCode := run(t, bin, "validate", "--test-dir", tmpDir, "--pre-run-timeout", "500ms")
elapsed := time.Since(start)

if exitCode == 0 {
t.Error("expected non-zero exit when pre_run times out")
}
if !strings.Contains(output, "timed out") {
t.Errorf("expected 'timed out' in output, got:\n%s", output)
}
// Should kill at ~500ms, well before the 5s sleep. Allow slack for process
// startup on slow CI, but anything past 3s means we didn't actually kill it.
if elapsed > 3*time.Second {
t.Errorf("pre-run timeout didn't cancel: took %s", elapsed)
}
}
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func main() {
tagFlag,
workersFlag,
failFastFlag,
preRunTimeoutFlag,
quietFlag,
jsonOutputFlag,
yamlOutputFlag,
Expand Down
Loading
Loading