303 lines
12 KiB
Go
303 lines
12 KiB
Go
package validation
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments(t *testing.T) {
|
|
guide := []byte("" +
|
|
"# Start\n" +
|
|
"[target](target.md?view=1#TARGET)\n" +
|
|
"[same](#START)\n" +
|
|
"[url heading](autolinks.md#httpsexampleinvalid)\n" +
|
|
"[email heading](autolinks.md#maintainerexampleinvalid)\n" +
|
|
"[top](#)\n" +
|
|
"[directory](assets/?view=1)\n" +
|
|
"[binary](image.bin#not-an-anchor)\n" +
|
|
"[encoded](docs/a%20b.md#CAF%C3%89)\n" +
|
|
"[query](target.md?view=%ZZ)\n" +
|
|
"[external](https://example.invalid/%ZZ#also%ZZ)\n" +
|
|
"[protocol-relative](//example.invalid/file.md)\n")
|
|
repository, tree := committedMarkdownTree(t, map[string][]byte{
|
|
"guide.md": guide,
|
|
"target.md": []byte("# Target\n"),
|
|
"autolinks.md": []byte("# <https://example.invalid>\n# <maintainer@example.invalid>\n"),
|
|
"docs/a b.md": []byte("# Café\n"),
|
|
"assets/file.md": []byte("# Asset\n"),
|
|
"nested/guide.md": []byte("# Nested\n[same](#NESTED)\n[query](?view=1#nested)\n"),
|
|
"image.bin": []byte{0, 1, 2},
|
|
})
|
|
|
|
if err := checkMarkdownLinks(context.Background(), tree, false, nil); err != nil {
|
|
t.Fatalf("checkMarkdownLinks() error = %v", err)
|
|
}
|
|
if got := readCommitted(t, tree, "guide.md"); !bytes.Equal(got, guide) {
|
|
t.Fatal("checker changed committed Markdown bytes")
|
|
}
|
|
if got, err := os.ReadFile(filepath.Join(repository, "guide.md")); err != nil || !bytes.Equal(got, guide) {
|
|
t.Fatalf("worktree Markdown changed: got %q, err %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestCheckMarkdownLinksTreatsImageLabelsAsAlternativeText(t *testing.T) {
|
|
_, tree := committedMarkdownTree(t, map[string][]byte{
|
|
"guide.md": []byte("" +
|
|
"](image.png)\n" +
|
|
"](image.png)\n" +
|
|
"](image.png)\n" +
|
|
"](image.png)\n" +
|
|
"[](target.md#present)\n" +
|
|
"[](absent.md)\n"),
|
|
"image.png": []byte("image"),
|
|
"target.md": []byte("# Present\n"),
|
|
})
|
|
for _, enforceBoundary := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("boundary=%t", enforceBoundary), func(t *testing.T) {
|
|
err := checkMarkdownLinks(context.Background(), tree, enforceBoundary, nil)
|
|
var failure *Failure
|
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 2 {
|
|
t.Fatalf("error = %v, want only the missing linked-image source and link target", err)
|
|
}
|
|
want := map[string]bool{"absent.png": true, "absent.md": true}
|
|
for _, finding := range failure.Findings {
|
|
if !want[finding.Target] {
|
|
t.Errorf("unexpected destination finding: %#v", finding)
|
|
}
|
|
delete(want, finding.Target)
|
|
}
|
|
if len(want) != 0 {
|
|
t.Errorf("missing findings for real destinations: %v", want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles(t *testing.T) {
|
|
_, tree := committedMarkdownTree(t, map[string][]byte{
|
|
"guide.md": []byte("" +
|
|
"[missing](absent.md)\n" +
|
|
"[anchor](target.md#missing)\n" +
|
|
"[cycle](cycle-a)\n"),
|
|
"target.md": []byte("# Present\n"),
|
|
})
|
|
repository := tree.repositoryDir
|
|
if err := os.Symlink("cycle-b", filepath.Join(repository, "cycle-a")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("cycle-a", filepath.Join(repository, "cycle-b")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gitTree(t, repository, "add", "cycle-a", "cycle-b")
|
|
gitTree(t, repository, "commit", "-m", "add markdown link cycle")
|
|
tree = candidateTreeAtHead(t, repository)
|
|
|
|
err := checkMarkdownLinks(context.Background(), tree, false, nil)
|
|
var failure *Failure
|
|
if !errors.As(err, &failure) {
|
|
t.Fatalf("checkMarkdownLinks() error = %v, want validation Failure", err)
|
|
}
|
|
if failure.Check != MarkdownLinks {
|
|
t.Fatalf("failure check = %q, want %q", failure.Check, MarkdownLinks)
|
|
}
|
|
if len(failure.Findings) != 3 {
|
|
t.Fatalf("findings = %#v, want missing path, anchor, and cycle", failure.Findings)
|
|
}
|
|
want := map[string]string{
|
|
"absent.md": "destination does not exist",
|
|
"target.md#missing": "heading anchor does not exist",
|
|
"cycle-a": "destination contains a symlink cycle",
|
|
}
|
|
for _, finding := range failure.Findings {
|
|
if finding.Path != "guide.md" || finding.Line < 1 || finding.Column < 1 {
|
|
t.Errorf("finding lacks source location: %#v", finding)
|
|
}
|
|
if want[finding.Target] != finding.Message {
|
|
t.Errorf("finding = %#v", finding)
|
|
}
|
|
delete(want, finding.Target)
|
|
}
|
|
if len(want) != 0 {
|
|
t.Fatalf("missing expected findings for %#v", want)
|
|
}
|
|
}
|
|
|
|
func TestCheckMarkdownLinksUsesResolvedMarkdownIdentity(t *testing.T) {
|
|
repository, _ := committedMarkdownTree(t, map[string][]byte{
|
|
"guide.md": []byte("[valid](alias.md#present)\n[missing](alias.md#missing)\n"),
|
|
"target.md": []byte("# Present\n"),
|
|
})
|
|
if err := os.Symlink("target.md", filepath.Join(repository, "alias.md")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gitTree(t, repository, "add", "alias.md")
|
|
gitTree(t, repository, "commit", "-m", "add Markdown alias")
|
|
tree := candidateTreeAtHead(t, repository)
|
|
|
|
err := checkMarkdownLinks(context.Background(), tree, false, nil)
|
|
var failure *Failure
|
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
|
|
t.Fatalf("resolved alias error = %v, want one missing-anchor finding", err)
|
|
}
|
|
if finding := failure.Findings[0]; finding.Path != "guide.md" || finding.Target != "alias.md#missing" || finding.Message != "heading anchor does not exist" {
|
|
t.Fatalf("resolved alias finding = %#v", finding)
|
|
}
|
|
}
|
|
|
|
func TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations(t *testing.T) {
|
|
repository, tree := committedMarkdownTree(t, map[string][]byte{
|
|
"guide.md": []byte("[outside](../../outside.md)\n[submodule](deps/module/README.md)\n"),
|
|
})
|
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
|
gitTree(t, repository, "update-index", "--add", "--cacheinfo", fmt.Sprintf("160000,%s,deps/module", commit))
|
|
gitTree(t, repository, "commit", "-m", "add submodule entry")
|
|
tree = candidateTreeAtHead(t, repository)
|
|
|
|
var observed bytes.Buffer
|
|
if err := checkMarkdownLinks(context.Background(), tree, false, &observed); err != nil {
|
|
t.Fatalf("checkMarkdownLinks() error = %v", err)
|
|
}
|
|
if got := observed.String(); !strings.Contains(got, "outside the checkout") || !strings.Contains(got, "uninspected submodule content") || strings.Count(got, "out of scope") != 2 {
|
|
t.Fatalf("out-of-scope observations = %q", got)
|
|
}
|
|
|
|
observed.Reset()
|
|
err := checkMarkdownLinks(context.Background(), tree, true, &observed)
|
|
var failure *Failure
|
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
|
|
t.Fatalf("enforced boundary error = %v, want one Markdown finding", err)
|
|
}
|
|
if finding := failure.Findings[0]; finding.Target != "../../outside.md" || finding.Message != "destination is outside the checkout" {
|
|
t.Fatalf("boundary finding = %#v", finding)
|
|
}
|
|
if got := observed.String(); !strings.Contains(got, "uninspected submodule content") || strings.Contains(got, "outside the checkout") {
|
|
t.Fatalf("enforced observations = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckMarkdownLinksPreservesResolverCauseAndCancellation(t *testing.T) {
|
|
repository, tree := committedMarkdownTree(t, map[string][]byte{
|
|
"guide.md": []byte("[target](broken.md)\n"),
|
|
})
|
|
if err := os.Symlink("target.md", filepath.Join(repository, "broken.md")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gitTree(t, repository, "add", "broken.md")
|
|
gitTree(t, repository, "commit", "-m", "add Markdown symlink")
|
|
tree = candidateTreeAtHead(t, repository)
|
|
index := tree.byPath["broken.md"]
|
|
tree.entries[index].ObjectID = strings.Repeat("0", 40)
|
|
|
|
err := checkMarkdownLinks(context.Background(), tree, false, nil)
|
|
var failure *Failure
|
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || failure.Cause == nil {
|
|
t.Fatalf("resolver inspection error = %v, want Markdown failure with cause", err)
|
|
}
|
|
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Line != 1 || failure.Findings[0].Target != "broken.md" {
|
|
t.Fatalf("resolver inspection finding = %#v", failure.Findings)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
err = checkMarkdownLinks(ctx, tree, false, nil)
|
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled Markdown check error = %v, want preserved cancellation", err)
|
|
}
|
|
}
|
|
|
|
func TestCheckMarkdownLinksPreservesAnchorReadCause(t *testing.T) {
|
|
_, tree := committedMarkdownTree(t, map[string][]byte{
|
|
"guide.md": []byte("[target](target.md#present)\n"),
|
|
"target.md": []byte("# Present\n"),
|
|
})
|
|
tree.entries[tree.byPath["target.md"]].ObjectID = strings.Repeat("0", 40)
|
|
|
|
err := checkMarkdownLinks(context.Background(), tree, false, nil)
|
|
var failure *Failure
|
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || failure.Cause == nil {
|
|
t.Fatalf("anchor inspection error = %v, want Markdown failure with cause", err)
|
|
}
|
|
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Target != "target.md#present" || failure.Findings[0].Message != "could not inspect destination anchor" {
|
|
t.Fatalf("anchor inspection finding = %#v", failure.Findings)
|
|
}
|
|
}
|
|
|
|
func TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks(t *testing.T) {
|
|
repository, _ := committedMarkdownTree(t, map[string][]byte{
|
|
"guide.md": []byte("[outside](../../outside.md)\n"),
|
|
})
|
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
|
goLog := installGoShim(t, "", false)
|
|
var output bytes.Buffer
|
|
err := Run(context.Background(), Request{
|
|
RepositoryDir: repository,
|
|
Commit: commit,
|
|
Output: &output,
|
|
Settings: Settings{
|
|
Race: true,
|
|
FullVet: true,
|
|
CompileTargets: []Target{{OS: "linux", Arch: "amd64"}},
|
|
Markdown: &MarkdownSettings{},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want out-of-scope observation to succeed", err)
|
|
}
|
|
if got := output.String(); !strings.Contains(got, "outside the checkout") || !strings.Contains(got, "out of scope") {
|
|
t.Fatalf("Run() output = %q, want outside-checkout observation", got)
|
|
}
|
|
commands := readGoShimLog(t, goLog)
|
|
var testCommands []string
|
|
var validationCommands []string
|
|
for _, command := range commands {
|
|
if strings.HasPrefix(command, "test ") {
|
|
testCommands = append(testCommands, command)
|
|
}
|
|
if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") || strings.HasPrefix(command, "build ") {
|
|
validationCommands = append(validationCommands, command)
|
|
}
|
|
}
|
|
if len(testCommands) != 1 || !strings.Contains(testCommands[0], "-race") || !strings.Contains(testCommands[0], "-vet=off") {
|
|
t.Fatalf("Go test commands = %#v, want one race-enabled suite with test-time vet disabled", testCommands)
|
|
}
|
|
if len(validationCommands) != 3 || !strings.HasPrefix(validationCommands[0], "test ") || !strings.HasPrefix(validationCommands[1], "vet ") || !strings.HasPrefix(validationCommands[2], "build ") {
|
|
t.Fatalf("Go validation commands = %#v, want test, full vet, and target compilation before Markdown", validationCommands)
|
|
}
|
|
}
|
|
|
|
func committedMarkdownTree(t *testing.T, files map[string][]byte) (string, *CandidateTree) {
|
|
t.Helper()
|
|
repository := newTreeRepository(t)
|
|
for name, content := range files {
|
|
writeTreeFile(t, repository, name, content)
|
|
}
|
|
gitTree(t, repository, "add", "--", ".")
|
|
gitTree(t, repository, "commit", "-m", "add Markdown fixture")
|
|
return repository, candidateTreeAtHead(t, repository)
|
|
}
|
|
|
|
func candidateTreeAtHead(t *testing.T, repository string) *CandidateTree {
|
|
t.Helper()
|
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tree
|
|
}
|
|
|
|
func readCommitted(t *testing.T, tree *CandidateTree, name string) []byte {
|
|
t.Helper()
|
|
contents, err := tree.ReadFile(context.Background(), name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return contents
|
|
}
|