package hugolib import ( "bytes" "context" "encoding/base64" "errors" "fmt" "io" "math/rand" "os" "path/filepath" "regexp" "runtime" "sort" "strings" "sync" "testing" "github.com/bep/logg" qt "github.com/frankban/quicktest" "github.com/fsnotify/fsnotify" "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/common/maps" "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/config/allconfig" "github.com/gohugoio/hugo/config/security" "github.com/gohugoio/hugo/deps" "github.com/gohugoio/hugo/helpers" "github.com/gohugoio/hugo/htesting" "github.com/gohugoio/hugo/hugofs" "github.com/spf13/afero" "github.com/spf13/cast" "golang.org/x/text/unicode/norm" "golang.org/x/tools/txtar" ) type TestOpt func(*IntegrationTestConfig) // TestOptRunning will enable running in integration tests. func TestOptRunning() TestOpt { return func(c *IntegrationTestConfig) { c.Running = true } } // TestOptWatching will enable watching in integration tests. func TestOptWatching() TestOpt { return func(c *IntegrationTestConfig) { c.Watching = true } } // Enable tracing in integration tests. // THis should only be used during development and not committed to the repo. func TestOptTrace() TestOpt { return func(c *IntegrationTestConfig) { c.LogLevel = logg.LevelTrace } } // TestOptDebug will enable debug logging in integration tests. func TestOptDebug() TestOpt { return func(c *IntegrationTestConfig) { c.LogLevel = logg.LevelDebug } } // TestOptWarn will enable warn logging in integration tests. func TestOptWarn() TestOpt { return func(c *IntegrationTestConfig) { c.LogLevel = logg.LevelWarn } } // TestOptOsFs will enable the real file system in integration tests. func TestOptOsFs() TestOpt { return func(c *IntegrationTestConfig) { c.NeedsOsFS = true } } // TestOptWithNFDOnDarwin will normalize the Unicode filenames to NFD on Darwin. func TestOptWithNFDOnDarwin() TestOpt { return func(c *IntegrationTestConfig) { c.NFDFormOnDarwin = true } } // TestOptWithWorkingDir allows setting any config optiona as a function al option. func TestOptWithConfig(fn func(c *IntegrationTestConfig)) TestOpt { return func(c *IntegrationTestConfig) { fn(c) } } // Test is a convenience method to create a new IntegrationTestBuilder from some files and run a build. func Test(t testing.TB, files string, opts ...TestOpt) *IntegrationTestBuilder { cfg := IntegrationTestConfig{T: t, TxtarString: files} for _, o := range opts { o(&cfg) } return NewIntegrationTestBuilder(cfg).Build() } // TestE is the same as Test, but returns an error instead of failing the test. func TestE(t testing.TB, files string, opts ...TestOpt) (*IntegrationTestBuilder, error) { cfg := IntegrationTestConfig{T: t, TxtarString: files} for _, o := range opts { o(&cfg) } return NewIntegrationTestBuilder(cfg).BuildE() } // TestRunning is a convenience method to create a new IntegrationTestBuilder from some files with Running set to true and run a build. // Deprecated: Use Test with TestOptRunning instead. func TestRunning(t testing.TB, files string, opts ...TestOpt) *IntegrationTestBuilder { cfg := IntegrationTestConfig{T: t, TxtarString: files, Running: true} for _, o := range opts { o(&cfg) } return NewIntegrationTestBuilder(cfg).Build() } // In most cases you should not use this function directly, but the Test or TestRunning function. func NewIntegrationTestBuilder(conf IntegrationTestConfig) *IntegrationTestBuilder { // Code fences. conf.TxtarString = strings.ReplaceAll(conf.TxtarString, "§§§", "```") // Multiline strings. conf.TxtarString = strings.ReplaceAll(conf.TxtarString, "§§", "`") data := txtar.Parse([]byte(conf.TxtarString)) if conf.NFDFormOnDarwin { for i, f := range data.Files { data.Files[i].Name = norm.NFD.String(f.Name) } } c, ok := conf.T.(*qt.C) if !ok { c = qt.New(conf.T) } if conf.NeedsOsFS { if !filepath.IsAbs(conf.WorkingDir) { tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test") c.Assert(err, qt.IsNil) conf.WorkingDir = filepath.Join(tempDir, conf.WorkingDir) if !conf.PrintAndKeepTempDir { c.Cleanup(clean) } else { fmt.Println("\nUsing WorkingDir dir:", conf.WorkingDir) } } } else if conf.WorkingDir == "" { conf.WorkingDir = helpers.FilePathSeparator } return &IntegrationTestBuilder{ Cfg: conf, C: c, data: data, } } // IntegrationTestBuilder is a (partial) rewrite of sitesBuilder. // The main problem with the "old" one was that it was that the test data was often a little hidden, // so it became hard to look at a test and determine what it should do, especially coming back to the // test after a year or so. type IntegrationTestBuilder struct { *qt.C data *txtar.Archive fs *hugofs.Fs H *HugoSites Cfg IntegrationTestConfig changedFiles []string createdFiles []string removedFiles []string renamedFiles []string renamedDirs []string buildCount int GCCount int counters *buildCounters logBuff lockingBuffer lastBuildLog string builderInit sync.Once } type lockingBuffer struct { sync.Mutex bytes.Buffer } func (b *lockingBuffer) ReadFrom(r io.Reader) (n int64, err error) { b.Lock() n, err = b.Buffer.ReadFrom(r) b.Unlock() return } func (b *lockingBuffer) Write(p []byte) (n int, err error) { b.Lock() n, err = b.Buffer.Write(p) b.Unlock() return } // AssertLogContains asserts that the last build log contains the given strings. // Each string can be negated with a "! " prefix. func (s *IntegrationTestBuilder) AssertLogContains(els ...string) { s.Helper() for _, el := range els { var negate bool el, negate = s.negate(el) check := qt.Contains if negate { check = qt.Not(qt.Contains) } s.Assert(s.lastBuildLog, check, el) } } // AssertLogNotContains asserts that the last build log does matches the given regular expressions. // The regular expressions can be negated with a "! " prefix. func (s *IntegrationTestBuilder) AssertLogMatches(expression string) { s.Helper() var negate bool expression, negate = s.negate(expression) re := regexp.MustCompile(expression) checker := qt.IsTrue if negate { checker = qt.IsFalse } s.Assert(re.MatchString(s.lastBuildLog), checker, qt.Commentf(s.lastBuildLog)) } func (s *IntegrationTestBuilder) AssertBuildCountData(count int) { s.Helper() s.Assert(s.H.init.data.InitCount(), qt.Equals, count) } func (s *IntegrationTestBuilder) AssertBuildCountGitInfo(count int) { s.Helper() s.Assert(s.H.init.gitInfo.InitCount(), qt.Equals, count) } func (s *IntegrationTestBuilder) AssertFileCount(dirname string, expected int) { s.Helper() fs := s.fs.WorkingDirReadOnly count := 0 afero.Walk(fs, dirname, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } count++ return nil }) s.Assert(count, qt.Equals, expected) } func (s *IntegrationTestBuilder) negate(match string) (string, bool) { var negate bool if strings.HasPrefix(match, "! ") { negate = true match = strings.TrimPrefix(match, "! ") } return match, negate } func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...string) { s.Helper() content := strings.TrimSpace(s.FileContent(filename)) for _, m := range matches { cm := qt.Commentf("File: %s Match %s", filename, m) lines := strings.Split(m, "\n") for _, match := range lines { match = strings.TrimSpace(match) if match == "" || strings.HasPrefix(match, "#") { continue } var negate bool match, negate = s.negate(match) if negate { s.Assert(content, qt.Not(qt.Contains), match, cm) continue } s.Assert(content, qt.Contains, match, cm) } } } func (s *IntegrationTestBuilder) AssertFileContentEquals(filename string, match string) { s.Helper() content := s.FileContent(filename) s.Assert(content, qt.Equals, match, qt.Commentf(match)) } func (s *IntegrationTestBuilder) AssertFileContentExact(filename string, matches ...string) { s.Helper() content := s.FileContent(filename) for _, m := range matches { s.Assert(content, qt.Contains, m, qt.Commentf(m)) } } func (s *IntegrationTestBuilder) AssertNoRenderShortcodesArtifacts() { s.Helper() for _, p := range s.H.Pages() { content, err := p.Content(context.Background()) s.Assert(err, qt.IsNil) comment := qt.Commentf("Page: %s\n%s", p.Path(), content) s.Assert(strings.Contains(cast.ToString(content), "__hugo_ctx"), qt.IsFalse, comment) } } func (s *IntegrationTestBuilder) AssertPublishDir(matches ...string) { s.AssertFs(s.fs.PublishDir, matches...) } func (s *IntegrationTestBuilder) AssertFs(fs afero.Fs, matches ...string) { s.Helper() var buff bytes.Buffer s.Assert(s.printAndCheckFs(fs, "", &buff), qt.IsNil) printFsLines := strings.Split(buff.String(), "\n") sort.Strings(printFsLines) content := strings.TrimSpace((strings.Join(printFsLines, "\n"))) for _, m := range matches { cm := qt.Commentf("Match: %q\nIn:\n%s", m, content) lines := strings.Split(m, "\n") for _, match := range lines { match = strings.TrimSpace(match) var negate bool if strings.HasPrefix(match, "! ") { negate = true match = strings.TrimPrefix(match, "! ") } if negate { s.Assert(content, qt.Not(qt.Contains), match, cm) continue } s.Assert(content, qt.Contains, match, cm) } } } func (s *IntegrationTestBuilder) printAndCheckFs(fs afero.Fs, path string, w io.Writer) error { if fs == nil { return nil } return afero.Walk(fs, path, func(path string, info os.FileInfo, err error) error { if err != nil { return fmt.Errorf("error: path %q: %s", path, err) } path = filepath.ToSlash(path) if path == "" { path = "." } if !info.IsDir() { f, err := fs.Open(path) if err != nil { return fmt.Errorf("error: path %q: %s", path, err) } defer f.Close() // This will panic if the file is a directory. var buf [1]byte io.ReadFull(f, buf[:]) } fmt.Fprintln(w, path, info.IsDir()) return nil }) } func (s *IntegrationTestBuilder) AssertFileExists(filename string, b bool) { checker := qt.IsNil if !b { checker = qt.IsNotNil } _, err := s.fs.WorkingDirReadOnly.Stat(filename) if !herrors.IsNotExist(err) { s.Assert(err, qt.IsNil) } s.Assert(err, checker) } func (s *IntegrationTestBuilder) AssertIsFileError(err error) herrors.FileError { s.Assert(err, qt.ErrorAs, new(herrors.FileError)) return herrors.UnwrapFileError(err) } func (s *IntegrationTestBuilder) AssertRenderCountContent(count int) { s.Helper() s.Assert(s.counters.contentRenderCounter.Load(), qt.Equals, uint64(count)) } func (s *IntegrationTestBuilder) AssertRenderCountPage(count int) { s.Helper() s.Assert(s.counters.pageRenderCounter.Load(), qt.Equals, uint64(count)) } func (s *IntegrationTestBuilder) AssertRenderCountPageBetween(from, to int) { s.Helper() i := int(s.counters.pageRenderCounter.Load()) s.Assert(i >= from && i <= to, qt.IsTrue) } func (s *IntegrationTestBuilder) Build() *IntegrationTestBuilder { s.Helper() _, err := s.BuildE() if s.Cfg.Verbose || err != nil { fmt.Println(s.lastBuildLog) if s.H != nil && err == nil { for _, s := range s.H.Sites { m := s.pageMap var buff bytes.Buffer fmt.Fprintf(&buff, "PageMap for site %q\n\n", s.Language().Lang) m.debugPrint("", 999, &buff) fmt.Println(buff.String()) } } } else if s.Cfg.LogLevel <= logg.LevelDebug { fmt.Println(s.lastBuildLog) } s.Assert(err, qt.IsNil) if s.Cfg.RunGC { s.GCCount, err = s.H.GC() s.Assert(err, qt.IsNil) } s.Cleanup(func() { if h := s.H; h != nil { s.Assert(h.Close(), qt.IsNil) } }) return s } func (s *IntegrationTestBuilder) LogString() string { return s.lastBuildLog } func (s *IntegrationTestBuilder) BuildE() (*IntegrationTestBuilder, error) { s.Helper() if err := s.initBuilder(); err != nil { return s, err } err := s.build(s.Cfg.BuildCfg) return s, err } func (s *IntegrationTestBuilder) Init() *IntegrationTestBuilder { if err := s.initBuilder(); err != nil { s.Fatalf("Failed to init builder: %s", err) } s.lastBuildLog = s.logBuff.String() return s } type IntegrationTestDebugConfig struct { Out io.Writer PrintDestinationFs bool PrintPagemap bool PrefixDestinationFs string PrefixPagemap string } func (s *IntegrationTestBuilder) EditFileReplaceAll(filename, old, new string) *IntegrationTestBuilder { return s.EditFileReplaceFunc(filename, func(s string) string { return strings.ReplaceAll(s, old, new) }) } func (s *IntegrationTestBuilder) EditFileReplaceFunc(filename string, replacementFunc func(s string) string) *IntegrationTestBuilder { absFilename := s.absFilename(filename) b, err := afero.ReadFile(s.fs.Source, absFilename) s.Assert(err, qt.IsNil) s.changedFiles = append(s.changedFiles, absFilename) oldContent := string(b) s.writeSource(absFilename, replacementFunc(oldContent)) return s } func (s *IntegrationTestBuilder) EditFiles(filenameContent ...string) *IntegrationTestBuilder { for i := 0; i < len(filenameContent); i += 2 { filename, content := filepath.FromSlash(filenameContent[i]), filenameContent[i+1] absFilename := s.absFilename(filename) s.changedFiles = append(s.changedFiles, absFilename) s.writeSource(absFilename, content) } return s } func (s *IntegrationTestBuilder) AddFiles(filenameContent ...string) *IntegrationTestBuilder { for i := 0; i < len(filenameContent); i += 2 { filename, content := filepath.FromSlash(filenameContent[i]), filenameContent[i+1] absFilename := s.absFilename(filename) s.createdFiles = append(s.createdFiles, absFilename) s.writeSource(absFilename, content) } return s } func (s *IntegrationTestBuilder) RemoveFiles(filenames ...string) *IntegrationTestBuilder { for _, filename := range filenames { absFilename := s.absFilename(filename) s.removedFiles = append(s.removedFiles, absFilename) s.Assert(s.fs.Source.Remove(absFilename), qt.IsNil) } return s } func (s *IntegrationTestBuilder) RenameFile(old, new string) *IntegrationTestBuilder { absOldFilename := s.absFilename(old) absNewFilename := s.absFilename(new) s.renamedFiles = append(s.renamedFiles, absOldFilename) s.createdFiles = append(s.createdFiles, absNewFilename) s.Assert(s.fs.Source.MkdirAll(filepath.Dir(absNewFilename), 0o777), qt.IsNil) s.Assert(s.fs.Source.Rename(absOldFilename, absNewFilename), qt.IsNil) return s } func (s *IntegrationTestBuilder) RenameDir(old, new string) *IntegrationTestBuilder { absOldFilename := s.absFilename(old) absNewFilename := s.absFilename(new) s.renamedDirs = append(s.renamedDirs, absOldFilename) s.changedFiles = append(s.changedFiles, absNewFilename) afero.Walk(s.fs.Source, absOldFilename, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } s.createdFiles = append(s.createdFiles, strings.Replace(path, absOldFilename, absNewFilename, 1)) return nil }) s.Assert(s.fs.Source.MkdirAll(filepath.Dir(absNewFilename), 0o777), qt.IsNil) s.Assert(s.fs.Source.Rename(absOldFilename, absNewFilename), qt.IsNil) return s } func (s *IntegrationTestBuilder) FileContent(filename string) string { s.Helper() return s.readWorkingDir(s, s.fs, filepath.FromSlash(filename)) } func (s *IntegrationTestBuilder) initBuilder() error { var initErr error s.builderInit.Do(func() { var afs afero.Fs if s.Cfg.NeedsOsFS { afs = afero.NewOsFs() } else { afs = afero.NewMemMapFs() } if s.Cfg.LogLevel == 0 { s.Cfg.LogLevel = logg.LevelError } isBinaryRe := regexp.MustCompile(`^(.*)(\.png|\.jpg)$`) const dataSourceFilenamePrefix = "sourcefilename:" for _, f := range s.data.Files { filename := filepath.Join(s.Cfg.WorkingDir, f.Name) data := bytes.TrimSuffix(f.Data, []byte("\n")) datastr := strings.TrimSpace(string(data)) if strings.HasPrefix(datastr, dataSourceFilenamePrefix) { // Read from file relative to the current dir. var err error wd, _ := os.Getwd() filename := filepath.Join(wd, strings.TrimSpace(strings.TrimPrefix(datastr, dataSourceFilenamePrefix))) data, err = os.ReadFile(filename) s.Assert(err, qt.IsNil) } else if isBinaryRe.MatchString(filename) { var err error data, err = base64.StdEncoding.DecodeString(string(data)) s.Assert(err, qt.IsNil) } s.Assert(afs.MkdirAll(filepath.Dir(filename), 0o777), qt.IsNil) s.Assert(afero.WriteFile(afs, filename, data, 0o666), qt.IsNil) } configDir := "config" if _, err := afs.Stat(filepath.Join(s.Cfg.WorkingDir, "config")); err != nil { configDir = "" } var flags config.Provider if s.Cfg.BaseCfg != nil { flags = s.Cfg.BaseCfg } else { flags = config.New() } if s.Cfg.Running { flags.Set("internal", maps.Params{ "running": s.Cfg.Running, "watch": s.Cfg.Running, }) } else if s.Cfg.Watching { flags.Set("internal", maps.Params{ "watch": s.Cfg.Watching, }) } if s.Cfg.WorkingDir != "" { flags.Set("workingDir", s.Cfg.WorkingDir) } var w io.Writer if s.Cfg.LogLevel == logg.LevelTrace { w = os.Stdout } else { w = &s.logBuff } logger := loggers.New( loggers.Options{ Stdout: w, Stderr: w, Level: s.Cfg.LogLevel, DistinctLevel: logg.LevelWarn, }, ) res, err := allconfig.LoadConfig( allconfig.ConfigSourceDescriptor{ Flags: flags, ConfigDir: configDir, Fs: afs, Logger: logger, Environ: s.Cfg.Environ, }, ) if err != nil { initErr = err return } fs := hugofs.NewFrom(afs, res.LoadingInfo.BaseConfig) s.Assert(err, qt.IsNil) depsCfg := deps.DepsCfg{Configs: res, Fs: fs, LogLevel: logger.Level(), LogOut: logger.Out()} sites, err := NewHugoSites(depsCfg) if err != nil { initErr = err return } if sites == nil { initErr = errors.New("no sites") return } s.H = sites s.fs = fs if s.Cfg.NeedsNpmInstall { wd, _ := os.Getwd() s.Assert(os.Chdir(s.Cfg.WorkingDir), qt.IsNil) s.C.Cleanup(func() { os.Chdir(wd) }) sc := security.DefaultConfig sc.Exec.Allow, err = security.NewWhitelist("npm") s.Assert(err, qt.IsNil) ex := hexec.New(sc, s.Cfg.WorkingDir) command, err := ex.New("npm", "install") s.Assert(err, qt.IsNil) s.Assert(command.Run(), qt.IsNil) } }) return initErr } func (s *IntegrationTestBuilder) absFilename(filename string) string { filename = filepath.FromSlash(filename) if filepath.IsAbs(filename) { return filename } if s.Cfg.WorkingDir != "" && !strings.HasPrefix(filename, s.Cfg.WorkingDir) { filename = filepath.Join(s.Cfg.WorkingDir, filename) } return filename } func (s *IntegrationTestBuilder) reset() { s.changedFiles = nil s.createdFiles = nil s.removedFiles = nil s.renamedFiles = nil } func (s *IntegrationTestBuilder) build(cfg BuildCfg) error { s.Helper() defer func() { s.reset() s.lastBuildLog = s.logBuff.String() s.logBuff.Reset() }() changeEvents := s.changeEvents() s.counters = &buildCounters{} cfg.testCounters = s.counters if s.buildCount > 0 && (len(changeEvents) == 0) { return nil } s.buildCount++ err := s.H.Build(cfg, changeEvents...) if err != nil { return err } return nil } // We simulate the fsnotify events. // See the test output in https://github.com/bep/fsnotifyeventlister for what events gets produced // by the different OSes. func (s *IntegrationTestBuilder) changeEvents() []fsnotify.Event { var ( events []fsnotify.Event isLinux = runtime.GOOS == "linux" isMacOs = runtime.GOOS == "darwin" isWindows = runtime.GOOS == "windows" ) for _, v := range s.removedFiles { events = append(events, fsnotify.Event{ Name: v, Op: fsnotify.Remove, }) } for _, v := range s.renamedFiles { events = append(events, fsnotify.Event{ Name: v, Op: fsnotify.Rename, }) } for _, v := range s.renamedDirs { events = append(events, fsnotify.Event{ Name: v, // This is what we get on MacOS. Op: fsnotify.Remove | fsnotify.Rename, }) } for _, v := range s.changedFiles { events = append(events, fsnotify.Event{ Name: v, Op: fsnotify.Write, }) if isLinux || isWindows { // Duplicate write events, for some reason. events = append(events, fsnotify.Event{ Name: v, Op: fsnotify.Write, }) } if isMacOs { events = append(events, fsnotify.Event{ Name: v, Op: fsnotify.Chmod, }) } } for _, v := range s.createdFiles { events = append(events, fsnotify.Event{ Name: v, Op: fsnotify.Create, }) if isLinux || isWindows { events = append(events, fsnotify.Event{ Name: v, Op: fsnotify.Write, }) } } // Shuffle events. for i := range events { j := rand.Intn(i + 1) events[i], events[j] = events[j], events[i] } return events } func (s *IntegrationTestBuilder) readWorkingDir(t testing.TB, fs *hugofs.Fs, filename string) string { t.Helper() return s.readFileFromFs(t, fs.WorkingDirReadOnly, filename) } func (s *IntegrationTestBuilder) readFileFromFs(t testing.TB, fs afero.Fs, filename string) string { t.Helper() filename = filepath.Clean(filename) b, err := afero.ReadFile(fs, filename) if err != nil { // Print some debug info hadSlash := strings.HasPrefix(filename, helpers.FilePathSeparator) start := 0 if hadSlash { start = 1 } end := start + 1 parts := strings.Split(filename, helpers.FilePathSeparator) if parts[start] == "work" { end++ } s.Assert(err, qt.IsNil) } return string(b) } func (s *IntegrationTestBuilder) writeSource(filename, content string) { s.Helper() s.writeToFs(s.fs.Source, filename, content) } func (s *IntegrationTestBuilder) writeToFs(fs afero.Fs, filename, content string) { s.Helper() if err := afero.WriteFile(fs, filepath.FromSlash(filename), []byte(content), 0o755); err != nil { s.Fatalf("Failed to write file: %s", err) } } type IntegrationTestConfig struct { T testing.TB // The files to use on txtar format, see // https://pkg.go.dev/golang.org/x/exp/cmd/txtar // There are some conentions used in this test setup. // - §§§ can be used to wrap code fences. // - §§ can be used to wrap multiline strings. // - filenames prefixed with sourcefilename: will be read from the file system relative to the current dir. // - filenames with a .png or .jpg extension will be treated as binary and base64 decoded. TxtarString string // COnfig to use as the base. We will also read the config from the txtar. BaseCfg config.Provider // Environment variables passed to the config loader. Environ []string // Whether to simulate server mode. Running bool // Watch for changes. // This is (currently) always set to true when Running is set. // Note that the CLI for the server does allow for --watch=false, but that is not used in these test. Watching bool // Will print the log buffer after the build Verbose bool // The log level to use. LogLevel logg.Level // Whether it needs the real file system (e.g. for js.Build tests). NeedsOsFS bool // Whether to run GC after each build. RunGC bool // Do not remove the temp dir after the test. PrintAndKeepTempDir bool // Whether to run npm install before Build. NeedsNpmInstall bool // Whether to normalize the Unicode filenames to NFD on Darwin. NFDFormOnDarwin bool // The working dir to use. If not absolute, a temp dir will be created. WorkingDir string // The config to pass to Build. BuildCfg BuildCfg }