diff --git a/.github/workflows/fuzz-nightly.yml b/.github/workflows/fuzz-nightly.yml new file mode 100644 index 0000000..e511cc9 --- /dev/null +++ b/.github/workflows/fuzz-nightly.yml @@ -0,0 +1,33 @@ +name: nightly-fuzz + +on: + schedule: + - cron: "17 2 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + fuzz: + name: nightly-fuzz + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: "go.mod" + cache: true + + - name: Run nightly fuzz suite + run: make fuzz-nightly NIGHTLY_FUZZTIME=45s + + - name: Upload failing fuzz corpus + if: failure() + uses: actions/upload-artifact@v4 + with: + name: nightly-fuzz-corpus + path: | + **/testdata/fuzz/** + if-no-files-found: ignore diff --git a/Makefile b/Makefile index 0c20fb0..4528b2e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build run test test-race lint clean deps tidy docs site release snapshot install +.PHONY: help build run test test-race test-coverage lint fmt vet check fuzz fuzz-nightly clean deps tidy update-deps generate docs site release snapshot install verify all # Default target .DEFAULT_GOAL := help @@ -9,6 +9,8 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") DATE?=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") LDFLAGS=-ldflags "-X github.com/skatkov/devtui/cmd.version=$(VERSION) -X github.com/skatkov/devtui/cmd.commit=$(COMMIT) -X github.com/skatkov/devtui/cmd.date=$(DATE)" +FUZZTIME?=5s +NIGHTLY_FUZZTIME?=45s help: ## Display this help message @echo "DevTUI - Development targets:" @@ -33,6 +35,29 @@ test-race: ## Run tests with race detection (what CI uses) @echo "Running tests with race detection..." go test -v -failfast -race ./... +fuzz: ## Run fuzz tests (set FUZZTIME=30s for deeper runs) + @echo "Running fuzz tests (FUZZTIME=$(FUZZTIME))..." + go test ./internal/base64 -run=^$$ -fuzz=FuzzBase64RoundTrip -fuzztime=$(FUZZTIME) + go test ./internal/base64 -run=^$$ -fuzz=FuzzDecodeMatchesStdlibBehavior -fuzztime=$(FUZZTIME) + go test ./internal/csv2json -run=^$$ -fuzz=FuzzConvertDoesNotPanic -fuzztime=$(FUZZTIME) + go test ./internal/converter -run=^$$ -fuzz=FuzzYAMLToJSON -fuzztime=$(FUZZTIME) + go test ./internal/converter -run=^$$ -fuzz=FuzzJSONToYAML -fuzztime=$(FUZZTIME) + go test ./internal/converter -run=^$$ -fuzz=FuzzTOMLToJSON -fuzztime=$(FUZZTIME) + go test ./internal/converter -run=^$$ -fuzz=FuzzJSONToTOML -fuzztime=$(FUZZTIME) + go test ./internal/converter -run=^$$ -fuzz=FuzzXMLToJSON -fuzztime=$(FUZZTIME) + go test ./internal/converter -run=^$$ -fuzz=FuzzJSONToXML -fuzztime=$(FUZZTIME) + go test ./internal/converter -run=^$$ -fuzz=FuzzYAMLToTOML -fuzztime=$(FUZZTIME) + go test ./internal/converter -run=^$$ -fuzz=FuzzTOMLToYAML -fuzztime=$(FUZZTIME) + go test ./internal/yamlfmt -run=^$$ -fuzz=FuzzFormatYAML -fuzztime=$(FUZZTIME) + go test ./internal/htmlfmt -run=^$$ -fuzz=FuzzFormatHTML -fuzztime=$(FUZZTIME) + go test ./tui/jsonrepair -run=^$$ -fuzz=FuzzRepairJSONProducesValidJSON -fuzztime=$(FUZZTIME) + go test ./tui/json2toon -run=^$$ -fuzz=FuzzConvertNoPanic -fuzztime=$(FUZZTIME) + go test ./tui/json2toon -run=^$$ -fuzz=FuzzConvertWithOptionsNoPanic -fuzztime=$(FUZZTIME) + +fuzz-nightly: ## Run extended fuzz tests (default NIGHTLY_FUZZTIME=45s) + @echo "Running nightly fuzz tests (NIGHTLY_FUZZTIME=$(NIGHTLY_FUZZTIME))..." + $(MAKE) fuzz FUZZTIME=$(NIGHTLY_FUZZTIME) + test-coverage: ## Run tests with coverage report @echo "Running tests with coverage..." go test -coverprofile=coverage.txt ./... diff --git a/internal/base64/base64_fuzz_test.go b/internal/base64/base64_fuzz_test.go new file mode 100644 index 0000000..ab192ca --- /dev/null +++ b/internal/base64/base64_fuzz_test.go @@ -0,0 +1,94 @@ +package base64 + +import ( + "bytes" + stdbase64 "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +const fuzzSeedDir = "../../testdata" + +func addByteSeedsFromFiles(f *testing.F, fileNames ...string) { + for _, fileName := range fileNames { + content, err := os.ReadFile(filepath.Join(fuzzSeedDir, fileName)) + if err != nil { + continue + } + + f.Add(content) + } +} + +func addStringSeedsFromFiles(f *testing.F, fileNames ...string) { + for _, fileName := range fileNames { + content, err := os.ReadFile(filepath.Join(fuzzSeedDir, fileName)) + if err != nil { + continue + } + + f.Add(string(content)) + } +} + +func FuzzBase64RoundTrip(f *testing.F) { + f.Add([]byte("")) + f.Add([]byte("hello world")) + f.Add([]byte("\x00\x01\x02\xff\xfe")) + f.Add([]byte("ñáéíóú 中文 🚀")) + addByteSeedsFromFiles(f, "sample.txt", "json.txt", "binary.txt", "example.json", "example.yaml") + + f.Fuzz(func(t *testing.T, data []byte) { + encoded := Encode(data) + + decoded, err := Decode(encoded) + if err != nil { + t.Fatalf("Decode(Encode(data)) returned error: %v", err) + } + + if !bytes.Equal(decoded, data) { + t.Fatalf("round-trip mismatch: got %x, want %x", decoded, data) + } + + decodedString, err := DecodeToString(encoded) + if err != nil { + t.Fatalf("DecodeToString(Encode(data)) returned error: %v", err) + } + + if decodedString != string(data) { + t.Fatalf("string round-trip mismatch: got %q, want %q", decodedString, string(data)) + } + }) +} + +func FuzzDecodeMatchesStdlibBehavior(f *testing.F) { + f.Add("") + f.Add("SGVsbG8=") + f.Add(" SGVsbG8= ") + f.Add("%%%") + f.Add("YWJj\n") + addStringSeedsFromFiles(f, "sample.base64", "json.base64", "binary.base64", "invalid.base64") + + f.Fuzz(func(t *testing.T, input string) { + trimmed := strings.TrimSpace(input) + want, wantErr := stdbase64.StdEncoding.DecodeString(trimmed) + + got, err := Decode(input) + if wantErr != nil { + if err == nil { + t.Fatalf("expected error for input %q", input) + } + return + } + + if err != nil { + t.Fatalf("unexpected error for input %q: %v", input, err) + } + + if !bytes.Equal(got, want) { + t.Fatalf("decoded bytes mismatch for input %q: got %x, want %x", input, got, want) + } + }) +} diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 7a8eaed..10d06dd 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -3,7 +3,10 @@ package converter import ( "bytes" "encoding/json" + "encoding/xml" + "errors" "fmt" + "io" "github.com/clbanning/mxj/v2" "github.com/pelletier/go-toml/v2" @@ -77,7 +80,13 @@ func JSONToTOML(jsonContent string) (string, error) { return "", fmt.Errorf("TOML encoding error: %w", err) } - return buf.String(), nil + tomlContent := buf.String() + var validation any + if err := toml.Unmarshal([]byte(tomlContent), &validation); err != nil { + return "", fmt.Errorf("TOML encoding error: %w", err) + } + + return tomlContent, nil } func XMLToJSON(xmlContent string) (string, error) { @@ -105,6 +114,30 @@ func JSONToXML(jsonContent string) (string, error) { return "", fmt.Errorf("XML encoding error: %w", err) } + if len(bytes.TrimSpace(xmlBytes)) == 0 { + return "", errors.New("XML encoding error: empty XML output") + } + + decoder := xml.NewDecoder(bytes.NewReader(xmlBytes)) + seenStart := false + for { + tok, err := decoder.Token() + if err != nil { + if err == io.EOF { + break + } + return "", fmt.Errorf("XML encoding error: %w", err) + } + + if _, ok := tok.(xml.StartElement); ok { + seenStart = true + } + } + + if !seenStart { + return "", errors.New("XML encoding error: empty XML output") + } + var buf bytes.Buffer buf.Write(xmlBytes) return buf.String(), nil @@ -127,7 +160,13 @@ func YAMLToTOML(yamlContent string) (string, error) { return "", fmt.Errorf("TOML encoding error: %w", err) } - return buf.String(), nil + tomlContent := buf.String() + var validation any + if err := toml.Unmarshal([]byte(tomlContent), &validation); err != nil { + return "", fmt.Errorf("TOML encoding error: %w", err) + } + + return tomlContent, nil } func TOMLToYAML(tomlContent string) (string, error) { diff --git a/internal/converter/converter_fuzz_test.go b/internal/converter/converter_fuzz_test.go new file mode 100644 index 0000000..6837334 --- /dev/null +++ b/internal/converter/converter_fuzz_test.go @@ -0,0 +1,213 @@ +package converter + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/clbanning/mxj/v2" + "github.com/pelletier/go-toml/v2" + "gopkg.in/yaml.v3" +) + +const fuzzSeedDir = "../../testdata" + +func addStringSeedFiles(f *testing.F, fileNames ...string) { + for _, fileName := range fileNames { + content, err := os.ReadFile(filepath.Join(fuzzSeedDir, fileName)) + if err != nil { + continue + } + + f.Add(string(content)) + } +} + +func FuzzYAMLToJSON(f *testing.F) { + f.Add("name: Alice\nage: 30\n") + f.Add("items:\n - one\n - two\n") + f.Add("{not: valid") + f.Add("") + addStringSeedFiles(f, "example.yaml", "nested.yaml") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + output, err := YAMLToJSON(input) + if err != nil { + return + } + + if !json.Valid([]byte(output)) { + t.Fatalf("YAMLToJSON returned invalid JSON: %q", output) + } + }) +} + +func FuzzJSONToYAML(f *testing.F) { + f.Add(`{"name":"Alice","age":30}`) + f.Add(`[1,2,3]`) + f.Add(`{invalid`) + f.Add("") + addStringSeedFiles(f, "example.json", "nested.json", "json-with-urls.json") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + output, err := JSONToYAML(input) + if err != nil { + return + } + + var data any + if err := yaml.Unmarshal([]byte(output), &data); err != nil { + t.Fatalf("JSONToYAML returned invalid YAML: %v", err) + } + }) +} + +func FuzzTOMLToJSON(f *testing.F) { + f.Add("name = \"Alice\"\nage = 30\n") + f.Add("[user]\nname = \"Bob\"\n") + f.Add("[[items]]\nname = \"one\"\n") + f.Add("") + addStringSeedFiles(f, "example.toml") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + output, err := TOMLToJSON(input) + if err != nil { + return + } + + if !json.Valid([]byte(output)) { + t.Fatalf("TOMLToJSON returned invalid JSON: %q", output) + } + }) +} + +func FuzzJSONToTOML(f *testing.F) { + f.Add(`{"name":"Alice","age":30}`) + f.Add(`{"items":[{"name":"one"}]}`) + f.Add(`[]`) + f.Add("") + addStringSeedFiles(f, "example.json", "nested.json", "json-with-urls.json") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + output, err := JSONToTOML(input) + if err != nil { + return + } + + var data any + if err := toml.Unmarshal([]byte(output), &data); err != nil { + t.Fatalf("JSONToTOML returned invalid TOML: %v", err) + } + }) +} + +func FuzzXMLToJSON(f *testing.F) { + f.Add("Alice") + f.Add("1") + f.Add("") + f.Add("") + addStringSeedFiles(f, "example.xml") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + output, err := XMLToJSON(input) + if err != nil { + return + } + + if !json.Valid([]byte(output)) { + t.Fatalf("XMLToJSON returned invalid JSON: %q", output) + } + }) +} + +func FuzzJSONToXML(f *testing.F) { + f.Add(`{"root":{"name":"Alice"}}`) + f.Add(`{"root":{"items":[1,2,3]}}`) + f.Add(`[]`) + f.Add("") + addStringSeedFiles(f, "example.json", "nested.json", "json-with-urls.json") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + output, err := JSONToXML(input) + if err != nil { + return + } + + if _, err := mxj.NewMapXml([]byte(output)); err != nil { + t.Fatalf("JSONToXML returned invalid XML: %v", err) + } + }) +} + +func FuzzYAMLToTOML(f *testing.F) { + f.Add("name: Alice\nage: 30\n") + f.Add("items:\n - one\n - two\n") + f.Add("{not: valid") + f.Add("") + addStringSeedFiles(f, "example.yaml", "nested.yaml") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + output, err := YAMLToTOML(input) + if err != nil { + return + } + + var data any + if err := toml.Unmarshal([]byte(output), &data); err != nil { + t.Fatalf("YAMLToTOML returned invalid TOML: %v", err) + } + }) +} + +func FuzzTOMLToYAML(f *testing.F) { + f.Add("name = \"Alice\"\nage = 30\n") + f.Add("[user]\nname = \"Bob\"\n") + f.Add("[[items]]\nname = \"one\"\n") + f.Add("") + addStringSeedFiles(f, "example.toml") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + output, err := TOMLToYAML(input) + if err != nil { + return + } + + var data any + if err := yaml.Unmarshal([]byte(output), &data); err != nil { + t.Fatalf("TOMLToYAML returned invalid YAML: %v", err) + } + }) +} diff --git a/internal/converter/converter_test.go b/internal/converter/converter_test.go new file mode 100644 index 0000000..5809f43 --- /dev/null +++ b/internal/converter/converter_test.go @@ -0,0 +1,30 @@ +package converter + +import "testing" + +func TestJSONToXMLInvalidElementName(t *testing.T) { + t.Parallel() + + _, err := JSONToXML(`{"0":""}`) + if err == nil { + t.Fatal("expected error for invalid XML element name") + } +} + +func TestJSONToTOMLTopLevelArrayReturnsError(t *testing.T) { + t.Parallel() + + _, err := JSONToTOML(`[]`) + if err == nil { + t.Fatal("expected error for top-level array TOML encoding") + } +} + +func TestYAMLToTOMLScalarReturnsError(t *testing.T) { + t.Parallel() + + _, err := YAMLToTOML("00") + if err == nil { + t.Fatal("expected error for scalar TOML encoding") + } +} diff --git a/internal/csv2json/csv2json.go b/internal/csv2json/csv2json.go index 24fe81b..5b8f6f2 100644 --- a/internal/csv2json/csv2json.go +++ b/internal/csv2json/csv2json.go @@ -108,7 +108,7 @@ func arrayContentMatch(str string) (string, int) { i := strings.Index(str, "[") if i >= 0 { j := strings.Index(str, "]") - if j >= 0 { + if j > i { index, err := strconv.Atoi(str[i+1 : j]) if err != nil { return str, -1 diff --git a/internal/csv2json/csv2json_test.go b/internal/csv2json/csv2json_test.go new file mode 100644 index 0000000..e189792 --- /dev/null +++ b/internal/csv2json/csv2json_test.go @@ -0,0 +1,113 @@ +package csv2json + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func addCSVSeedsFromFiles(f *testing.F, fileNames ...string) { + for _, fileName := range fileNames { + content, err := os.ReadFile(filepath.Join("../../testdata", fileName)) + if err != nil { + continue + } + + f.Add(string(content)) + } +} + +func convertNoPanic(t *testing.T, input string) (result string, err error) { + t.Helper() + + defer func() { + if r := recover(); r != nil { + t.Fatalf("Convert panicked: %v", r) + } + }() + + return Convert(input) +} + +func TestConvertConflictingHeadersReturnError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + { + name: "scalar_then_object", + input: "a,a.b\n1,2\n", + }, + { + name: "object_then_scalar", + input: "a.b,a\n1,2\n", + }, + { + name: "array_then_object", + input: "a[0],a.b\n1,2\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := convertNoPanic(t, tt.input) + if err == nil { + t.Fatal("expected error for conflicting CSV headers") + } + }) + } +} + +func TestConvertHighArrayIndexDoesNotPanic(t *testing.T) { + t.Parallel() + + output, err := convertNoPanic(t, "items[2].name\nhello\n") + if err != nil { + t.Fatalf("Convert should not fail for sparse array indexes: %v", err) + } + + if !json.Valid([]byte(output)) { + t.Fatalf("expected valid JSON output, got: %s", output) + } + + if !strings.Contains(output, "hello") { + t.Fatalf("expected output to include converted value, got: %s", output) + } +} + +func TestConvertMalformedArrayHeaderDoesNotPanic(t *testing.T) { + t.Parallel() + + output, err := convertNoPanic(t, "][\n0\n") + if err != nil { + return + } + + if !json.Valid([]byte(output)) { + t.Fatalf("expected valid JSON output, got: %s", output) + } +} + +func FuzzConvertDoesNotPanic(f *testing.F) { + f.Add("name,age\nAlice,30\n") + f.Add("a,a.b\n1,2\n") + f.Add("items[2].name\nhello\n") + f.Add("") + addCSVSeedsFromFiles(f, "example.csv") + + f.Fuzz(func(t *testing.T, input string) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("Convert panicked for input %q: %v", input, r) + } + }() + + _, _ = Convert(input) + }) +} diff --git a/internal/htmlfmt/htmlfmt_fuzz_test.go b/internal/htmlfmt/htmlfmt_fuzz_test.go new file mode 100644 index 0000000..4a19c62 --- /dev/null +++ b/internal/htmlfmt/htmlfmt_fuzz_test.go @@ -0,0 +1,33 @@ +package htmlfmt + +import ( + "os" + "path/filepath" + "testing" +) + +func addHTMLSeedsFromFiles(f *testing.F, fileNames ...string) { + for _, fileName := range fileNames { + content, err := os.ReadFile(filepath.Join("../../testdata", fileName)) + if err != nil { + continue + } + + f.Add(string(content)) + } +} + +func FuzzFormatHTML(f *testing.F) { + f.Add("

Hello

") + f.Add("
missing end") + f.Add("") + addHTMLSeedsFromFiles(f, "html-with-urls.html") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + _ = Format(input) + }) +} diff --git a/internal/ui/base_pager_model.go b/internal/ui/base_pager_model.go index 5df9566..dd599ce 100644 --- a/internal/ui/base_pager_model.go +++ b/internal/ui/base_pager_model.go @@ -228,16 +228,12 @@ func (m *BasePagerModel) StatusBarView() string { // FormatHelpColumns formats the help view with columns func (m *BasePagerModel) FormatHelpColumns(col1 []string) string { s := "\n" - s += "k/↑ up " + col1[0] + "\n" - s += "j/↓ down " + col1[1] + "\n" - s += "b/pgup page up " + col1[2] + "\n" - s += "f/pgdn page down " + col1[3] + "\n" - s += "u ½ page up " + col1[4] + "\n" - s += "d ½ page down " - - if len(col1) > 5 { - s += col1[5] - } + s += "k/↑ up " + helpColumnValue(col1, 0) + "\n" + s += "j/↓ down " + helpColumnValue(col1, 1) + "\n" + s += "b/pgup page up " + helpColumnValue(col1, 2) + "\n" + s += "f/pgdn page down " + helpColumnValue(col1, 3) + "\n" + s += "u ½ page up " + helpColumnValue(col1, 4) + "\n" + s += "d ½ page down " + helpColumnValue(col1, 5) s = Indent(s, 2) @@ -255,3 +251,11 @@ func (m *BasePagerModel) FormatHelpColumns(col1 []string) string { return HelpViewStyle(s) } + +func helpColumnValue(columns []string, index int) string { + if index < 0 || index >= len(columns) { + return "" + } + + return columns[index] +} diff --git a/internal/ui/base_pager_model_test.go b/internal/ui/base_pager_model_test.go new file mode 100644 index 0000000..92be31a --- /dev/null +++ b/internal/ui/base_pager_model_test.go @@ -0,0 +1,27 @@ +package ui + +import "testing" + +func TestFormatHelpColumnsHandlesShortColumnList(t *testing.T) { + t.Parallel() + + m := BasePagerModel{Common: &CommonModel{Width: 80, Height: 24}} + + if m.FormatHelpColumns([]string{"copy", "edit"}) == "" { + t.Fatal("expected non-empty help output") + } +} + +func TestHelpColumnValueBounds(t *testing.T) { + t.Parallel() + + columns := []string{"a", "b"} + + if got := helpColumnValue(columns, 0); got != "a" { + t.Fatalf("expected first value to be 'a', got %q", got) + } + + if got := helpColumnValue(columns, 5); got != "" { + t.Fatalf("expected out-of-range value to be empty, got %q", got) + } +} diff --git a/internal/yamlfmt/yamlfmt.go b/internal/yamlfmt/yamlfmt.go index 0c003d7..9179a79 100644 --- a/internal/yamlfmt/yamlfmt.go +++ b/internal/yamlfmt/yamlfmt.go @@ -2,6 +2,7 @@ package yamlfmt import ( "bytes" + "fmt" "gopkg.in/yaml.v3" ) @@ -22,5 +23,11 @@ func Format(content string) (string, error) { return "", err } - return buf.String(), nil + formatted := buf.String() + var validation any + if err := yaml.Unmarshal([]byte(formatted), &validation); err != nil { + return "", fmt.Errorf("formatted YAML is invalid: %w", err) + } + + return formatted, nil } diff --git a/internal/yamlfmt/yamlfmt_fuzz_test.go b/internal/yamlfmt/yamlfmt_fuzz_test.go new file mode 100644 index 0000000..2d7f1f5 --- /dev/null +++ b/internal/yamlfmt/yamlfmt_fuzz_test.go @@ -0,0 +1,44 @@ +package yamlfmt + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func addYAMLSeedsFromFiles(f *testing.F, fileNames ...string) { + for _, fileName := range fileNames { + content, err := os.ReadFile(filepath.Join("../../testdata", fileName)) + if err != nil { + continue + } + + f.Add(string(content)) + } +} + +func FuzzFormatYAML(f *testing.F) { + f.Add("name: Alice\nage: 30\n") + f.Add("items:\n - one\n - two\n") + f.Add("{not: valid") + f.Add("") + addYAMLSeedsFromFiles(f, "example.yaml", "nested.yaml") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + output, err := Format(input) + if err != nil { + return + } + + var data any + if err := yaml.Unmarshal([]byte(output), &data); err != nil { + t.Fatalf("Format returned invalid YAML: %v", err) + } + }) +} diff --git a/internal/yamlfmt/yamlfmt_test.go b/internal/yamlfmt/yamlfmt_test.go new file mode 100644 index 0000000..d4b8bd9 --- /dev/null +++ b/internal/yamlfmt/yamlfmt_test.go @@ -0,0 +1,28 @@ +package yamlfmt + +import ( + "strings" + "testing" +) + +func TestFormatValidYAML(t *testing.T) { + t.Parallel() + + formatted, err := Format("name: Alice\nage: 30\n") + if err != nil { + t.Fatalf("Format returned error for valid YAML: %v", err) + } + + if !strings.Contains(formatted, "name: Alice") { + t.Fatalf("unexpected formatted YAML output: %q", formatted) + } +} + +func TestFormatReturnsErrorForInvalidFormattedYAML(t *testing.T) { + t.Parallel() + + _, err := Format("0: \n0.:") + if err == nil { + t.Fatal("expected error for YAML that formats into invalid output") + } +} diff --git a/tui/base64-decoder/main.go b/tui/base64-decoder/main.go index 97b38b2..9b8df26 100644 --- a/tui/base64-decoder/main.go +++ b/tui/base64-decoder/main.go @@ -135,7 +135,7 @@ func (m Base64Model) helpView() (s string) { s += "j/↓ down " + col1[1] + "\n" s += "b/pgup page up " + col1[2] + "\n" s += "f/pgdn page down " + col1[3] + "\n" - s += "u ½ page up " + col1[4] + "\n" + s += "u ½ page up " + "\n" s += "d ½ page down " if len(col1) > 5 { diff --git a/tui/base64-decoder/main_test.go b/tui/base64-decoder/main_test.go new file mode 100644 index 0000000..7dab144 --- /dev/null +++ b/tui/base64-decoder/main_test.go @@ -0,0 +1,16 @@ +package base64decoder + +import ( + "testing" + + "github.com/skatkov/devtui/internal/ui" +) + +func TestHelpViewDoesNotPanic(t *testing.T) { + t.Parallel() + + m := NewBase64Model(&ui.CommonModel{Width: 80, Height: 24}) + if m.helpView() == "" { + t.Fatal("expected non-empty help view") + } +} diff --git a/tui/base64-encoder/main.go b/tui/base64-encoder/main.go index a697f81..4e1b1ff 100644 --- a/tui/base64-encoder/main.go +++ b/tui/base64-encoder/main.go @@ -172,7 +172,7 @@ func (m Base64Model) helpView() (s string) { s += "j/↓ down " + col1[1] + "\n" s += "b/pgup page up " + col1[2] + "\n" s += "f/pgdn page down " + col1[3] + "\n" - s += "u ½ page up " + col1[4] + "\n" + s += "u ½ page up " + "\n" s += "d ½ page down " if len(col1) > 5 { diff --git a/tui/base64-encoder/main_test.go b/tui/base64-encoder/main_test.go new file mode 100644 index 0000000..88412ab --- /dev/null +++ b/tui/base64-encoder/main_test.go @@ -0,0 +1,16 @@ +package base64encoder + +import ( + "testing" + + "github.com/skatkov/devtui/internal/ui" +) + +func TestHelpViewDoesNotPanic(t *testing.T) { + t.Parallel() + + m := NewBase64Model(&ui.CommonModel{Width: 80, Height: 24}) + if m.helpView() == "" { + t.Fatal("expected non-empty help view") + } +} diff --git a/tui/json2toon/fuzz_test.go b/tui/json2toon/fuzz_test.go new file mode 100644 index 0000000..db122be --- /dev/null +++ b/tui/json2toon/fuzz_test.go @@ -0,0 +1,68 @@ +package json2toon + +import ( + "os" + "path/filepath" + "testing" + + "github.com/hannes-sistemica/toon" +) + +func addJSONSeedsFromFiles(f *testing.F, fileNames ...string) { + for _, fileName := range fileNames { + content, err := os.ReadFile(filepath.Join("../../testdata", fileName)) + if err != nil { + continue + } + + f.Add(string(content)) + } +} + +func addJSONOptionSeedsFromFiles(f *testing.F, fileNames ...string) { + for _, fileName := range fileNames { + content, err := os.ReadFile(filepath.Join("../../testdata", fileName)) + if err != nil { + continue + } + + f.Add(string(content), uint8(2), "") + } +} + +func FuzzConvertNoPanic(f *testing.F) { + f.Add(`{"name":"Alice"}`) + f.Add(`{"items":[1,2,3]}`) + f.Add(`{invalid`) + f.Add("") + addJSONSeedsFromFiles(f, "example.json", "nested.json", "json-with-urls.json") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + _, _ = Convert(input) + }) +} + +func FuzzConvertWithOptionsNoPanic(f *testing.F) { + f.Add(`{"name":"Alice"}`, uint8(2), "") + f.Add(`{"items":[{"id":1}]}`, uint8(4), "#") + f.Add(`{invalid`, uint8(0), "!") + addJSONOptionSeedsFromFiles(f, "example.json", "nested.json", "json-with-urls.json") + + f.Fuzz(func(t *testing.T, input string, indent uint8, marker string) { + if len(input) > 4096 || len(marker) > 8 { + t.Skip() + } + + opts := toon.EncodeOptions{ + Indent: int(indent % 8), + Delimiter: ",", + LengthMarker: marker, + } + + _, _ = ConvertWithOptions(input, opts) + }) +} diff --git a/tui/jsonrepair/fuzz_test.go b/tui/jsonrepair/fuzz_test.go new file mode 100644 index 0000000..f4f1dee --- /dev/null +++ b/tui/jsonrepair/fuzz_test.go @@ -0,0 +1,42 @@ +package jsonrepair + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func addJSONSeedsFromFiles(f *testing.F, fileNames ...string) { + for _, fileName := range fileNames { + content, err := os.ReadFile(filepath.Join("../../testdata", fileName)) + if err != nil { + continue + } + + f.Add(string(content)) + } +} + +func FuzzRepairJSONProducesValidJSON(f *testing.F) { + f.Add(`{"name":"Alice"}`) + f.Add(`{'name':'Alice'}`) + f.Add("```json\n{'name': 'Alice'}\n```") + f.Add("") + addJSONSeedsFromFiles(f, "example.json", "nested.json", "json-with-urls.json") + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 { + t.Skip() + } + + output, err := RepairJSON(input) + if err != nil { + return + } + + if !json.Valid([]byte(output)) { + t.Fatalf("RepairJSON returned invalid JSON: %q", output) + } + }) +}