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
4 changes: 3 additions & 1 deletion src/cmd/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"github.com/spf13/cobra"
)

var validWorkflowName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)

var workflowCmd = &cobra.Command{
Use: "workflow [name] [description...]",
Short: "Run a YAML workflow by name",
Expand All @@ -33,7 +35,7 @@ var workflowCmd = &cobra.Command{
}

// Validate workflow name to prevent path traversal
if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(name) {
if !validWorkflowName.MatchString(name) {
return fmt.Errorf("invalid workflow name %q — use only letters, numbers, hyphens, underscores", name)
}

Expand Down
111 changes: 111 additions & 0 deletions src/cmd/workflow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package cmd

import (
"testing"
"time"

"github.com/5uck1ess/devkit/runners"
)

func TestWorkflowNameValidation(t *testing.T) {
tests := []struct {
name string
input string
valid bool
}{
{"simple", "feature", true},
{"with-dash", "self-improve", true},
{"with-underscore", "my_workflow", true},
{"with-numbers", "v2-test", true},
{"path-traversal", "../etc/passwd", false},
{"absolute-path", "/etc/passwd", false},
{"spaces", "my workflow", false},
{"dots", "my.workflow", false},
{"empty", "", false},
{"shell-injection", "foo;rm -rf /", false},
{"backtick", "foo`id`", false},
{"dollar", "foo$HOME", false},
{"null-byte", "foo\x00bar", false},
{"pipe", "foo|cat", false},
{"newline", "foo\nbar", false},
{"lone-dot", ".", false},
{"double-dot", "..", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := validWorkflowName.MatchString(tt.input)
if got != tt.valid {
t.Errorf("validWorkflowName.MatchString(%q) = %v, want %v", tt.input, got, tt.valid)
}
})
}
}

func TestFormatAge(t *testing.T) {
tests := []struct {
name string
age time.Duration
want string
}{
{"just now", 30 * time.Second, "just now"},
{"minutes", 5 * time.Minute, "5m ago"},
{"hours", 3 * time.Hour, "3h ago"},
{"days", 48 * time.Hour, "2d ago"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatAge(time.Now().Add(-tt.age))
if got != tt.want {
t.Errorf("formatAge(-%v) = %q, want %q", tt.age, got, tt.want)
}
})
}
}

func TestFormatAge_Boundaries(t *testing.T) {
// At exactly 59s — still "just now" (d < time.Minute)
got := formatAge(time.Now().Add(-59 * time.Second))
if got != "just now" {
t.Errorf("formatAge(-59s) = %q, want %q", got, "just now")
}

// At 61s — crosses minute boundary, should be "1m ago"
got = formatAge(time.Now().Add(-61 * time.Second))
if got != "1m ago" {
t.Errorf("formatAge(-61s) = %q, want %q", got, "1m ago")
}

// At 59m — still minutes, should be "59m ago"
got = formatAge(time.Now().Add(-59 * time.Minute))
if got != "59m ago" {
t.Errorf("formatAge(-59m) = %q, want %q", got, "59m ago")
}

// At 61m — crosses hour boundary, should be "1h ago"
got = formatAge(time.Now().Add(-61 * time.Minute))
if got != "1h ago" {
t.Errorf("formatAge(-61m) = %q, want %q", got, "1h ago")
}

// At 23h — still hours
got = formatAge(time.Now().Add(-23 * time.Hour))
if got != "23h ago" {
t.Errorf("formatAge(-23h) = %q, want %q", got, "23h ago")
}

// At 25h — crosses day boundary, should be "1d ago"
got = formatAge(time.Now().Add(-25 * time.Hour))
if got != "1d ago" {
t.Errorf("formatAge(-25h) = %q, want %q", got, "1d ago")
}
}

func TestResolveRunnerFrom_EmptyName(t *testing.T) {
available := []runners.Runner{&stubRunner{"claude"}}
_, err := resolveRunnerFrom("", available)
if err == nil {
t.Fatal("expected error for empty agent name")
}
}
124 changes: 124 additions & 0 deletions src/runners/claude_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package runners

import (
"encoding/json"
"testing"
)

func TestClaudeRunnerName(t *testing.T) {
r := &ClaudeRunner{}
if r.Name() != "claude" {
t.Errorf("got %q, want %q", r.Name(), "claude")
}
}

func TestCodexRunnerName(t *testing.T) {
r := &CodexRunner{}
if r.Name() != "codex" {
t.Errorf("got %q, want %q", r.Name(), "codex")
}
}

func TestGeminiRunnerName(t *testing.T) {
r := &GeminiRunner{}
if r.Name() != "gemini" {
t.Errorf("got %q, want %q", r.Name(), "gemini")
}
}

func TestClaudeResponseParsing(t *testing.T) {
tests := []struct {
name string
json string
wantOut string
wantCost float64
wantIn int
wantSess string
wantErr bool
}{
{
name: "full response",
json: `{"result":"hello world","session_id":"sess-123","is_error":false,"total_cost_usd":0.05,"usage":{"input_tokens":100,"output_tokens":50}}`,
wantOut: "hello world",
wantCost: 0.05,
wantIn: 100,
wantSess: "sess-123",
},
{
name: "error response",
json: `{"result":"something went wrong","is_error":true,"total_cost_usd":0.01,"usage":{"input_tokens":10,"output_tokens":5}}`,
wantOut: "something went wrong",
wantCost: 0.01,
wantIn: 10,
},
{
name: "empty response",
json: `{"result":"","session_id":"","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0}}`,
wantOut: "",
wantCost: 0,
wantIn: 0,
},
{
name: "invalid json",
json: `not json at all`,
wantErr: true,
},
{
name: "missing fields",
json: `{"result":"partial"}`,
wantOut: "partial",
wantCost: 0,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var resp claudeResponse
err := json.Unmarshal([]byte(tt.json), &resp)
if tt.wantErr {
if err == nil {
t.Fatal("expected parse error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Result != tt.wantOut {
t.Errorf("result = %q, want %q", resp.Result, tt.wantOut)
}
if resp.TotalCostUSD != tt.wantCost {
t.Errorf("cost = %f, want %f", resp.TotalCostUSD, tt.wantCost)
}
if resp.Usage.InputTokens != tt.wantIn {
t.Errorf("input tokens = %d, want %d", resp.Usage.InputTokens, tt.wantIn)
}
if resp.SessionID != tt.wantSess {
t.Errorf("session = %q, want %q", resp.SessionID, tt.wantSess)
}
})
}
}

func TestTruncStr_EdgeCases(t *testing.T) {
tests := []struct {
name string
s string
n int
want string
}{
{"empty", "", 10, ""},
{"exact length", "abcde", 5, "abcde"},
{"one over", "abcdef", 5, "abcde..."},
{"zero limit", "abc", 0, "..."},
{"unicode", "hello 世界!", 7, "hello 世..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := TruncStr(tt.s, tt.n)
if got != tt.want {
t.Errorf("TruncStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
}
})
}
}
Loading