blob: dead71a82ce301618c692ee1ee02bfbbcd81e446 [file] [log] [blame]
// Copyright 2019 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package dochtml
import (
"bufio"
"bytes"
"context"
"flag"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/ioutil"
"path/filepath"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/safehtml/template"
"github.com/jba/templatecheck"
"golang.org/x/net/html"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/experiment"
"golang.org/x/pkgsite/internal/godoc/dochtml/internal/render"
"golang.org/x/pkgsite/internal/godoc/internal/doc"
"golang.org/x/pkgsite/internal/testing/testhelper"
)
var templateSource = template.TrustedSourceFromConstant("../../../static/doc")
var update = flag.Bool("update", false, "update goldens instead of checking against them")
var testRenderOptions = RenderOptions{
FileLinkFunc: func(string) string { return "file" },
SourceLinkFunc: func(ast.Node) string { return "src" },
SinceVersionFunc: func(string) string { return "" },
}
func TestCheckTemplates(t *testing.T) {
LoadTemplates(templateSource)
for _, tm := range []*template.Template{bodyTemplate, outlineTemplate, sidenavTemplate} {
if err := templatecheck.CheckSafe(tm, templateData{}); err != nil {
t.Fatal(err)
}
}
}
func TestRender(t *testing.T) {
ctx := context.Background()
LoadTemplates(templateSource)
fset, d := mustLoadPackage("everydecl")
parts, err := Render(ctx, fset, d, testRenderOptions)
if err != nil {
t.Fatal(err)
}
compareWithGolden(t, parts, "everydecl", *update)
bodyDoc, err := html.Parse(strings.NewReader(parts.Body.String()))
if err != nil {
t.Fatal(err)
}
// Check that there are no duplicate id attributes.
t.Run("duplicate ids", func(t *testing.T) {
testDuplicateIDs(t, bodyDoc)
})
t.Run("ids-and-kinds", func(t *testing.T) {
// Check that the id and data-kind labels are right.
testIDsAndKinds(t, bodyDoc)
})
wantLinks := []render.Link{
{Href: "https://go.googlesource.com/pkgsite", Text: "pkgsite repo"},
{Href: "https://play-with-go.dev", Text: "Play with Go"},
}
if diff := cmp.Diff(wantLinks, parts.Links); diff != "" {
t.Errorf("links mismatch (-want, +got):\n%s", diff)
}
}
func TestRenderDeprecated(t *testing.T) {
compare := func(ctx context.Context, name string) {
t.Helper()
fset, d := mustLoadPackage("deprecated")
parts, err := Render(ctx, fset, d, testRenderOptions)
if err != nil {
t.Fatal(err)
}
compareWithGolden(t, parts, name, *update)
}
compare(context.Background(), "deprecated-off")
compare(experiment.NewContext(context.Background(), internal.ExperimentDeprecatedDoc), "deprecated-on")
}
func compareWithGolden(t *testing.T, parts *Parts, name string, update bool) {
got := fmt.Sprintf("%s\n----\n%s\n----\n%s\n", parts.Body, parts.Outline, parts.MobileOutline)
// Remove blank lines and whitespace around lines.
var b strings.Builder
s := bufio.NewScanner(strings.NewReader(got))
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line != "" {
fmt.Fprintln(&b, line)
}
}
got = b.String()
testhelper.CompareWithGolden(t, got, name+".golden", update)
}
func TestExampleRender(t *testing.T) {
LoadTemplates(templateSource)
ctx := context.Background()
fset, d := mustLoadPackage("example_test")
parts, err := Render(ctx, fset, d, testRenderOptions)
if err != nil {
t.Fatal(err)
}
htmlDoc, err := html.Parse(strings.NewReader(parts.Body.String()))
if err != nil {
t.Fatal(err)
}
got := make(map[string]string)
walk(htmlDoc, func(n *html.Node) {
if attr(n, "class") == "Documentation-exampleDetails js-exampleContainer" {
var b bytes.Buffer
err := html.Render(&b, n)
if err != nil {
t.Fatal(err)
}
got[attr(n, "id")] = b.String()
}
})
for _, test := range []struct {
name string
htmlID string
want string
}{
{
name: "Executable examples (with play buttons)",
htmlID: "example-package-StringsCompare",
want: `<details tabindex="-1" id="example-package-StringsCompare" class="Documentation-exampleDetails js-exampleContainer">
<summary class="Documentation-exampleDetailsHeader">Example (StringsCompare) <a href="#example-package-StringsCompare">ΒΆ</a></summary>
<div class="Documentation-exampleDetailsBody">
<p>executable example
</p>
<pre class="Documentation-exampleCode">package main
import (
&#34;fmt&#34;
&#34;strings&#34;
)
func main() {
// example comment
fmt.Println(strings.Compare(&#34;a&#34;, &#34;b&#34;))
fmt.Println(strings.Compare(&#34;a&#34;, &#34;a&#34;))
fmt.Println(strings.Compare(&#34;b&#34;, &#34;a&#34;))
}
</pre>
<pre><span class="Documentation-exampleOutputLabel">Output:</span>
<span class="Documentation-exampleOutput">-1
0
1
</span></pre>
</div>
<div class="Documentation-exampleButtonsContainer">
<p class="Documentation-exampleError" role="alert" aria-atomic="true"></p>
<button class="Documentation-exampleShareButton" aria-label="Share Code">Share</button>
<button class="Documentation-exampleFormatButton" aria-label="Format Code">Format</button>
<button class="Documentation-exampleRunButton" aria-label="Run Code">Run</button>
</div></details>`,
},
} {
t.Run(test.name, func(t *testing.T) {
diff := cmp.Diff(test.want, got[test.htmlID])
if diff != "" {
t.Errorf("mismatch (-want, +got):\n%s", diff)
}
})
}
}
func TestLinkHTML(t *testing.T) {
for _, test := range []struct {
name string
in string
link string
want string
}{
{
name: "regular string and link are rendered",
in: `escape.go`,
link: `https://golang.org/src/html/escape.go`,
want: `<a class="class" href="https://golang.org/src/html/escape.go">escape.go</a>`,
},
{
name: "name is escaped",
in: `"File & name" <'file@name.com>`,
link: "",
want: `&#34;File &amp; name&#34; &lt;&#39;file@name.com&gt;`,
},
{
name: "link is escaped",
in: "file.go",
link: `"abc@go's.com"`,
want: `<a class="class" href="%22abc@go%27s.com%22">file.go</a>`,
},
{
name: "file name and link are escaped",
in: `"a's.com@/`,
link: `"x@go's.com"`,
want: `<a class="class" href="%22x@go%27s.com%22">&#34;a&#39;s.com@/</a>`,
},
{
name: "HTML injection escaped",
in: `<a href="gfr.con"></a>`,
link: `"><script>bad</script>`,
want: `<a class="class" href="%22%3e%3cscript%3ebad%3c/script%3e">&lt;a href=&#34;gfr.con&#34;&gt;&lt;/a&gt;</a>`,
},
} {
t.Run(test.name, func(t *testing.T) {
got := linkHTML(test.in, test.link, "class")
diff := cmp.Diff(test.want, got.String())
if diff != "" {
t.Errorf("mismatch (-want, +got):\n%s", diff)
}
})
}
}
func TestVersionedPkgPath(t *testing.T) {
for _, test := range []struct {
name string
pkgPath string
modInfo *ModuleInfo
want string
}{
{
name: "builtin package is not versioned",
pkgPath: "builtin",
modInfo: &ModuleInfo{
ModulePath: "std",
ResolvedVersion: "v1.14.4",
ModulePackages: map[string]bool{"std/builtin": true, "std/net/http": true},
},
want: "builtin",
},
{
name: "std packages are not versioned",
pkgPath: "net/http",
modInfo: &ModuleInfo{
ModulePath: "std",
ResolvedVersion: "v1.14.4",
ModulePackages: map[string]bool{"std/builtin": true, "std/net/http": true},
},
want: "net/http",
},
{
name: "imports from other modules are not versioned",
pkgPath: "golang.org/x/pkgsite",
modInfo: &ModuleInfo{
ModulePath: "cloud.google.com/go",
ResolvedVersion: "v0.60.0",
ModulePackages: map[string]bool{"cloud.google.com/go/civil": true},
},
want: "golang.org/x/pkgsite",
},
{
name: "imports from other modules with shared prefixes are not versioned",
pkgPath: "golang.org/x/pkgsite",
modInfo: &ModuleInfo{
ModulePath: "golang.org/x/time",
ResolvedVersion: "v1.2.3",
ModulePackages: map[string]bool{"golang.org/x/time/rate": true},
},
want: "golang.org/x/pkgsite",
},
{
name: "imports from same module are versioned",
pkgPath: "golang.org/x/pkgsite/internal/log",
modInfo: &ModuleInfo{
ModulePath: "golang.org/x/pkgsite",
ResolvedVersion: "v1.1.2",
ModulePackages: map[string]bool{"golang.org/x/pkgsite/internal/log": true},
},
want: "golang.org/x/pkgsite@v1.1.2/internal/log",
},
{
name: "imports from same module with pseudo version are versioned",
pkgPath: "golang.org/x/pkgsite/internal/log",
modInfo: &ModuleInfo{
ModulePath: "golang.org/x/pkgsite",
ResolvedVersion: "v0.0.0-20200709011933-a59b4ce778c4",
ModulePackages: map[string]bool{"golang.org/x/pkgsite/internal/log": true},
},
want: "golang.org/x/pkgsite@v0.0.0-20200709011933-a59b4ce778c4/internal/log",
},
{
name: "imports from same v2 module are versioned",
pkgPath: "k8s.io/klog/v2/klogr",
modInfo: &ModuleInfo{
ModulePath: "k8s.io/klog/v2",
ResolvedVersion: "v2.3.0",
ModulePackages: map[string]bool{"k8s.io/klog/v2": true, "k8s.io/klog/v2/klogr": true},
},
want: "k8s.io/klog/v2@v2.3.0/klogr",
},
{
name: "imports from older major module version are not versioned",
pkgPath: "rsc.io/quote",
modInfo: &ModuleInfo{
ModulePath: "rsc.io/quote/v3",
ResolvedVersion: "v3.1.0",
ModulePackages: map[string]bool{"rsc.io/quote/v3": true},
},
want: "rsc.io/quote",
},
{
name: "imports from newer major module version are not versioned",
pkgPath: "rsc.io/quote/v3",
modInfo: &ModuleInfo{
ModulePath: "rsc.io/quote",
ResolvedVersion: "v1.5.3",
ModulePackages: map[string]bool{"rsc.io/quote": true},
},
want: "rsc.io/quote/v3",
},
{
name: "imports from nested module are not versioned",
pkgPath: "A/B/C/D",
modInfo: &ModuleInfo{
ModulePath: "A",
ResolvedVersion: "v1.0.0",
ModulePackages: map[string]bool{"A/B": true, "A/B/C": true},
},
want: "A/B/C/D",
},
} {
t.Run(test.name, func(t *testing.T) {
got := versionedPkgPath(test.pkgPath, test.modInfo)
if got != test.want {
t.Errorf("versionedPkgPath(%q) = %q, want %q", test.pkgPath, got, test.want)
}
})
}
}
func testDuplicateIDs(t *testing.T, htmlDoc *html.Node) {
idCounts := map[string]int{}
walk(htmlDoc, func(n *html.Node) {
id := attr(n, "id")
if id != "" {
idCounts[id]++
}
})
var dups []string
for id, n := range idCounts {
if n > 1 {
dups = append(dups, id)
}
}
if len(dups) > 0 {
t.Errorf("duplicate ids: %v", dups)
}
}
func testIDsAndKinds(t *testing.T, htmlDoc *html.Node) {
type attrs struct {
ID, Kind string // export fields for cmp
}
// want is a complete list of id, kind pairs we expect to see the HTML.
want := []attrs{
{"C", "constant"},
{"CT", "constant"},
{"F", "function"},
{"TF", "function"},
{"T.M", "method"},
{"V", "variable"},
{"VT", "variable"},
{"T", "type"},
{"S1", "type"},
{"S1.F", "field"},
{"S2", "type"},
{"S2.S1", "field"},
{"S2.G", "field"},
{"I1", "type"},
{"I1.M1", "method"},
{"I2", "type"},
{"I2.M2", "method"},
{"A", "type"},
{"B", "type"},
}
var got []attrs
walk(htmlDoc, func(n *html.Node) {
if kind := attr(n, "data-kind"); kind != "" {
got = append(got, attrs{attr(n, "id"), kind})
}
})
diff := cmp.Diff(want, got, cmpopts.SortSlices(func(a1, a2 attrs) bool {
return a1.ID < a2.ID
}))
if diff != "" {
t.Errorf("mismatch (-want, +got):\n%s", diff)
}
}
func walk(n *html.Node, f func(*html.Node)) {
f(n)
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c, f)
}
}
func attr(n *html.Node, key string) string {
for _, a := range n.Attr {
if a.Key == key {
return a.Val
}
}
return ""
}
// Copied from internal/render/render_test.go, with the slight modification of returning the fset.
func mustLoadPackage(path string) (*token.FileSet, *doc.Package) {
srcName := filepath.Base(path) + ".go"
code, err := ioutil.ReadFile(filepath.Join("testdata", srcName))
if err != nil {
panic(err)
}
fset := token.NewFileSet()
astFile, _ := parser.ParseFile(fset, srcName, code, parser.ParseComments)
files := []*ast.File{astFile}
astPackage, err := doc.NewFromFiles(fset, files, path, doc.AllDecls)
if err != nil {
panic(err)
}
return fset, astPackage
}