From 8ced75f576a3af896b7a3eb9ece658d6dae727da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:01:19 +0000 Subject: [PATCH] test: add specification-driven tests for console, envutil, and stringutil Adds spec_test.go files for three packages derived from their README.md specifications, covering public API contracts, type structures, and documented design decisions. Co-Authored-By: Claude Sonnet 4.6 --- pkg/console/spec_test.go | 252 +++++++++++++++++ pkg/envutil/spec_test.go | 150 ++++++++++ pkg/stringutil/spec_test.go | 541 ++++++++++++++++++++++++++++++++++++ 3 files changed, 943 insertions(+) create mode 100644 pkg/console/spec_test.go create mode 100644 pkg/envutil/spec_test.go create mode 100644 pkg/stringutil/spec_test.go diff --git a/pkg/console/spec_test.go b/pkg/console/spec_test.go new file mode 100644 index 00000000000..0927f762bbe --- /dev/null +++ b/pkg/console/spec_test.go @@ -0,0 +1,252 @@ +//go:build !integration + +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSpec_PublicAPI_FormatFileSize validates the documented byte formatting +// behavior of FormatFileSize as described in the package README.md. +// +// Specification: "Formats a byte count as a human-readable string with +// appropriate unit suffix." +func TestSpec_PublicAPI_FormatFileSize(t *testing.T) { + tests := []struct { + name string + size int64 + expected string + }{ + { + name: "zero bytes documented as '0 B'", + size: 0, + expected: "0 B", + }, + { + name: "1500 bytes documented as '1.5 KB'", + size: 1500, + expected: "1.5 KB", + }, + { + name: "2.1 million bytes documented as '2.0 MB'", + size: 2_100_000, + expected: "2.0 MB", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatFileSize(tt.size) + assert.Equal(t, tt.expected, result, + "FormatFileSize(%d) should match documented output", tt.size) + }) + } +} + +// TestSpec_Types_CompilerError validates that CompilerError has the documented +// fields and structure as described in the package README.md. +// +// Specification: +// +// type CompilerError struct { +// Position ErrorPosition // Source file position +// Type string // "error", "warning", "info" +// Message string +// Context []string // Source lines shown around the error +// Hint string // Optional actionable fix suggestion +// } +func TestSpec_Types_CompilerError(t *testing.T) { + err := CompilerError{ + Position: ErrorPosition{File: "workflow.md", Line: 12, Column: 5}, + Type: "error", + Message: "unknown engine: 'myengine'", + Context: []string{"engine: myengine"}, + Hint: "Valid engines are: copilot, claude, codex, gemini", + } + + assert.Equal(t, "workflow.md", err.Position.File, "ErrorPosition.File should be accessible") + assert.Equal(t, 12, err.Position.Line, "ErrorPosition.Line should be accessible") + assert.Equal(t, 5, err.Position.Column, "ErrorPosition.Column should be accessible") + assert.Equal(t, "error", err.Type, "CompilerError.Type should be accessible") + assert.Equal(t, "unknown engine: 'myengine'", err.Message, "CompilerError.Message should be accessible") + require.Len(t, err.Context, 1, "CompilerError.Context should hold context lines") + assert.Equal(t, "engine: myengine", err.Context[0], "CompilerError.Context[0] should match") + assert.Equal(t, "Valid engines are: copilot, claude, codex, gemini", err.Hint, "CompilerError.Hint should be accessible") +} + +// TestSpec_Types_CompilerError_DocumentedTypes validates that CompilerError.Type +// accepts the documented values as described in the package README.md. +// +// Specification: Type string // "error", "warning", "info" +func TestSpec_Types_CompilerError_DocumentedTypes(t *testing.T) { + documentedTypes := []string{"error", "warning", "info"} + for _, errType := range documentedTypes { + t.Run("type_"+errType, func(t *testing.T) { + err := CompilerError{Type: errType, Message: "test"} + assert.Equal(t, errType, err.Type, + "CompilerError.Type should accept documented value %q", errType) + }) + } +} + +// TestSpec_Types_TableConfig validates the documented TableConfig struct fields +// as described in the package README.md. +// +// Specification: +// +// type TableConfig struct { +// Headers []string +// Rows [][]string +// Title string // Optional table title +// ShowTotal bool // Display a total row +// TotalRow []string // Content for the total row +// } +func TestSpec_Types_TableConfig(t *testing.T) { + config := TableConfig{ + Headers: []string{"Name", "Status", "Duration"}, + Rows: [][]string{{"build", "success", "1m30s"}}, + Title: "Job Results", + ShowTotal: true, + TotalRow: []string{"Total", "", "1m30s"}, + } + + assert.Equal(t, []string{"Name", "Status", "Duration"}, config.Headers, + "TableConfig.Headers should be settable") + require.Len(t, config.Rows, 1, "TableConfig.Rows should hold row data") + assert.Equal(t, "Job Results", config.Title, "TableConfig.Title should be settable") + assert.True(t, config.ShowTotal, "TableConfig.ShowTotal should be settable") + assert.Equal(t, []string{"Total", "", "1m30s"}, config.TotalRow, + "TableConfig.TotalRow should be settable") +} + +// TestSpec_Types_FormField validates the documented FormField struct and its +// Type values as described in the package README.md. +// +// Specification: Type string // "input", "password", "confirm", "select" +func TestSpec_Types_FormField(t *testing.T) { + documentedTypes := []string{"input", "password", "confirm", "select"} + + for _, fieldType := range documentedTypes { + t.Run("type_"+fieldType, func(t *testing.T) { + field := FormField{ + Type: fieldType, + Title: "Test Field", + Description: "A test description", + Placeholder: "placeholder", + } + assert.Equal(t, fieldType, field.Type, + "FormField.Type should accept documented value %q", fieldType) + assert.Equal(t, "Test Field", field.Title, + "FormField.Title should be accessible") + assert.Equal(t, "A test description", field.Description, + "FormField.Description should be accessible") + assert.Equal(t, "placeholder", field.Placeholder, + "FormField.Placeholder should be accessible") + }) + } +} + +// TestSpec_Types_SelectOption validates the documented SelectOption struct +// as described in the package README.md. +// +// Specification: +// +// type SelectOption struct { +// Label string +// Value string +// } +func TestSpec_Types_SelectOption(t *testing.T) { + opt := SelectOption{ + Label: "My Option", + Value: "my-option", + } + assert.Equal(t, "My Option", opt.Label, "SelectOption.Label should be accessible") + assert.Equal(t, "my-option", opt.Value, "SelectOption.Value should be accessible") +} + +// TestSpec_Types_TreeNode validates the documented TreeNode struct +// as described in the package README.md. +// +// Specification: +// +// type TreeNode struct { +// Value string +// Children []TreeNode +// } +func TestSpec_Types_TreeNode(t *testing.T) { + node := TreeNode{ + Value: "root", + Children: []TreeNode{ + {Value: "child1", Children: nil}, + {Value: "child2", Children: []TreeNode{{Value: "grandchild"}}}, + }, + } + assert.Equal(t, "root", node.Value, "TreeNode.Value should be accessible") + require.Len(t, node.Children, 2, "TreeNode.Children should support multiple children") + assert.Equal(t, "child1", node.Children[0].Value, "Nested TreeNode.Value should be accessible") + assert.Len(t, node.Children[1].Children, 1, + "TreeNode.Children should support recursive nesting") +} + +// TestSpec_PublicAPI_NewListItem validates the documented NewListItem constructor +// as described in the package README.md. +// +// Specification: "An item in an interactive list with title, description, and +// an internal value. Create with NewListItem(title, description, value string)." +func TestSpec_PublicAPI_NewListItem(t *testing.T) { + item := NewListItem("My Title", "My Description", "my-value") + assert.Equal(t, "My Title", item.title, "NewListItem should set title") + assert.Equal(t, "My Description", item.description, "NewListItem should set description") +} + +// TestSpec_DesignDecision_RenderStruct_SkipTag validates the documented +// console:"-" struct tag behavior of RenderStruct as described in the README.md. +// +// Specification: `"-"` — Always skips the field +func TestSpec_DesignDecision_RenderStruct_SkipTag(t *testing.T) { + type TestData struct { + Visible string `console:"header:Visible"` + Internal string `console:"-"` + } + + data := TestData{ + Visible: "shown", + Internal: "hidden", + } + + result := RenderStruct(data) + assert.Contains(t, result, "shown", + "fields without '-' tag should appear in rendered output") + assert.NotContains(t, result, "hidden", + "fields tagged with '-' must not appear in rendered output") +} + +// TestSpec_DesignDecision_RenderStruct_OmitEmptyTag validates the documented +// omitempty struct tag behavior of RenderStruct as described in the README.md. +// +// Specification: `"omitempty"` — Skips the field if it has a zero value +func TestSpec_DesignDecision_RenderStruct_OmitEmptyTag(t *testing.T) { + type TestData struct { + Name string `console:"header:Name"` + Duration string `console:"header:Duration,omitempty"` + } + + t.Run("zero value omitted", func(t *testing.T) { + data := TestData{Name: "test", Duration: ""} + result := RenderStruct(data) + assert.Contains(t, result, "test", + "non-omitempty field should appear in rendered output") + assert.NotContains(t, result, "Duration", + "omitempty field with zero value should not appear in rendered output") + }) + + t.Run("non-zero value included", func(t *testing.T) { + data := TestData{Name: "test", Duration: "5m30s"} + result := RenderStruct(data) + assert.Contains(t, result, "5m30s", + "omitempty field with non-zero value should appear in rendered output") + }) +} diff --git a/pkg/envutil/spec_test.go b/pkg/envutil/spec_test.go new file mode 100644 index 00000000000..bfc851ed5e7 --- /dev/null +++ b/pkg/envutil/spec_test.go @@ -0,0 +1,150 @@ +//go:build !integration + +package envutil + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestSpec_PublicAPI_GetIntFromEnv validates the documented behavior of +// GetIntFromEnv as described in the package README.md. +// +// Specification: +// - Returns defaultValue when the environment variable is not set. +// - Returns defaultValue and emits a warning when the value cannot be parsed as an integer. +// - Returns defaultValue and emits a warning when the value is outside [minValue, maxValue]. +// - Logs the accepted value at debug level when log is non-nil. +// - Pass nil for log to suppress debug output. + +// TestSpec_PublicAPI_GetIntFromEnv_ReturnsDefault_WhenNotSet validates that +// defaultValue is returned when the environment variable is not set. +func TestSpec_PublicAPI_GetIntFromEnv_ReturnsDefault_WhenNotSet(t *testing.T) { + const envVar = "GH_AW_SPEC_TEST_UNSET" + os.Unsetenv(envVar) + defer os.Unsetenv(envVar) + + result := GetIntFromEnv(envVar, 5, 1, 20, nil) + assert.Equal(t, 5, result, + "GetIntFromEnv should return defaultValue when env var is not set") +} + +// TestSpec_PublicAPI_GetIntFromEnv_ReturnsDefault_WhenNotInteger validates that +// defaultValue is returned when the value cannot be parsed as an integer. +func TestSpec_PublicAPI_GetIntFromEnv_ReturnsDefault_WhenNotInteger(t *testing.T) { + const envVar = "GH_AW_SPEC_TEST_NAN" + os.Setenv(envVar, "not-a-number") + defer os.Unsetenv(envVar) + + result := GetIntFromEnv(envVar, 5, 1, 20, nil) + assert.Equal(t, 5, result, + "GetIntFromEnv should return defaultValue when value cannot be parsed as integer") +} + +// TestSpec_PublicAPI_GetIntFromEnv_ReturnsDefault_WhenBelowMin validates that +// defaultValue is returned when the value is below minValue. +func TestSpec_PublicAPI_GetIntFromEnv_ReturnsDefault_WhenBelowMin(t *testing.T) { + const envVar = "GH_AW_SPEC_TEST_BELOW_MIN" + os.Setenv(envVar, "0") + defer os.Unsetenv(envVar) + + // minValue is 1, so 0 is outside [1, 20] + result := GetIntFromEnv(envVar, 5, 1, 20, nil) + assert.Equal(t, 5, result, + "GetIntFromEnv should return defaultValue when value is below minValue") +} + +// TestSpec_PublicAPI_GetIntFromEnv_ReturnsDefault_WhenAboveMax validates that +// defaultValue is returned when the value is above maxValue. +func TestSpec_PublicAPI_GetIntFromEnv_ReturnsDefault_WhenAboveMax(t *testing.T) { + const envVar = "GH_AW_SPEC_TEST_ABOVE_MAX" + os.Setenv(envVar, "21") + defer os.Unsetenv(envVar) + + // maxValue is 20, so 21 is outside [1, 20] + result := GetIntFromEnv(envVar, 5, 1, 20, nil) + assert.Equal(t, 5, result, + "GetIntFromEnv should return defaultValue when value is above maxValue") +} + +// TestSpec_PublicAPI_GetIntFromEnv_ReturnsValue_WhenInRange validates that +// the parsed value is returned when it is within [minValue, maxValue]. +func TestSpec_PublicAPI_GetIntFromEnv_ReturnsValue_WhenInRange(t *testing.T) { + const envVar = "GH_AW_SPEC_TEST_IN_RANGE" + os.Setenv(envVar, "10") + defer os.Unsetenv(envVar) + + result := GetIntFromEnv(envVar, 5, 1, 20, nil) + assert.Equal(t, 10, result, + "GetIntFromEnv should return the env var value when within [minValue, maxValue]") +} + +// TestSpec_PublicAPI_GetIntFromEnv_AcceptsInclusiveMinBoundary validates that +// minValue itself is accepted (inclusive lower bound). +func TestSpec_PublicAPI_GetIntFromEnv_AcceptsInclusiveMinBoundary(t *testing.T) { + const envVar = "GH_AW_SPEC_TEST_MIN_BOUNDARY" + os.Setenv(envVar, "1") + defer os.Unsetenv(envVar) + + result := GetIntFromEnv(envVar, 5, 1, 20, nil) + assert.Equal(t, 1, result, + "GetIntFromEnv should accept value equal to minValue (inclusive lower bound)") +} + +// TestSpec_PublicAPI_GetIntFromEnv_AcceptsInclusiveMaxBoundary validates that +// maxValue itself is accepted (inclusive upper bound). +func TestSpec_PublicAPI_GetIntFromEnv_AcceptsInclusiveMaxBoundary(t *testing.T) { + const envVar = "GH_AW_SPEC_TEST_MAX_BOUNDARY" + os.Setenv(envVar, "20") + defer os.Unsetenv(envVar) + + result := GetIntFromEnv(envVar, 5, 1, 20, nil) + assert.Equal(t, 20, result, + "GetIntFromEnv should accept value equal to maxValue (inclusive upper bound)") +} + +// TestSpec_PublicAPI_GetIntFromEnv_AcceptsNilLogger validates that passing nil +// for the log parameter suppresses debug output without panicking. +// +// Specification: "Pass nil for log to suppress debug output." +func TestSpec_PublicAPI_GetIntFromEnv_AcceptsNilLogger(t *testing.T) { + const envVar = "GH_AW_SPEC_TEST_NIL_LOGGER" + os.Setenv(envVar, "10") + defer os.Unsetenv(envVar) + + assert.NotPanics(t, func() { + GetIntFromEnv(envVar, 5, 1, 20, nil) + }, "GetIntFromEnv should not panic when nil logger is passed") +} + +// TestSpec_PublicAPI_GetIntFromEnv_DocExample validates the usage example +// from the package README.md. +// +// Specification example: +// +// concurrency := envutil.GetIntFromEnv("GH_AW_MAX_CONCURRENT_DOWNLOADS", 5, 1, 20, log) +func TestSpec_PublicAPI_GetIntFromEnv_DocExample(t *testing.T) { + const envVar = "GH_AW_MAX_CONCURRENT_DOWNLOADS" + saved := os.Getenv(envVar) + defer func() { + if saved != "" { + os.Setenv(envVar, saved) + } else { + os.Unsetenv(envVar) + } + }() + + // When not set, should return default of 5 + os.Unsetenv(envVar) + result := GetIntFromEnv(envVar, 5, 1, 20, nil) + assert.Equal(t, 5, result, + "documented example: default 5 when env var not set") + + // When set to valid value, should return it + os.Setenv(envVar, "8") + result = GetIntFromEnv(envVar, 5, 1, 20, nil) + assert.Equal(t, 8, result, + "documented example: returns valid value within [1, 20]") +} diff --git a/pkg/stringutil/spec_test.go b/pkg/stringutil/spec_test.go new file mode 100644 index 00000000000..07119c31847 --- /dev/null +++ b/pkg/stringutil/spec_test.go @@ -0,0 +1,541 @@ +//go:build !integration + +package stringutil + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSpec_PublicAPI_Truncate validates the documented behavior of Truncate +// as described in the package README.md. +// +// Specification: +// - Truncates s to at most maxLen characters, appending "..." when truncation occurs. +// - For maxLen ≤ 3 the string is truncated without ellipsis. +func TestSpec_PublicAPI_Truncate(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + expected string + }{ + { + name: "truncates with ellipsis for maxLen > 3", + input: "hello world", + maxLen: 8, + expected: "hello...", + }, + { + name: "no truncation when string fits within maxLen", + input: "hi", + maxLen: 8, + expected: "hi", + }, + { + name: "maxLen <= 3 truncates without ellipsis", + input: "hello world", + maxLen: 3, + expected: "hel", + }, + { + name: "maxLen = 1 truncates without ellipsis", + input: "hello", + maxLen: 1, + expected: "h", + }, + { + name: "maxLen = 2 truncates without ellipsis", + input: "hello", + maxLen: 2, + expected: "he", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Truncate(tt.input, tt.maxLen) + assert.Equal(t, tt.expected, result, + "Truncate(%q, %d) should match documented output", tt.input, tt.maxLen) + }) + } +} + +// TestSpec_PublicAPI_ParseVersionValue validates the documented behavior of +// ParseVersionValue as described in the package README.md. +// +// Specification examples: +// +// stringutil.ParseVersionValue("20") // "20" +// stringutil.ParseVersionValue(20) // "20" +// stringutil.ParseVersionValue(20.0) // "20" +func TestSpec_PublicAPI_ParseVersionValue(t *testing.T) { + tests := []struct { + name string + input any + expected string + }{ + { + name: "string input '20' returns '20'", + input: "20", + expected: "20", + }, + { + name: "int input 20 returns '20'", + input: 20, + expected: "20", + }, + { + name: "float64 input 20.0 returns '20'", + input: 20.0, + expected: "20", + }, + { + name: "nil input returns empty string", + input: nil, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseVersionValue(tt.input) + assert.Equal(t, tt.expected, result, + "ParseVersionValue(%v) should match documented output", tt.input) + }) + } +} + +// TestSpec_PublicAPI_IsPositiveInteger validates the documented behavior of +// IsPositiveInteger as described in the package README.md. +// +// Specification: "Returns true if s is a non-empty string containing only +// digit characters (0–9)." +func TestSpec_PublicAPI_IsPositiveInteger(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "digit-only string returns true", + input: "123", + expected: true, + }, + { + name: "single digit returns true", + input: "1", + expected: true, + }, + { + name: "empty string returns false", + input: "", + expected: false, + }, + { + name: "string with letter returns false", + input: "12a3", + expected: false, + }, + { + name: "negative number returns false", + input: "-1", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsPositiveInteger(tt.input) + assert.Equal(t, tt.expected, result, + "IsPositiveInteger(%q) should match documented behavior", tt.input) + }) + } +} + +// TestSpec_PublicAPI_StripANSI validates the documented behavior of StripANSI +// as described in the package README.md. +// +// Specification: "Removes all ANSI/VT100 escape sequences from s. Handles CSI +// sequences (e.g. \x1b[31m for colors) and other ESC-prefixed sequences." +// +// Specification example: +// +// colored := "\x1b[32mSuccess\x1b[0m" +// plain := stringutil.StripANSI(colored) // "Success" +func TestSpec_PublicAPI_StripANSI(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "removes CSI color escape sequence (documented example)", + input: "\x1b[32mSuccess\x1b[0m", + expected: "Success", + }, + { + name: "plain string returned unchanged", + input: "plain text", + expected: "plain text", + }, + { + name: "removes red color code", + input: "\x1b[31mError\x1b[0m", + expected: "Error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := StripANSI(tt.input) + assert.Equal(t, tt.expected, result, + "StripANSI(%q) should remove ANSI escape sequences", tt.input) + }) + } +} + +// TestSpec_PublicAPI_NormalizeWorkflowName validates the documented behavior of +// NormalizeWorkflowName as described in the package README.md. +// +// Specification examples: +// +// stringutil.NormalizeWorkflowName("weekly-research.md") // "weekly-research" +// stringutil.NormalizeWorkflowName("weekly-research.lock.yml") // "weekly-research" +// stringutil.NormalizeWorkflowName("weekly-research") // "weekly-research" +func TestSpec_PublicAPI_NormalizeWorkflowName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "removes .md extension", + input: "weekly-research.md", + expected: "weekly-research", + }, + { + name: "removes .lock.yml extension", + input: "weekly-research.lock.yml", + expected: "weekly-research", + }, + { + name: "no extension returned unchanged", + input: "weekly-research", + expected: "weekly-research", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeWorkflowName(tt.input) + assert.Equal(t, tt.expected, result, + "NormalizeWorkflowName(%q) should match documented output", tt.input) + }) + } +} + +// TestSpec_PublicAPI_NormalizeSafeOutputIdentifier validates the documented +// behavior of NormalizeSafeOutputIdentifier as described in the package README.md. +// +// Specification: "Converts dashes to underscores in safe-output identifiers, +// normalizing the user-facing dash-separated format to the internal +// underscore_separated format." +// +// Specification example: +// +// stringutil.NormalizeSafeOutputIdentifier("create-issue") // "create_issue" +func TestSpec_PublicAPI_NormalizeSafeOutputIdentifier(t *testing.T) { + result := NormalizeSafeOutputIdentifier("create-issue") + assert.Equal(t, "create_issue", result, + "NormalizeSafeOutputIdentifier should convert dashes to underscores") +} + +// TestSpec_PublicAPI_MarkdownToLockFile validates the documented behavior of +// MarkdownToLockFile as described in the package README.md. +// +// Specification: "Converts a workflow markdown path (.md) to its compiled lock +// file path (.lock.yml). Returns the path unchanged if it already ends with .lock.yml." +// +// Specification example: +// +// stringutil.MarkdownToLockFile(".github/workflows/test.md") +// // → ".github/workflows/test.lock.yml" +func TestSpec_PublicAPI_MarkdownToLockFile(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "converts .md to .lock.yml (documented example)", + input: ".github/workflows/test.md", + expected: ".github/workflows/test.lock.yml", + }, + { + name: "already .lock.yml returned unchanged", + input: ".github/workflows/test.lock.yml", + expected: ".github/workflows/test.lock.yml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MarkdownToLockFile(tt.input) + assert.Equal(t, tt.expected, result, + "MarkdownToLockFile(%q) should match documented output", tt.input) + }) + } +} + +// TestSpec_PublicAPI_LockFileToMarkdown validates the documented behavior of +// LockFileToMarkdown as described in the package README.md. +// +// Specification: "Converts a compiled lock file path (.lock.yml) back to its +// markdown source path (.md). Returns the path unchanged if it already ends with .md." +// +// Specification example: +// +// stringutil.LockFileToMarkdown(".github/workflows/test.lock.yml") +// // → ".github/workflows/test.md" +func TestSpec_PublicAPI_LockFileToMarkdown(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "converts .lock.yml to .md (documented example)", + input: ".github/workflows/test.lock.yml", + expected: ".github/workflows/test.md", + }, + { + name: "already .md returned unchanged", + input: ".github/workflows/test.md", + expected: ".github/workflows/test.md", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := LockFileToMarkdown(tt.input) + assert.Equal(t, tt.expected, result, + "LockFileToMarkdown(%q) should match documented output", tt.input) + }) + } +} + +// TestSpec_PublicAPI_NormalizeGitHubHostURL validates the documented behavior +// of NormalizeGitHubHostURL as described in the package README.md. +// +// Specification: "Normalizes a GitHub host URL by ensuring it has an https:// +// scheme and no trailing slash." +// +// Specification examples: +// +// stringutil.NormalizeGitHubHostURL("github.example.com") // "https://github.example.com" +// stringutil.NormalizeGitHubHostURL("https://github.com/") // "https://github.com" +func TestSpec_PublicAPI_NormalizeGitHubHostURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "bare hostname gets https scheme", + input: "github.example.com", + expected: "https://github.example.com", + }, + { + name: "trailing slash removed from https URL", + input: "https://github.com/", + expected: "https://github.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeGitHubHostURL(tt.input) + assert.Equal(t, tt.expected, result, + "NormalizeGitHubHostURL(%q) should match documented output", tt.input) + }) + } +} + +// TestSpec_PublicAPI_ExtractDomainFromURL validates the documented behavior of +// ExtractDomainFromURL as described in the package README.md. +// +// Specification: "Extracts the hostname (without port) from a URL string." +// +// Specification example: +// +// stringutil.ExtractDomainFromURL("https://api.github.com/repos") // "api.github.com" +func TestSpec_PublicAPI_ExtractDomainFromURL(t *testing.T) { + result := ExtractDomainFromURL("https://api.github.com/repos") + assert.Equal(t, "api.github.com", result, + "ExtractDomainFromURL should return hostname without port (documented example)") +} + +// TestSpec_Constants_PATType validates the documented PATType constant values +// as described in the package README.md. +// +// Specification: +// +// | Constant | Value | Prefix | +// |---------------------|----------------|--------------| +// | PATTypeFineGrained | "fine-grained" | github_pat_ | +// | PATTypeClassic | "classic" | ghp_ | +// | PATTypeOAuth | "oauth" | gho_ | +// | PATTypeUnknown | "unknown" | (other) | +func TestSpec_Constants_PATType(t *testing.T) { + assert.Equal(t, PATType("fine-grained"), PATTypeFineGrained, + "PATTypeFineGrained should have documented value 'fine-grained'") + assert.Equal(t, PATType("classic"), PATTypeClassic, + "PATTypeClassic should have documented value 'classic'") + assert.Equal(t, PATType("oauth"), PATTypeOAuth, + "PATTypeOAuth should have documented value 'oauth'") + assert.Equal(t, PATType("unknown"), PATTypeUnknown, + "PATTypeUnknown should have documented value 'unknown'") +} + +// TestSpec_PublicAPI_PATType_Methods validates the documented PATType methods +// as described in the package README.md. +// +// Specification: Methods: String() string, IsFineGrained() bool, IsValid() bool +func TestSpec_PublicAPI_PATType_Methods(t *testing.T) { + t.Run("IsFineGrained returns true only for fine-grained type", func(t *testing.T) { + assert.True(t, PATTypeFineGrained.IsFineGrained(), + "PATTypeFineGrained.IsFineGrained() should return true") + assert.False(t, PATTypeClassic.IsFineGrained(), + "PATTypeClassic.IsFineGrained() should return false") + assert.False(t, PATTypeOAuth.IsFineGrained(), + "PATTypeOAuth.IsFineGrained() should return false") + assert.False(t, PATTypeUnknown.IsFineGrained(), + "PATTypeUnknown.IsFineGrained() should return false") + }) + + t.Run("IsValid returns false only for unknown type", func(t *testing.T) { + assert.True(t, PATTypeFineGrained.IsValid(), + "PATTypeFineGrained.IsValid() should return true") + assert.True(t, PATTypeClassic.IsValid(), + "PATTypeClassic.IsValid() should return true") + assert.True(t, PATTypeOAuth.IsValid(), + "PATTypeOAuth.IsValid() should return true") + assert.False(t, PATTypeUnknown.IsValid(), + "PATTypeUnknown.IsValid() should return false") + }) +} + +// TestSpec_PublicAPI_ClassifyPAT validates the documented behavior of ClassifyPAT +// as described in the package README.md. +// +// Specification: "Determines the token type from its prefix." +// +// Prefixes per spec: +// - github_pat_ → PATTypeFineGrained +// - ghp_ → PATTypeClassic +// - gho_ → PATTypeOAuth +// - (other) → PATTypeUnknown +func TestSpec_PublicAPI_ClassifyPAT(t *testing.T) { + tests := []struct { + name string + token string + expected PATType + }{ + { + name: "github_pat_ prefix yields fine-grained", + token: "github_pat_abc123", + expected: PATTypeFineGrained, + }, + { + name: "ghp_ prefix yields classic", + token: "ghp_abc123", + expected: PATTypeClassic, + }, + { + name: "gho_ prefix yields oauth", + token: "gho_abc123", + expected: PATTypeOAuth, + }, + { + name: "unknown prefix yields unknown", + token: "xyz_unknown_token", + expected: PATTypeUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ClassifyPAT(tt.token) + assert.Equal(t, tt.expected, result, + "ClassifyPAT(%q) should classify token by prefix", tt.token) + }) + } +} + +// TestSpec_PublicAPI_ValidateCopilotPAT validates the documented behavior of +// ValidateCopilotPAT as described in the package README.md. +// +// Specification: "Returns nil if the token is a fine-grained PAT; returns an +// actionable error message with a link to create the correct token type otherwise." +func TestSpec_PublicAPI_ValidateCopilotPAT(t *testing.T) { + t.Run("fine-grained PAT returns nil", func(t *testing.T) { + err := ValidateCopilotPAT("github_pat_validtokenhere") + assert.NoError(t, err, + "ValidateCopilotPAT should return nil for fine-grained PAT") + }) + + t.Run("classic PAT returns actionable error", func(t *testing.T) { + err := ValidateCopilotPAT("ghp_classic_token") + require.Error(t, err, + "ValidateCopilotPAT should return an error for classic PAT") + assert.NotEmpty(t, err.Error(), + "ValidateCopilotPAT error should contain an actionable message") + }) + + t.Run("oauth token returns actionable error", func(t *testing.T) { + err := ValidateCopilotPAT("gho_oauth_token") + require.Error(t, err, + "ValidateCopilotPAT should return an error for OAuth token") + }) +} + +// TestSpec_PublicAPI_SanitizeErrorMessage validates the documented behavior of +// SanitizeErrorMessage as described in the package README.md. +// +// Specification: "Redacts potential secret key names from error messages. Matches +// uppercase SNAKE_CASE identifiers (e.g. MY_SECRET_KEY, API_TOKEN) and PascalCase +// identifiers ending with security-related suffixes (e.g. GitHubToken, ApiKey). +// Common GitHub Actions workflow keywords (GITHUB, RUNNER, WORKFLOW, etc.) are +// excluded from redaction." +// +// Specification example: +// +// stringutil.SanitizeErrorMessage("Error: MY_SECRET_TOKEN is invalid") +// // → "Error: [REDACTED] is invalid" +func TestSpec_PublicAPI_SanitizeErrorMessage(t *testing.T) { + t.Run("redacts SNAKE_CASE secret (documented example)", func(t *testing.T) { + result := SanitizeErrorMessage("Error: MY_SECRET_TOKEN is invalid") + assert.Equal(t, "Error: [REDACTED] is invalid", result, + "SanitizeErrorMessage should redact SNAKE_CASE secret identifiers") + }) + + // Specification: "Common GitHub Actions workflow keywords (GITHUB, RUNNER, + // WORKFLOW, etc.) are excluded from redaction." + // Note: standalone keywords like "GITHUB" do not match the compound pattern + // (which requires underscores), so they pass through unchanged. + t.Run("does not redact standalone GITHUB keyword", func(t *testing.T) { + result := SanitizeErrorMessage("Error: GITHUB is not responding") + assert.NotContains(t, result, "[REDACTED]", + "SanitizeErrorMessage should not redact standalone GITHUB keyword") + }) + + // Specification: "GH_AW_ prefixed variables are not redacted." + t.Run("does not redact GH_AW_ configuration variable", func(t *testing.T) { + result := SanitizeErrorMessage("Set GH_AW_SKIP_NPX_VALIDATION=true") + assert.NotContains(t, result, "[REDACTED]", + "SanitizeErrorMessage should not redact GH_AW_ configuration variables") + }) +}