Files
narratio/internal/doccheck/doccheck_test.go

256 lines
7.5 KiB
Go

package doccheck
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
var markdownLinkPattern = regexp.MustCompile(`\[[^\]]+\]\(([^)]+)\)`)
func TestDocumentationLinksResolve(t *testing.T) {
root := repositoryRoot(t)
for _, path := range []string{
filepath.Join(root, "README.md"),
filepath.Join(root, "docs"),
filepath.Join(root, "examples"),
} {
walkDocumentationLinks(t, root, path)
}
}
func TestWoodpeckerWorkflowDependencies(t *testing.T) {
root := repositoryRoot(t)
paths, err := filepath.Glob(filepath.Join(root, ".woodpecker", "*.yml"))
if err != nil {
t.Fatalf("list Woodpecker workflows: %v", err)
}
if len(paths) == 0 {
t.Fatal("no Woodpecker workflows found")
}
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %q: %v", path, err)
}
var workflow woodpeckerWorkflow
if err := yaml.Unmarshal(data, &workflow); err != nil {
t.Fatalf("parse %q: %v", path, err)
}
if len(workflow.Steps) == 0 {
t.Fatalf("workflow %q has no steps", path)
}
validateWorkflowDependencies(t, path, workflow.Steps)
}
}
func TestReleaseWorkflowRequiresValidation(t *testing.T) {
root := repositoryRoot(t)
path := filepath.Join(root, ".woodpecker", "release.yml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read release workflow: %v", err)
}
var workflow woodpeckerWorkflow
if err := yaml.Unmarshal(data, &workflow); err != nil {
t.Fatalf("parse release workflow: %v", err)
}
validate, ok := workflow.Steps["validate-release"]
if !ok {
t.Fatal("release workflow must define validate-release")
}
if validate.Image != "golang:1.25.5" || !containsWorkflowCommand(validate.Commands, `./scripts/check-release-candidate.sh "$CI_COMMIT_TAG"`) {
t.Fatalf("validate-release = %#v, want the shared candidate checker in golang:1.25.5", validate)
}
assets, ok := workflow.Steps["build-release-assets"]
if !ok {
t.Fatal("release workflow must define build-release-assets")
}
if assets.Image != "golang:1.25.5" || !workflowDependsOn(workflow.Steps, "build-release-assets", "validate-release", map[string]bool{}) {
t.Fatalf("build-release-assets = %#v, want validated golang:1.25.5 asset construction", assets)
}
assetCommands := strings.Join(assets.Commands, "\n")
if !strings.Contains(assetCommands, `./scripts/build-release-assets.sh "$CI_COMMIT_TAG" "$PWD/dist"`) || strings.Contains(assetCommands, "GOOS=") || strings.Contains(assetCommands, "go build") {
t.Fatalf("build-release-assets commands must delegate target construction: %q", assetCommands)
}
publish, ok := workflow.Steps["publish-release"]
if !ok {
t.Fatal("release workflow must define publish-release")
}
if publish.Image != "woodpeckerci/plugin-release:0.3.1" || !workflowDependsOn(workflow.Steps, "publish-release", "validate-release", map[string]bool{}) || !workflowDependsOn(workflow.Steps, "publish-release", "build-release-assets", map[string]bool{}) {
t.Fatalf("publish-release = %#v, want transitive validation and asset dependencies", publish)
}
for key, want := range map[string]any{
"title": "Narratio ${CI_COMMIT_TAG}",
"note": "docs/releases/${CI_COMMIT_TAG}.md",
"checksum": "sha256",
"checksum-file": "SHA256SUMS",
"checksum-flatten": true,
"file-exists": "skip",
"overwrite": false,
"prerelease": false,
} {
if got := publish.Settings[key]; got != want {
t.Fatalf("publish-release setting %q = %#v, want %#v", key, got, want)
}
}
files, ok := publish.Settings["files"].([]any)
if !ok || len(files) != 1 || files[0] != "dist/narratio-*" {
t.Fatalf("publish-release files = %#v", publish.Settings["files"])
}
}
func containsWorkflowCommand(commands []string, want string) bool {
for _, command := range commands {
if strings.Contains(command, want) {
return true
}
}
return false
}
func repositoryRoot(t *testing.T) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
return root
}
func walkDocumentationLinks(t *testing.T, root, directory string) {
t.Helper()
if err := filepath.WalkDir(directory, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || filepath.Ext(path) != ".md" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(data), -1) {
target := strings.TrimSpace(match[1])
if !isLocalDocumentationTarget(target) {
continue
}
target = strings.Trim(strings.SplitN(strings.SplitN(target, "#", 2)[0], "?", 2)[0], "<>")
if target == "" {
continue
}
resolved := filepath.Clean(filepath.Join(filepath.Dir(path), target))
if _, err := os.Stat(resolved); err != nil {
t.Errorf("%s links to missing local target %q (%s): %v", relativeToRoot(root, path), target, relativeToRoot(root, resolved), err)
}
}
return nil
}); err != nil {
t.Fatalf("walk documentation in %q: %v", directory, err)
}
}
func isLocalDocumentationTarget(target string) bool {
target = strings.TrimSpace(target)
return target != "" &&
!strings.HasPrefix(target, "#") &&
!strings.HasPrefix(target, "/") &&
!strings.Contains(target, "://") &&
!strings.HasPrefix(target, "mailto:")
}
func relativeToRoot(root, path string) string {
relative, err := filepath.Rel(root, path)
if err != nil {
return path
}
return relative
}
type woodpeckerWorkflow struct {
Steps map[string]woodpeckerStep `yaml:"steps"`
}
type woodpeckerStep struct {
DependsOn woodpeckerDependencies `yaml:"depends_on"`
Image string `yaml:"image"`
Commands []string `yaml:"commands"`
Settings map[string]any `yaml:"settings"`
}
type woodpeckerDependencies []string
func (d *woodpeckerDependencies) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind {
case yaml.ScalarNode:
*d = []string{value.Value}
return nil
case yaml.SequenceNode:
dependencies := make([]string, 0, len(value.Content))
for _, item := range value.Content {
if item.Kind != yaml.ScalarNode {
return fmt.Errorf("dependency must be a step name")
}
dependencies = append(dependencies, item.Value)
}
*d = dependencies
return nil
default:
return fmt.Errorf("dependency must be a string or list of strings")
}
}
func validateWorkflowDependencies(t *testing.T, path string, steps map[string]woodpeckerStep) {
t.Helper()
for name, step := range steps {
for _, dependency := range step.DependsOn {
if _, ok := steps[dependency]; !ok {
t.Errorf("workflow %q step %q depends on undefined step %q", path, name, dependency)
}
}
}
visiting := map[string]bool{}
visited := map[string]bool{}
var visit func(string)
visit = func(name string) {
if visiting[name] {
t.Errorf("workflow %q has a dependency cycle at step %q", path, name)
return
}
if visited[name] {
return
}
visiting[name] = true
for _, dependency := range steps[name].DependsOn {
if _, ok := steps[dependency]; ok {
visit(dependency)
}
}
visiting[name] = false
visited[name] = true
}
for name := range steps {
visit(name)
}
}
func workflowDependsOn(steps map[string]woodpeckerStep, stepName, dependencyName string, visited map[string]bool) bool {
if visited[stepName] {
return false
}
visited[stepName] = true
for _, dependency := range steps[stepName].DependsOn {
if dependency == dependencyName || workflowDependsOn(steps, dependency, dependencyName, visited) {
return true
}
}
return false
}