Repository: raviqqe/muffet (go)

Issue:
Add support for outputing to JSON all crawled URLs (including 200 ones)
In addition to #38, where one wants to save the failed URLs as JSON, listing also all resources (i.e. those that return 30x or 200) could be useful.

For example one could use `muffet` to crawl a site in order to extract a list of all dependent resources (CSS, JS, images, etc.) and other linked-to pages.

Then one could use these URLs for other analytical purposes, or even to warmup a cache after a redeploy.

With the current format, the `links` JSON list could be expanded with all encountered URLs and replacing `error` with `status` to easily differentiate what was an error and what was  a successful crawl.


Pull request:
Add JUnit output and expand JSON output
This PR does three things:

1. Allow including successful results in JSON output. (Fixes #214.)
2. Add JUnit output with a <kbd>--junit</kbd> flag. (Allows GitLab to parse output. Does not include skipped links.)
3. Track elapsed time for each link and each page. (Used in JUnit output.)

I’m not a Go developer, so let me know if there are any best practices or style guidelines I’ve ignored (most probably when updating existing tests to add durations with lots of repetition) and I’ll try to fix them. I modeled the JUnit addition after the existing JSON output and divided the changes into atomic commits as far as possible. There are no errors or warnings, the tests pass, and the JUnit output seems to be correct (I used [broken-links-inspector](https://github.com/dbogatov/broken-links-inspector/)’s JUnit output as a point of reference).

Patch:
diff --git a/arguments.go b/arguments.go
index 8d58fbb..c9ea2ac 100644
--- a/arguments.go
+++ b/arguments.go
@@ -10,31 +10,33 @@ import (
 )
 
 type arguments struct {
-	BufferSize            int      `short:"b" long:"buffer-size" value-name:"<size>" default:"4096" description:"HTTP response buffer size in bytes"`
-	MaxConnections        int      `short:"c" long:"max-connections" value-name:"<count>" default:"512" description:"Maximum number of HTTP connections"`
-	MaxConnectionsPerHost int      `long:"max-connections-per-host" value-name:"<count>" default:"512" description:"Maximum number of HTTP connections per host"`
-	MaxResponseBodySize   int      `long:"max-response-body-size" value-name:"<size>" default:"10000000" description:"Maximum response body size to read"`
-	RawExcludedPatterns   []string `short:"e" long:"exclude" value-name:"<pattern>..." description:"Exclude URLs matched with given regular expressions"`
-	RawIncludedPatterns   []string `short:"i" long:"include" value-name:"<pattern>..." description:"Include URLs matched with given regular expressions"`
-	FollowRobotsTxt       bool     `long:"follow-robots-txt" description:"Follow robots.txt when scraping pages"`
-	FollowSitemapXML      bool     `long:"follow-sitemap-xml" description:"Scrape only pages listed in sitemap.xml"`
-	RawHeaders            []string `long:"header" value-name:"<header>..." description:"Custom headers"`
-	IgnoreFragments       bool     `short:"f" long:"ignore-fragments" description:"Ignore URL fragments"`
-	JSONOutput            bool     `long:"json" description:"Output results in JSON"`
-	MaxRedirections       int      `short:"r" long:"max-redirections" value-name:"<count>" default:"64" description:"Maximum number of redirections"`
-	RateLimit             int      `long:"rate-limit" value-name:"<rate>" description:"Max requests per second"`
-	Timeout               int      `short:"t" long:"timeout" value-name:"<seconds>" default:"10" description:"Timeout for HTTP requests in seconds"`
-	Verbose               bool     `short:"v" long:"verbose" description:"Show successful results too"`
-	Proxy                 string   `long:"proxy" value-name:"<host>" description:"HTTP proxy host"`
-	SkipTLSVerification   bool     `long:"skip-tls-verification" description:"Skip TLS certificate verification"`
-	OnePageOnly           bool     `long:"one-page-only" description:"Only check links found in the given URL"`
-	Color                 color    `long:"color" description:"Color output" choice:"auto" choice:"always" choice:"never" default:"auto"`
-	Help                  bool     `short:"h" long:"help" description:"Show this help"`
-	Version               bool     `long:"version" description:"Show version"`
-	URL                   string
-	ExcludedPatterns      []*regexp.Regexp
-	IncludePatterns       []*regexp.Regexp
-	Headers               map[string]string
+	BufferSize                 int      `short:"b" long:"buffer-size" value-name:"<size>" default:"4096" description:"HTTP response buffer size in bytes"`
+	MaxConnections             int      `short:"c" long:"max-connections" value-name:"<count>" default:"512" description:"Maximum number of HTTP connections"`
+	MaxConnectionsPerHost      int      `long:"max-connections-per-host" value-name:"<count>" default:"512" description:"Maximum number of HTTP connections per host"`
+	MaxResponseBodySize        int      `long:"max-response-body-size" value-name:"<size>" default:"10000000" description:"Maximum response body size to read"`
+	RawExcludedPatterns        []string `short:"e" long:"exclude" value-name:"<pattern>..." description:"Exclude URLs matched with given regular expressions"`
+	RawIncludedPatterns        []string `short:"i" long:"include" value-name:"<pattern>..." description:"Include URLs matched with given regular expressions"`
+	FollowRobotsTxt            bool     `long:"follow-robots-txt" description:"Follow robots.txt when scraping pages"`
+	FollowSitemapXML           bool     `long:"follow-sitemap-xml" description:"Scrape only pages listed in sitemap.xml"`
+	RawHeaders                 []string `long:"header" value-name:"<header>..." description:"Custom headers"`
+	IgnoreFragments            bool     `short:"f" long:"ignore-fragments" description:"Ignore URL fragments"`
+	JSONOutput                 bool     `long:"json" description:"Output results in JSON"`
+	IncludeSuccessInJSONOutput bool     `long:"include-success-in-json" description:"Include successful results in JSON"`
+	JUnitOutput                bool     `long:"junit" description:"Output results as JUnit XML file"`
+	MaxRedirections            int      `short:"r" long:"max-redirections" value-name:"<count>" default:"64" description:"Maximum number of redirections"`
+	RateLimit                  int      `long:"rate-limit" value-name:"<rate>" description:"Max requests per second"`
+	Timeout                    int      `short:"t" long:"timeout" value-name:"<seconds>" default:"10" description:"Timeout for HTTP requests in seconds"`
+	Verbose                    bool     `short:"v" long:"verbose" description:"Show successful results too"`
+	Proxy                      string   `long:"proxy" value-name:"<host>" description:"HTTP proxy host"`
+	SkipTLSVerification        bool     `long:"skip-tls-verification" description:"Skip TLS certificate verification"`
+	OnePageOnly                bool     `long:"one-page-only" description:"Only check links found in the given URL"`
+	Color                      color    `long:"color" description:"Color output" choice:"auto" choice:"always" choice:"never" default:"auto"`
+	Help                       bool     `short:"h" long:"help" description:"Show this help"`
+	Version                    bool     `long:"version" description:"Show version"`
+	URL                        string
+	ExcludedPatterns           []*regexp.Regexp
+	IncludePatterns            []*regexp.Regexp
+	Headers                    map[string]string
 }
 
 func getArguments(ss []string) (*arguments, error) {
diff --git a/command.go b/command.go
index 35b25a9..0b5a0e3 100644
--- a/command.go
+++ b/command.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"encoding/json"
+	"encoding/xml"
 	"errors"
 	"fmt"
 	"io"
@@ -43,6 +44,10 @@ func (c *command) runWithError(ss []string) (bool, error) {
 		return err == nil, err
 	} else if args.JSONOutput && args.Verbose {
 		return false, errors.New("verbose option not supported for JSON output")
+	} else if args.JUnitOutput && args.Verbose {
+		return false, errors.New("verbose option not supported for JUnit output")
+	} else if args.JSONOutput && args.JUnitOutput {
+		return false, errors.New("JSON and JUnit output are mutually exclusive")
 	}
 
 	client := newRedirectHttpClient(
@@ -111,7 +116,11 @@ func (c *command) runWithError(ss []string) (bool, error) {
 	go checker.Check(p)
 
 	if args.JSONOutput {
-		return c.printResultsInJSON(checker.Results())
+		return c.printResultsInJSON(checker.Results(), args.IncludeSuccessInJSONOutput)
+	}
+
+	if args.JUnitOutput {
+		return c.printResultsAsJunitXML(checker.Results())
 	}
 
 	formatter := newPageResultFormatter(
@@ -134,18 +143,20 @@ func (c *command) runWithError(ss []string) (bool, error) {
 	return ok, nil
 }
 
-func (c *command) printResultsInJSON(rc <-chan *pageResult) (bool, error) {
-	rs := []*jsonPageResult{}
+func (c *command) printResultsInJSON(rc <-chan *pageResult, includeSuccess bool) (bool, error) {
+	results := []interface{}{}
 	ok := true
 
 	for r := range rc {
-		if !r.OK() {
-			rs = append(rs, newJSONPageResult(r))
+		if r.OK() && includeSuccess {
+			results = append(results, newJSONSuccessPageResult(r))
+		} else if !r.OK() {
+			results = append(results, newJSONErrorPageResult(r))
 			ok = false
 		}
 	}
 
-	bs, err := json.Marshal(rs)
+	bs, err := json.Marshal(results)
 
 	if err != nil {
 		return false, err
@@ -156,6 +167,35 @@ func (c *command) printResultsInJSON(rc <-chan *pageResult) (bool, error) {
 	return ok, nil
 }
 
+func (c *command) printResultsAsJunitXML(rc <-chan *pageResult) (bool, error) {
+	rs := []*xmlPageResult{}
+	ok := true
+
+	for r := range rc {
+		rs = append(rs, newXMLPageResult(r))
+
+		if !r.OK() {
+			ok = false
+		}
+	}
+
+	results := &struct {
+		XMLName     xml.Name         `xml:"testsuites"`
+		PageResults []*xmlPageResult `xml:"testsuite"`
+	}{PageResults: rs}
+
+	data, err := xml.MarshalIndent(results, "", "  ")
+
+	if err != nil {
+		return ok, err
+	}
+
+	c.print(xml.Header)
+	c.print(string(data))
+
+	return ok, nil
+}
+
 func (c *command) print(xs ...interface{}) {
 	if _, err := fmt.Fprintln(c.stdout, strings.TrimSpace(fmt.Sprint(xs...))); err != nil {
 		panic(err)
diff --git a/json_page_result.go b/json_page_result.go
index cc6fd36..2d67946 100644
--- a/json_page_result.go
+++ b/json_page_result.go
@@ -1,21 +1,46 @@
 package main
 
-type jsonPageResult struct {
-	URL   string            `json:"url"`
-	Links []*jsonLinkResult `json:"links"`
+type jsonAllPageResults struct {
+	Error   []*jsonErrorPageResult   `json:"error"`
+	Success []*jsonSuccessPageResult `json:"success"`
 }
 
-type jsonLinkResult struct {
+type jsonErrorPageResult struct {
+	URL   string                 `json:"url"`
+	Links []*jsonErrorLinkResult `json:"links"`
+}
+
+type jsonErrorLinkResult struct {
 	URL   string `json:"url"`
 	Error string `json:"error"`
 }
 
-func newJSONPageResult(r *pageResult) *jsonPageResult {
-	ls := make([]*jsonLinkResult, 0, len(r.ErrorLinkResults))
+type jsonSuccessPageResult struct {
+	URL   string                   `json:"url"`
+	Links []*jsonSuccessLinkResult `json:"links"`
+}
+
+type jsonSuccessLinkResult struct {
+	URL    string `json:"url"`
+	Status int    `json:"status"`
+}
+
+func newJSONErrorPageResult(r *pageResult) *jsonErrorPageResult {
+	ls := make([]*jsonErrorLinkResult, 0, len(r.ErrorLinkResults))
 
 	for _, r := range r.ErrorLinkResults {
-		ls = append(ls, &jsonLinkResult{r.URL, r.Error.Error()})
+		ls = append(ls, &jsonErrorLinkResult{r.URL, r.Error.Error()})
+	}
+
+	return &jsonErrorPageResult{r.URL, ls}
+}
+
+func newJSONSuccessPageResult(r *pageResult) *jsonSuccessPageResult {
+	ls := make([]*jsonSuccessLinkResult, 0, len(r.SuccessLinkResults))
+
+	for _, r := range r.SuccessLinkResults {
+		ls = append(ls, &jsonSuccessLinkResult{r.URL, r.StatusCode})
 	}
 
-	return &jsonPageResult{r.URL, ls}
+	return &jsonSuccessPageResult{r.URL, ls}
 }
diff --git a/page_checker.go b/page_checker.go
index a900bb6..6457ef0 100644
--- a/page_checker.go
+++ b/page_checker.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"sync"
+	"time"
 )
 
 type pageChecker struct {
@@ -42,9 +43,10 @@ func (c *pageChecker) checkPage(p *page) {
 	ec := make(chan *errorLinkResult, len(us))
 	w := sync.WaitGroup{}
 
+	pageStart := time.Now()
 	for u, err := range us {
 		if err != nil {
-			ec <- &errorLinkResult{u, err}
+			ec <- &errorLinkResult{u, err, time.Since(time.Now())}
 			continue
 		}
 
@@ -53,12 +55,14 @@ func (c *pageChecker) checkPage(p *page) {
 		go func(u string) {
 			defer w.Done()
 
+			linkStart := time.Now()
 			status, p, err := c.fetcher.Fetch(u)
+			linkElapsed := time.Since(linkStart)
 
 			if err == nil {
-				sc <- &successLinkResult{u, status}
+				sc <- &successLinkResult{u, status, linkElapsed}
 			} else {
-				ec <- &errorLinkResult{u, err}
+				ec <- &errorLinkResult{u, err, linkElapsed}
 			}
 
 			if !c.onePageOnly && p != nil && c.linkValidator.Validate(p.URL()) {
@@ -68,6 +72,7 @@ func (c *pageChecker) checkPage(p *page) {
 	}
 
 	w.Wait()
+	pageElapsed := time.Since(pageStart)
 
 	close(sc)
 	close(ec)
@@ -84,7 +89,7 @@ func (c *pageChecker) checkPage(p *page) {
 		es = append(es, e)
 	}
 
-	c.results <- &pageResult{p.URL().String(), ss, es}
+	c.results <- &pageResult{p.URL().String(), ss, es, pageElapsed}
 }
 
 func (c *pageChecker) addPage(p *page) {
diff --git a/page_result.go b/page_result.go
index af4b09f..9e37e33 100644
--- a/page_result.go
+++ b/page_result.go
@@ -1,19 +1,24 @@
 package main
 
+import "time"
+
 type pageResult struct {
 	URL                string
 	SuccessLinkResults []*successLinkResult
 	ErrorLinkResults   []*errorLinkResult
+	Elapsed            time.Duration
 }
 
 type successLinkResult struct {
 	URL        string
 	StatusCode int
+	Elapsed    time.Duration
 }
 
 type errorLinkResult struct {
-	URL   string
-	Error error
+	URL     string
+	Error   error
+	Elapsed time.Duration
 }
 
 func (r *pageResult) OK() bool {
diff --git a/xml_page_result.go b/xml_page_result.go
new file mode 100644
index 0000000..8088470
--- /dev/null
+++ b/xml_page_result.go
@@ -0,0 +1,40 @@
+package main
+
+type xmlPageResult struct {
+	Url      string           `xml:"name,attr"`
+	Total    int              `xml:"tests,attr"`
+	Failures int              `xml:"failures,attr"`
+	Skipped  int              `xml:"skipped,attr"`
+	Time     float64          `xml:"time,attr"`
+	Links    []*xmlLinkResult `xml:"testcase"`
+}
+
+type xmlLinkResult struct {
+	Url     string          `xml:"name,attr"`
+	Time    float64         `xml:"time,attr"`
+	Source  string          `xml:"classname,attr"`
+	Failure *xmlLinkFailure `xml:"failure"`
+}
+
+type xmlLinkFailure struct {
+	Message string `xml:"message,attr"`
+}
+
+func newXMLPageResult(pr *pageResult) *xmlPageResult {
+	ls := make([]*xmlLinkResult, 0, len(pr.ErrorLinkResults)+len(pr.SuccessLinkResults))
+
+	// TODO: Consider adding information skipped links, if that can be
+	// tracked.
+	for _, r := range pr.ErrorLinkResults {
+		failure := &xmlLinkFailure{Message: r.Error.Error()}
+		l := &xmlLinkResult{Url: r.URL, Source: pr.URL, Time: r.Elapsed.Seconds(), Failure: failure}
+		ls = append(ls, l)
+	}
+
+	for _, r := range pr.SuccessLinkResults {
+		l := &xmlLinkResult{Url: r.URL, Source: pr.URL, Time: r.Elapsed.Seconds()}
+		ls = append(ls, l)
+	}
+
+	return &xmlPageResult{Url: pr.URL, Time: pr.Elapsed.Seconds(), Skipped: 0, Total: len(ls), Failures: len(pr.ErrorLinkResults), Links: ls}
+}


Test patch:
diff --git a/.snapshots/TestCommandFailWithJUnitAndJSONOptions b/.snapshots/TestCommandFailWithJUnitAndJSONOptions
new file mode 100644
index 0000000..cffb2b4
--- /dev/null
+++ b/.snapshots/TestCommandFailWithJUnitAndJSONOptions
@@ -0,0 +1,2 @@
+JSON and JUnit output are mutually exclusive
+
diff --git a/.snapshots/TestCommandFailWithVerboseAndJUnitOptions b/.snapshots/TestCommandFailWithVerboseAndJUnitOptions
new file mode 100644
index 0000000..88b3335
--- /dev/null
+++ b/.snapshots/TestCommandFailWithVerboseAndJUnitOptions
@@ -0,0 +1,2 @@
+verbose option not supported for JUnit output
+
diff --git a/.snapshots/TestHelp b/.snapshots/TestHelp
index 0bd8923..c6e2426 100644
--- a/.snapshots/TestHelp
+++ b/.snapshots/TestHelp
@@ -21,6 +21,8 @@ Application Options:
       --header=<header>...                  Custom headers
   -f, --ignore-fragments                    Ignore URL fragments
       --json                                Output results in JSON
+      --include-success-in-json             Include successful results in JSON
+      --junit                               Output results as JUnit XML file
   -r, --max-redirections=<count>            Maximum number of redirections
                                             (default: 64)
       --rate-limit=<rate>                   Max requests per second
diff --git a/.snapshots/TestMarshalErrorJSONPageResult b/.snapshots/TestMarshalErrorJSONPageResult
new file mode 100644
index 0000000..bcda3c5
--- /dev/null
+++ b/.snapshots/TestMarshalErrorJSONPageResult
@@ -0,0 +1,1 @@
+{"url":"http://foo.com","links":[{"url":"http://foo.com/bar","error":"baz"}]}
diff --git a/.snapshots/TestMarshalSuccessJSONPageResult b/.snapshots/TestMarshalSuccessJSONPageResult
new file mode 100644
index 0000000..0156d38
--- /dev/null
+++ b/.snapshots/TestMarshalSuccessJSONPageResult
@@ -0,0 +1,1 @@
+{"url":"http://foo.com","links":[{"url":"http://foo.com/foo","status":200}]}
diff --git a/command_test.go b/command_test.go
index 74617dd..956eece 100644
--- a/command_test.go
+++ b/command_test.go
@@ -256,6 +256,20 @@ func TestCommandDoNotIncludeSuccessfulPageInJSONOutput(t *testing.T) {
 	assert.Equal(t, strings.TrimSpace(b.String()), "[]")
 }
 
+func TestCommandIncludeSuccessfulPageInJSONOutputWhenRequested(t *testing.T) {
+	b := &bytes.Buffer{}
+
+	ok := newTestCommandWithStdout(
+		b,
+		func(u *url.URL) (*fakeHttpResponse, error) {
+			return newFakeHtmlResponse("", ""), nil
+		},
+	).Run([]string{"--json", "--include-success-in-json", "http://foo.com"})
+
+	assert.True(t, ok)
+	assert.Equal(t, strings.TrimSpace(b.String()), "[{\"url\":\"\",\"links\":[]}]")
+}
+
 func TestCommandFailWithVerboseAndJSONOptions(t *testing.T) {
 	b := &bytes.Buffer{}
 
@@ -266,3 +280,46 @@ func TestCommandFailWithVerboseAndJSONOptions(t *testing.T) {
 	assert.False(t, ok)
 	cupaloy.SnapshotT(t, b.String())
 }
+
+func TestCommandFailToRunWithJUnitOutput(t *testing.T) {
+	b := &bytes.Buffer{}
+
+	ok := newTestCommandWithStdout(
+		b,
+		func(u *url.URL) (*fakeHttpResponse, error) {
+			if u.String() == "http://foo.com" {
+				return newFakeHtmlResponse(
+					"http://foo.com",
+					`<html><body><a href="/foo" /></body></html>`,
+				), nil
+			}
+
+			return nil, errors.New("foo")
+		},
+	).Run([]string{"--junit", "http://foo.com"})
+
+	assert.False(t, ok)
+	assert.Greater(t, b.Len(), 0)
+}
+
+func TestCommandFailWithVerboseAndJUnitOptions(t *testing.T) {
+	b := &bytes.Buffer{}
+
+	ok := newTestCommandWithStderr(b, nil).Run(
+		[]string{"--junit", "--verbose", "http://foo.com"},
+	)
+
+	assert.False(t, ok)
+	cupaloy.SnapshotT(t, b.String())
+}
+
+func TestCommandFailWithJUnitAndJSONOptions(t *testing.T) {
+	b := &bytes.Buffer{}
+
+	ok := newTestCommandWithStderr(b, nil).Run(
+		[]string{"--json", "--junit", "http://foo.com"},
+	)
+
+	assert.False(t, ok)
+	cupaloy.SnapshotT(t, b.String())
+}
diff --git a/json_page_result_test.go b/json_page_result_test.go
index cbadc06..04310ad 100644
--- a/json_page_result_test.go
+++ b/json_page_result_test.go
@@ -4,21 +4,37 @@ import (
 	"encoding/json"
 	"errors"
 	"testing"
+	"time"
 
 	"github.com/bradleyjkemp/cupaloy"
 	"github.com/stretchr/testify/assert"
 )
 
-func TestMarshalJSONPageResult(t *testing.T) {
-	bs, err := json.Marshal(newJSONPageResult(
+func TestMarshalErrorJSONPageResult(t *testing.T) {
+	d, _ := time.ParseDuration("1s")
+	bs, err := json.Marshal(newJSONErrorPageResult(
 		&pageResult{
 			"http://foo.com",
-			[]*successLinkResult{
-				{"http://foo.com/foo", 200},
-			},
+			[]*successLinkResult{},
 			[]*errorLinkResult{
-				{"http://foo.com/bar", errors.New("baz")},
+				{"http://foo.com/bar", errors.New("baz"), d},
+			},
+			d,
+		}))
+	assert.Nil(t, err)
+	cupaloy.SnapshotT(t, bs)
+}
+
+func TestMarshalSuccessJSONPageResult(t *testing.T) {
+	d, _ := time.ParseDuration("1s")
+	bs, err := json.Marshal(newJSONSuccessPageResult(
+		&pageResult{
+			"http://foo.com",
+			[]*successLinkResult{
+				{"http://foo.com/foo", 200, d},
 			},
+			[]*errorLinkResult{},
+			d,
 		}))
 	assert.Nil(t, err)
 	cupaloy.SnapshotT(t, bs)
diff --git a/page_result_formatter_test.go b/page_result_formatter_test.go
index c1f2121..6d7a322 100644
--- a/page_result_formatter_test.go
+++ b/page_result_formatter_test.go
@@ -3,103 +3,117 @@ package main
 import (
 	"errors"
 	"testing"
+	"time"
 
 	"github.com/bradleyjkemp/cupaloy"
 )
 
 func TestPageResultFormatterFormatEmptyResult(t *testing.T) {
+	d, _ := time.ParseDuration("1s")
 	cupaloy.SnapshotT(t,
 		newPageResultFormatter(false, true).Format(
-			&pageResult{"http://foo.com", nil, nil},
+			&pageResult{"http://foo.com", nil, nil, d},
 		),
 	)
 }
 
 func TestPageResultFormatterFormatSuccessLinkResults(t *testing.T) {
+	d, _ := time.ParseDuration("1s")
 	cupaloy.SnapshotT(t,
 		newPageResultFormatter(false, true).Format(
 			&pageResult{
 				"http://foo.com",
 				[]*successLinkResult{
-					{"http://foo.com", 200},
+					{"http://foo.com", 200, d},
 				},
 				nil,
+				d,
 			},
 		),
 	)
 }
 
 func TestPageResultFormatterFormatErrorLinkResults(t *testing.T) {
+	d, _ := time.ParseDuration("1s")
 	cupaloy.SnapshotT(t,
 		newPageResultFormatter(false, true).Format(
 			&pageResult{
 				"http://foo.com",
 				[]*successLinkResult{
-					{"http://foo.com", 200},
+					{"http://foo.com", 200, d},
 				},
 				[]*errorLinkResult{
-					{"http://foo.com", errors.New("500")},
+					{"http://foo.com", errors.New("500"), d},
 				},
+				d,
 			},
 		),
 	)
 }
 
 func TestPageResultFormatterFormatSuccessLinkResultsVerbosely(t *testing.T) {
+	d, _ := time.ParseDuration("1s")
 	cupaloy.SnapshotT(t,
 		newPageResultFormatter(true, true).Format(
 			&pageResult{
 				"http://foo.com",
 				[]*successLinkResult{
-					{"http://foo.com", 200},
+					{"http://foo.com", 200, d},
 				},
 				nil,
+				d,
 			},
 		),
 	)
 }
 
 func TestPageResultFormatterFormatErrorLinkResultsVerbosely(t *testing.T) {
+	d, _ := time.ParseDuration("1s")
 	cupaloy.SnapshotT(t,
 		newPageResultFormatter(true, true).Format(
 			&pageResult{
 				"http://foo.com",
 				[]*successLinkResult{
-					{"http://foo.com", 200},
+					{"http://foo.com", 200, d},
 				},
 				[]*errorLinkResult{
-					{"http://foo.com", errors.New("500")},
+					{"http://foo.com", errors.New("500"), d},
 				},
+				d,
 			},
 		),
 	)
 }
 
 func TestPageResultFormatterSortSuccessLinkResults(t *testing.T) {
+	d, _ := time.ParseDuration("1s")
 	cupaloy.SnapshotT(t,
 		newPageResultFormatter(true, true).Format(
 			&pageResult{
 				"http://foo.com",
 				[]*successLinkResult{
-					{"http://foo.com", 200},
-					{"http://bar.com", 200},
+					{"http://foo.com", 200, d},
+					{"http://bar.com", 200, d},
 				},
 				nil,
+				d,
 			},
 		),
 	)
 }
 
 func TestPageResultFormatterSortErrorLinkResults(t *testing.T) {
+	d, _ := time.ParseDuration("1s")
 	cupaloy.SnapshotT(t,
 		newPageResultFormatter(false, true).Format(
 			&pageResult{
 				"http://foo.com",
 				nil,
 				[]*errorLinkResult{
-					{"http://foo.com", errors.New("500")},
-					{"http://bar.com", errors.New("500")},
+					{"http://foo.com", errors.New("500"), d},
+					{"http://bar.com", errors.New("500"), d},
 				},
+				d,
 			},
 		),
 	)
diff --git a/page_result_test.go b/page_result_test.go
index 8767a02..09b2fa7 100644
--- a/page_result_test.go
+++ b/page_result_test.go
@@ -2,11 +2,13 @@ package main
 
 import (
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
 )
 
 func TestPageResultOK(t *testing.T) {
-	assert.True(t, (&pageResult{"", nil, nil}).OK())
-	assert.False(t, (&pageResult{"", nil, []*errorLinkResult{{}}}).OK())
+	d, _ := time.ParseDuration("1s")
+	assert.True(t, (&pageResult{"", nil, nil, d}).OK())
+	assert.False(t, (&pageResult{"", nil, []*errorLinkResult{{}}, d}).OK())
 }