diff --git a/Spec.go b/Spec.go index e16a5ac..b3758e1 100644 --- a/Spec.go +++ b/Spec.go @@ -1,15 +1,18 @@ package testcase import ( + "context" "fmt" "hash/fnv" "regexp" "strings" + "sync" "testing" "go.llib.dev/testcase/assert" "go.llib.dev/testcase/internal" "go.llib.dev/testcase/internal/caller" + "go.llib.dev/testcase/internal/doc" "go.llib.dev/testcase/internal/teardown" ) @@ -28,6 +31,7 @@ func NewSpec(tb testing.TB, opts ...SpecOption) *Spec { s.sync = true } applyGlobal(s) + tb.Cleanup(s.documentResults) return s } @@ -39,6 +43,7 @@ func newSpec(tb testing.TB, opts ...SpecOption) *Spec { vars: newVariables(), immutable: false, } + s.doc.maker = doc.DocumentFormat{} for _, to := range opts { to.setup(s) } @@ -50,10 +55,11 @@ func (spec *Spec) newSubSpec(desc string, opts ...SpecOption) *Spec { spec.immutable = true sub := newSpec(spec.testingTB, opts...) sub.parent = spec + spec.children = append(spec.children, sub) + sub.description = desc sub.seed = spec.seed + sub.doc.maker = spec.doc.maker sub.orderer = spec.orderer - sub.description = desc - spec.children = append(spec.children, sub) return sub } @@ -86,6 +92,12 @@ type Spec struct { defs []func(*Spec) + doc struct { + once sync.Once + maker doc.Formatter + results []doc.TestingCase + } + immutable bool vars *variables parallel bool @@ -450,7 +462,6 @@ func (spec *Spec) run(blk func(*T)) { if h, ok := tb.(helper); ok { h.Helper() } - tb.Helper() spec.runTB(tb, blk) }) } @@ -488,18 +499,16 @@ func (spec *Spec) runTB(tb testing.TB, blk func(*T)) { tb.Parallel() } - tb.Cleanup(func() { - var shouldPrint bool - if tb.Failed() { - shouldPrint = true + defer func() { + var contextPath []string + for _, spec := range spec.specsFromParent() { + contextPath = append(contextPath, spec.description) } - if testing.Verbose() { - shouldPrint = true - } - if shouldPrint { - spec.printDescription(newT(tb, spec)) - } - }) + spec.doc.results = append(spec.doc.results, doc.TestingCase{ + ContextPath: contextPath, + TestFailed: tb.Failed(), + }) + }() test := func(tb testing.TB) { tb.Helper() @@ -578,6 +587,7 @@ func (spec *Spec) Finish() { spec.orderer.Order(tests) td := &teardown.Teardown{} + defer spec.documentResults() defer td.Finish() for _, tc := range tests { tc() @@ -585,6 +595,34 @@ func (spec *Spec) Finish() { }) } +func (spec *Spec) documentResults() { + spec.testingTB.Helper() + if spec.parent != nil { + return + } + spec.doc.once.Do(func() { + var collect func(*Spec) []doc.TestingCase + collect = func(spec *Spec) []doc.TestingCase { + var result []doc.TestingCase + result = append(result, spec.doc.results...) + for _, child := range spec.children { + result = append(result, collect(child)...) + } + return result + } + + doc, err := spec.doc.maker.MakeDocument(context.Background(), collect(spec)) + if err != nil { + spec.testingTB.Errorf("document writer encountered an error: %s", err.Error()) + return + } + + if 0 < len(doc) { + internal.Log(spec.testingTB, doc) + } + }) +} + func (spec *Spec) withFinishUsingTestingTB(tb testing.TB, blk func()) { spec.testingTB.Helper() tb.Helper() @@ -669,6 +707,8 @@ func (spec *Spec) getTagSet() map[string]struct{} { return tagsSet } +// addTest registers a testing block to be executed as part of the Spec. +// the main purpose is to enable test execution order manipulation throught the TESTCASE_SEED. func (spec *Spec) addTest(blk func()) { spec.testingTB.Helper() diff --git a/internal/doc/doc.go b/internal/doc/doc.go new file mode 100644 index 0000000..2b87f56 --- /dev/null +++ b/internal/doc/doc.go @@ -0,0 +1,162 @@ +package doc + +import ( + "context" + "fmt" + "os" + "strings" + + "go.llib.dev/testcase/internal" +) + +type Formatter interface { + MakeDocument(context.Context, []TestingCase) (string, error) +} + +type TestingCase struct { + // ContextPath is the Testing ContextPath + ContextPath []string + // TestFailed tells if the test failed + TestFailed bool +} + +type DocumentFormat struct{} + +func (gen DocumentFormat) MakeDocument(ctx context.Context, tcs []TestingCase) (string, error) { + node := newNode() + for _, tc := range tcs { + node.Add(tc) + } + var document string + if gen.hasFailed(node) || internal.Verbose() { + document = gen.generateDocumentString(node, "") + } + return document, nil +} + +type colourCode string + +const ( + red colourCode = "91m" + green colourCode = "92m" +) + +func colourise(code colourCode, text string) string { + if isColourCodingSupported() { + return fmt.Sprintf("\033[%s%s\033[0m", code, text) + } + return text +} + +func newNode() *node { + return &node{Nodes: make(nodes)} +} + +type node struct { + Nodes nodes + TestingCase TestingCase +} + +type nodes map[string]*node + +func (n *node) Add(tc TestingCase) { + n.cd(tc.ContextPath).TestingCase = tc +} + +func (n *node) cd(path []string) *node { + current := n + for _, part := range path { + if current.Nodes == nil { + current.Nodes = make(nodes) + } + if _, ok := current.Nodes[part]; !ok { + current.Nodes[part] = newNode() + } + current = current.Nodes[part] + } + return current +} + +func (gen DocumentFormat) hasFailed(n *node) bool { + for _, child := range n.Nodes { + if child.TestingCase.TestFailed { + return true + } + if gen.hasFailed(child) { + return true + } + } + return false +} + +func (gen DocumentFormat) generateDocumentString(n *node, indent string) string { + var sb strings.Builder + for key, child := range n.Nodes { + sb.WriteString(indent) + var ( + line = key + colour = green + ) + if child.TestingCase.TestFailed { + line += " [FAIL]" + colour = red + } + if len(child.Nodes) == 0 { + line = colourise(colour, line) + } + sb.WriteString(line) + sb.WriteString("\n") + sb.WriteString(gen.generateDocumentString(child, indent+" ")) + } + return sb.String() +} + +var colourSupportingTerms = map[string]struct{}{ + "xterm-256color": {}, + "xterm-88color": {}, + "xterm-16color": {}, + "gnome-terminal": {}, + "screen": {}, + "konsole": {}, + "terminator": {}, + "aterm": {}, + "linux": {}, // default terminal type for Linux systems + "urxvt": {}, // popular terminal emulator for Unix-like systems + "konsole-256color": {}, // 256-color version of Konsole + "gnome-terminal-256color": {}, // 256-color version of Gnome Terminal + "xfce4-terminal": {}, // terminal emulator for Xfce desktop environment + "terminator-256color": {}, // 256-color version of Terminator + "alacritty": {}, // modern terminal emulator with GPU acceleration + "kitty": {}, // fast, feature-rich, GPU-based terminal emulator + "hyper": {}, // terminal built on web technologies + "wezterm": {}, // highly configurable, GPU-accelerated terminal + "iterm2": {}, // popular terminal emulator for macOS + "st": {}, // simple terminal from the suckless project + "rxvt-unicode-256color": {}, // 256-color version of rxvt-unicode + "rxvt-256color": {}, // 256-color version of rxvt + "foot": {}, // lightweight Wayland terminal emulator + "mlterm": {}, // multilingual terminal emulator + "putty": {}, // popular SSH and telnet client +} + +var colourlessTerms = map[string]struct{}{ + "dumb": {}, + "vt100": {}, + "ansi": {}, + "ansi.sys": {}, + "vt52": {}, +} + +func isColourCodingSupported() bool { + term, ok := os.LookupEnv("TERM") + if !ok { + return false + } + if _, ok := colourlessTerms[term]; ok { + return false + } + if _, ok := colourSupportingTerms[term]; ok { + return true + } + return false +} diff --git a/internal/doc/doc_test.go b/internal/doc/doc_test.go new file mode 100644 index 0000000..85828e7 --- /dev/null +++ b/internal/doc/doc_test.go @@ -0,0 +1,163 @@ +package doc_test + +import ( + "context" + "testing" + + "go.llib.dev/testcase" + "go.llib.dev/testcase/assert" + "go.llib.dev/testcase/internal" + "go.llib.dev/testcase/internal/doc" +) + +func TestTestDocumentGenerator(t *testing.T) { + + t.Run("no error (no verbose) - colourless", func(t *testing.T) { + internal.StubVerbose(t, func() bool { return false }) + + docw := doc.DocumentFormat{} + + d, err := docw.MakeDocument(context.Background(), []doc.TestingCase{ + { + ContextPath: []string{ + "TestTestDocumentGenerator", + "smoke", + "testA", + }, + TestFailed: false, + }, + { + ContextPath: []string{ + "TestTestDocumentGenerator", + "smoke", + "testB", + }, + TestFailed: false, + }, + }) + assert.NoError(t, err) + assert.Empty(t, d) + }) + + t.Run("no error (verbose) - colourless", func(t *testing.T) { + testcase.SetEnv(t, "TERM", "dumb") + internal.StubVerbose(t, func() bool { return true }) + + docw := doc.DocumentFormat{} + + d, err := docw.MakeDocument(context.Background(), []doc.TestingCase{ + { + ContextPath: []string{ + "TestTestDocumentGenerator", + "smoke", + "testA", + }, + TestFailed: false, + }, + }) + assert.NoError(t, err) + + exp := "TestTestDocumentGenerator\n smoke\n testA\n" + assert.Equal(t, d, exp) + }) + + t.Run("many - colourless", func(t *testing.T) { + testcase.SetEnv(t, "TERM", "dumb") + + docw := doc.DocumentFormat{} + + d, err := docw.MakeDocument(context.Background(), []doc.TestingCase{ + { + ContextPath: []string{ + "TestTestDocumentGenerator", + "smoke", + "testA", + }, + TestFailed: false, + }, + { + ContextPath: []string{ + "TestTestDocumentGenerator", + "smoke", + "testB", + }, + TestFailed: true, + }, + }) + assert.NoError(t, err) + + base := "TestTestDocumentGenerator\n" + base += " smoke\n" + exp1 := base + " testA\n" + " testB [FAIL]\n" + exp2 := base + " testB [FAIL]\n" + " testA\n" + + assert.AnyOf(t, func(a *assert.A) { + a.Case(func(t assert.It) { assert.Contain(t, d, exp1) }) + a.Case(func(t assert.It) { assert.Contain(t, d, exp2) }) + }) + }) + + t.Run("many - colourised", func(t *testing.T) { + testcase.SetEnv(t, "TERM", "xterm-256color") + + docw := doc.DocumentFormat{} + + d, err := docw.MakeDocument(context.Background(), []doc.TestingCase{ + { + ContextPath: []string{ + "TestTestDocumentGenerator", + "smoke", + "testA", + }, + TestFailed: false, + }, + { + ContextPath: []string{ + "TestTestDocumentGenerator", + "smoke", + "testB", + }, + TestFailed: true, + }, + }) + assert.NoError(t, err) + + exp1 := "TestTestDocumentGenerator\n smoke\n \x1b[91mtestB [FAIL]\x1b[0m\n \x1b[92mtestA\x1b[0m\n" + exp2 := "TestTestDocumentGenerator\n smoke\n \x1b[92mtestA\x1b[0m\n \x1b[91mtestB [FAIL]\x1b[0m\n" + + assert.AnyOf(t, func(a *assert.A) { + a.Case(func(t assert.It) { assert.Contain(t, d, exp1) }) + a.Case(func(t assert.It) { assert.Contain(t, d, exp2) }) + }) + }) +} + +func Test_spike(t *testing.T) { + + docw := doc.DocumentFormat{} + + d, err := docw.MakeDocument(context.Background(), []doc.TestingCase{ + { + ContextPath: []string{ + "subject", + "when", + "and", + "then A", + }, + TestFailed: false, + }, + { + ContextPath: []string{ + "subject", + "when", + "and", + "then B", + }, + TestFailed: true, + }, + }) + assert.NoError(t, err) + + t.Log("\n\n" + d) + +} diff --git a/internal/verbose.go b/internal/verbose.go new file mode 100644 index 0000000..37cfe0e --- /dev/null +++ b/internal/verbose.go @@ -0,0 +1,16 @@ +package internal + +import ( + "testing" +) + +func Verbose() bool { + return verbose() +} + +var verbose = testing.Verbose + +func StubVerbose(tb testing.TB, fn func() bool) { + tb.Cleanup(func() { verbose = testing.Verbose }) + verbose = fn +}