feat: implement output formatting and display (HAN-383)
CI / test (pull_request) Failing after 2m2s

Add text and JSON formatters with real-time progress via traverser hooks,
summary statistics, and CLI integration for --show-* and --json flags.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Gary Hansen
2026-06-08 02:21:13 +10:00
co-authored by multica-agent
parent 876542b2de
commit 5a54834c5f
13 changed files with 1359 additions and 180 deletions
+188
View File
@@ -0,0 +1,188 @@
package output
import (
"bytes"
"context"
"encoding/json"
"net"
"strings"
"testing"
"github.com/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse"
miekgdns "github.com/miekg/dns"
)
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if !cfg.ShowProgress {
t.Fatal("expected ShowProgress default true")
}
if cfg.Format != FormatText {
t.Fatalf("Format = %v, want text", cfg.Format)
}
}
func TestNewFormatterSelectsImplementation(t *testing.T) {
text := NewFormatter(DefaultConfig(), &bytes.Buffer{})
if _, ok := text.(*textFormatter); !ok {
t.Fatalf("expected text formatter, got %T", text)
}
jsonCfg := DefaultConfig()
jsonCfg.Format = FormatJSON
jsonFmt := NewFormatter(jsonCfg, &bytes.Buffer{})
if _, ok := jsonFmt.(*jsonFormatter); !ok {
t.Fatalf("expected json formatter, got %T", jsonFmt)
}
}
func TestComputeSummaryAggregatesAnswers(t *testing.T) {
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
resp := &traverse.Response{
Referral: ref,
Type: traverse.RespAnswer,
Decoded: &dns.DecodedResponse{
Answers: []miekgdns.RR{
&miekgdns.A{
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
A: net.ParseIP("93.184.216.34"),
},
},
},
}
stats := ComputeSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}})
if len(stats.Answers) != 1 {
t.Fatalf("answers = %d, want 1", len(stats.Answers))
}
if stats.Answers[0].Prob != 1.0 {
t.Fatalf("prob = %v, want 1.0", stats.Answers[0].Prob)
}
}
func TestTextFormatterSummaryOutput(t *testing.T) {
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
resp := &traverse.Response{
Referral: ref,
Type: traverse.RespAnswer,
Decoded: &dns.DecodedResponse{
Answers: []miekgdns.RR{
&miekgdns.A{
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
A: net.ParseIP("93.184.216.34"),
},
},
},
}
var buf bytes.Buffer
cfg := DefaultConfig()
cfg.Color = false
cfg.ShowServers = false
cfg.ShowResults = false
formatter := NewFormatter(cfg, &buf)
if err := formatter.WriteSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}}); err != nil {
t.Fatalf("WriteSummary: %v", err)
}
out := buf.String()
if !strings.Contains(out, "Summary:") {
t.Fatalf("expected summary header, got %q", out)
}
if !strings.Contains(out, "100%") {
t.Fatalf("expected probability in summary, got %q", out)
}
if !strings.Contains(out, "93.184.216.34") {
t.Fatalf("expected answer IP in summary, got %q", out)
}
}
func TestJSONFormatterProducesValidOutput(t *testing.T) {
ref := traverse.NewReferral("example.com.", dns.TypeA, ".", 0, 1.0, nil)
resp := &traverse.Response{
Referral: ref,
Server: net.ParseIP("198.41.0.4"),
Type: traverse.RespAnswer,
Decoded: &dns.DecodedResponse{
Answers: []miekgdns.RR{
&miekgdns.A{
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET},
A: net.ParseIP("93.184.216.34"),
},
},
},
}
var buf bytes.Buffer
cfg := DefaultConfig()
cfg.Format = FormatJSON
cfg.Domain = "example.com"
cfg.QueryType = "A"
cfg.ShowServers = false
cfg.ShowResults = true
cfg.ShowSummaryResults = true
formatter := NewFormatter(cfg, &buf)
if err := formatter.WriteProgress(traverse.TraversalEvent{
Stage: traverse.EventStart,
Result: traverse.TraversalResult{Referral: ref},
}); err != nil {
t.Fatalf("WriteProgress: %v", err)
}
if err := formatter.WriteSummary([]traverse.TraversalResult{{Referral: ref, Response: resp}}); err != nil {
t.Fatalf("WriteSummary: %v", err)
}
if err := formatter.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(buf.Bytes(), &payload); err != nil {
t.Fatalf("invalid json: %v\n%s", err, buf.String())
}
if payload["domain"] != "example.com" {
t.Fatalf("domain = %v", payload["domain"])
}
if _, ok := payload["summary"]; !ok {
t.Fatalf("expected summary in json output")
}
}
func TestRunTraversalUsesHooks(t *testing.T) {
answerResp := func() *miekgdns.Msg {
m := new(miekgdns.Msg)
m.SetReply(new(miekgdns.Msg))
m.Answer = append(m.Answer, &miekgdns.A{
Hdr: miekgdns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: miekgdns.ClassINET, Ttl: 300},
A: net.ParseIP("93.184.216.34"),
})
return m
}()
tr := traverse.NewTraverser(&traverse.TraverserConfig{
MaxDepth: 5,
QueryType: dns.TypeA,
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
})
tr.SetExchange(func(ctx context.Context, server string, msg *miekgdns.Msg, useTCP bool) (*miekgdns.Msg, error) {
return answerResp.Copy(), nil
})
var buf bytes.Buffer
cfg := DefaultConfig()
cfg.Color = false
cfg.ShowServers = false
cfg.ShowResults = false
cfg.ShowSummaryResults = true
formatter := NewFormatter(cfg, &buf)
_, err := RunTraversal(context.Background(), tr, cfg, formatter, "example.com")
if err != nil {
t.Fatalf("RunTraversal: %v", err)
}
if !strings.Contains(buf.String(), "Summary:") {
t.Fatalf("expected formatted summary output, got %q", buf.String())
}
}