blob: a6f8e489763cb6256380d2d1fd182e59b00074f7 [file]
// Copyright 2026 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 frontend
import (
"context"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/godoc"
"golang.org/x/pkgsite/internal/licenses"
"golang.org/x/pkgsite/internal/testing/fakedatasource"
"golang.org/x/pkgsite/internal/testing/sample"
"golang.org/x/tools/txtar"
)
func TestFetchEvalsDetails(t *testing.T) {
ctx := context.Background()
mit := &licenses.Metadata{Types: []string{"MIT"}, FilePath: "LICENSE"}
mitLicense := &licenses.License{
Metadata: mit,
Contents: []byte("MIT License"),
}
tests := []struct {
name string
version string
isRedistributable bool
licenses []*licenses.License
want []eval
}{
{
name: "no license, untagged pseudo version",
version: "v0.0.0-20260101120000-abcdef123456",
isRedistributable: false,
licenses: nil,
want: []eval{
{
Type: licenseEval,
Score: 0,
Value: "no license",
},
{
Type: moduleVersionEval,
Score: 0,
Value: "untagged",
},
},
},
{
name: "no license, prerelease",
version: "v1.2.3-alpha",
isRedistributable: false,
licenses: nil,
want: []eval{
{
Type: licenseEval,
Score: 0,
Value: "no license",
},
{
Type: moduleVersionEval,
Score: 1,
Value: "tagged, unstable",
},
},
},
{
name: "non-redistributable license, unstable v0 tagged version",
version: "v0.1.0",
isRedistributable: false,
licenses: []*licenses.License{mitLicense},
want: []eval{
{
Type: licenseEval,
Score: 1,
Value: "non-redistributable license",
},
{
Type: moduleVersionEval,
Score: 1,
Value: "tagged, unstable",
},
},
},
{
name: "redistributable license, stable v1 tagged version",
version: "v1.2.3",
isRedistributable: true,
licenses: []*licenses.License{mitLicense},
want: []eval{
{
Type: licenseEval,
Score: 2,
Value: "redistributable license",
},
{
Type: moduleVersionEval,
Score: 2,
Value: "tagged, stable",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fds := fakedatasource.New()
mod := sample.Module(sample.ModulePath, tt.version, sample.Suffix)
mod.IsRedistributable = tt.isRedistributable
mod.Licenses = tt.licenses
for _, u := range mod.Units {
u.IsRedistributable = tt.isRedistributable
u.LicenseContents = tt.licenses
}
fds.MustInsertModule(t, mod)
um := &internal.UnitMeta{
Path: sample.ModulePath + "/" + sample.Suffix,
ModuleInfo: mod.ModuleInfo,
}
got, err := fetchEvalsDetails(ctx, fds, um)
if err != nil {
t.Fatalf("fetchEvalsDetails() error = %v", err)
}
if diff := cmp.Diff(tt.want, got.Evals, cmp.AllowUnexported(eval{}, evalType{})); diff != "" {
t.Errorf("fetchEvalsDetails() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestAgeString(t *testing.T) {
testCases := []struct {
d time.Duration
want string
}{
{0, "less than a day"},
{12 * time.Hour, "less than a day"},
{23*time.Hour + 59*time.Minute, "less than a day"},
{day, "1 day"},
{2 * day, "2 days"},
{23 * day, "23 days"},
{30 * day, "30 days"},
{31 * day, "1 month"},
{31*day + 1*day, "1 month, 1 day"},
{31*day + 5*day, "1 month, 5 days"},
{2 * month, "2 months"},
{2*month + 1*day, "2 months, 1 day"},
{2*month + 3*day, "2 months, 3 days"},
{11*month + 23*day, "11 months, 23 days"},
{year, "1 year"},
{year + 1*day, "1 year"}, // 1 day is 0 months remainder
{year + 1*month, "1 year, 1 month"},
{year + 2*month + 5*day, "1 year, 2 months"},
{2 * year, "2 years"},
{2*year + 5*month, "2 years, 5 months"},
{2*year + 1*month + 30*day, "2 years, 1 month"},
}
for _, tc := range testCases {
t.Run(tc.want, func(t *testing.T) {
got := ageString(tc.d)
if got != tc.want {
t.Errorf("ageString(%v) = %q, want %q", tc.d, got, tc.want)
}
})
}
}
func TestSummarizeDocumentation(t *testing.T) {
testCases := []struct {
name string
// The files of the package. If nil, read the txtar from from testdata whose name
// is the test case name, replacing spaces with underscores.
files map[string]string
want docSummary
}{
{
name: "empty",
files: map[string]string{},
want: docSummary{},
},
{
name: "only test files ignored",
files: map[string]string{
"foo_test.go": `package foo
// TestSomething tests something.
func TestSomething(t *testing.T) {}`,
},
want: docSummary{},
},
{
name: "main package ignored",
files: map[string]string{
"main.go": `package main
// Package main is executable.
func main() {}`,
},
want: docSummary{},
},
{
name: "package does not have doc",
files: map[string]string{
"doc.go": `package mypkg`,
},
want: docSummary{
packageHasDoc: false,
},
},
{
name: "mixed doc and undoc",
files: nil,
want: docSummary{
packageHasDoc: true,
numExportedSymbols: 8,
numHaveDoc: 3,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
files := tc.files
if files == nil {
files = readTxtar(t, strings.ReplaceAll(tc.name, " ", "_"))
}
pkg := parseTestPackage(t, files)
got := summarizeDocumentation(pkg)
if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(docSummary{})); diff != "" {
t.Errorf("summarizeDocumentation() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestCollectSymbols(t *testing.T) {
testCases := []struct {
name string // txtar filename
want map[string]bool // exported symbol -> has doc
}{
{
name: "exported and unexported",
want: map[string]bool{
"ExportedFunc": true,
"ExportedType": true,
"(ExportedType).ExportedMethod": true,
"(ExportedType).UndocumentedMethod": false,
"ExportedConst": true,
"UndocumentedConst": false,
"SingleGroupedType": true,
"ExportedVar": true,
"UndocumentedVar": true,
"NoDocVar": false,
},
},
{
name: "mixed doc and undoc",
want: map[string]bool{
"ExportedWithDoc": true,
"ExportedNoDoc": false,
"ExportedType": true,
"(ExportedType).UndocumentedMethod": false,
"Config": false,
"DefaultTimeout": false,
"Helper": true,
"(Config).Less": false,
},
},
{
name: "const and var groups",
want: map[string]bool{
"U1": false,
"U2": false,
"D1": true,
"D2": true,
"Vu1": false,
"Vu2": false,
"Vd1": true,
"Vd2": true,
"Vs1": true,
"Vs2": true,
"Vs3": true,
},
},
{
name: "collect interface methods",
want: map[string]bool{
"TopLevelFunc": true,
"TopLevelVar": true,
"EmptyInterface": true,
"ExportedInterface": true,
"MyConcreteType": true,
"(MyConcreteType).NoDocMethod": true,
"(MyConcreteType).NoDocMethod2": false,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
pkg := parseTestPackage(t, readTxtar(t, strings.ReplaceAll(tc.name, " ", "_")))
var files []*ast.File
for _, f := range pkg.Files {
files = append(files, f.AST)
}
got := make(map[string]bool)
collectSymbols(files, func(name string, has bool) {
got[name] = has
})
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("collectSymbols() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestCollectSymbolsDeprecatedPackage(t *testing.T) {
testCases := []struct {
name string
src string
want map[string]bool
}{
{
name: "deprecated package",
src: `// Deprecated: use another package.
package foo
// Foo does something.
func Foo() {}
`,
want: map[string]bool{},
},
{
name: "deprecated not at start of paragraph",
src: `// Package foo is active.
// Deprecated: this is not a new paragraph.
package foo
// Foo does something.
func Foo() {}
`,
want: map[string]bool{"Foo": true},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "foo.go", tc.src, parser.ParseComments)
if err != nil {
t.Fatalf("parser.ParseFile: %v", err)
}
got := make(map[string]bool)
collectSymbols([]*ast.File{f}, func(name string, has bool) {
got[name] = has
})
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("collectSymbols() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestSigString(t *testing.T) {
testCases := []struct {
src string
want string
}{
{"package p; func Foo(int, int) bool", "(int, int) bool"},
{"package p; func Foo(x, y int) (z bool)", "(int, int) bool"},
{"package p; func Foo()", "()"},
{"package p; func Foo(a string, b, c int, d ...byte) (error, int)", "(string, int, int, ...byte) (error, int)"},
{"package p; func Foo() (x, y int)", "() (int, int)"},
{"package p; func Foo() (bool)", "() bool"},
}
for _, tc := range testCases {
t.Run(tc.src, func(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "p.go", tc.src, 0)
if err != nil {
t.Fatalf("parser.ParseFile(%q): %v", tc.src, err)
}
fd := f.Decls[0].(*ast.FuncDecl)
got := sigString(fd.Type)
if got != tc.want {
t.Errorf("sigString(%q) = %q, want %q", tc.src, got, tc.want)
}
})
}
}
func TestRecvTypeName(t *testing.T) {
testCases := []struct {
name string
src string
want string
}{
{
name: "value receiver",
src: "package p; func (t MyType) M() {}",
want: "MyType",
},
{
name: "pointer receiver",
src: "package p; func (t *MyType) M() {}",
want: "MyType",
},
{
name: "generic value receiver with single type parameter",
src: "package p; func (t MyType[P]) M() {}",
want: "MyType",
},
{
name: "generic pointer receiver with single type parameter",
src: "package p; func (t *MyType[P]) M() {}",
want: "MyType",
},
{
name: "generic value receiver with multiple type parameters",
src: "package p; func (t MyType[P1, P2]) M() {}",
want: "MyType",
},
{
name: "generic pointer receiver with multiple type parameters",
src: "package p; func (t *MyType[P1, P2]) M() {}",
want: "MyType",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "p.go", tc.src, 0)
if err != nil {
t.Fatalf("parser.ParseFile: %v", err)
}
decl := f.Decls[0].(*ast.FuncDecl)
got := recvTypeName(decl.Recv)
if got != tc.want {
t.Errorf("recvTypeName(%s) = %q, want %q", tc.src, got, tc.want)
}
})
}
}
func TestCollectInterfaceMethods(t *testing.T) {
pkg := parseTestPackage(t, readTxtar(t, "collect_interface_methods"))
var files []*ast.File
for _, f := range pkg.Files {
files = append(files, f.AST)
}
got := collectInterfaceMethods(files)
want := map[interfaceMethod]bool{
{"DocumentedMethod", "(int) string"}: true,
{"UndocumentedMethod", "() error"}: true,
{"MethodInUnexportedInterface2", "(int) bool"}: true,
}
if diff := cmp.Diff(want, got, cmp.AllowUnexported(interfaceMethod{})); diff != "" {
t.Errorf("collectInterfaceMethods() mismatch (-want +got):\n%s", diff)
}
}
// readTxtar reads a txtar file into a map from internal txtar filename to contents.
// It assumes the file lives in testdata and has a ".txtar" extension.
func readTxtar(t *testing.T, txtarName string) map[string]string {
filename := filepath.Join("testdata", txtarName+".txtar")
data, err := os.ReadFile(filename)
if err != nil {
t.Fatalf("os.ReadFile(%q): %v", filename, err)
}
archive := txtar.Parse(data)
files := make(map[string]string)
for _, f := range archive.Files {
files[f.Name] = string(f.Data)
}
return files
}
// parseTestPackage creates a godoc.Package with the given files. It removes the
// bodies of functions, encodes and then decodes the package, to simulate what the
// frontend actually observes. It returns the decoded package.
func parseTestPackage(t *testing.T, files map[string]string) *godoc.Package {
if len(files) == 0 {
return nil
}
t.Helper()
fset := token.NewFileSet()
docPkg := godoc.NewPackage(fset, nil)
for name, src := range files {
f, err := parser.ParseFile(fset, name, src, parser.ParseComments)
if err != nil {
t.Fatalf("parser.ParseFile(%q): %v", name, err)
}
docPkg.AddFile(f, true)
}
bytes, err := docPkg.Encode(context.Background())
if err != nil {
t.Fatalf("docPkg.Encode: %v", err)
}
decodedPkg, err := godoc.DecodePackage(bytes)
if err != nil {
t.Fatalf("godoc.DecodePackage: %v", err)
}
return decodedPkg
}