github.com/kovansky/hugo@v0.92.3-0.20220224232819-63076e4ff19f/hugolib/integrationtest_builder.go (about)

     1  package hugolib
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"path/filepath"
     9  	"strings"
    10  	"sync"
    11  	"testing"
    12  
    13  	jww "github.com/spf13/jwalterweatherman"
    14  
    15  	qt "github.com/frankban/quicktest"
    16  	"github.com/fsnotify/fsnotify"
    17  	"github.com/gohugoio/hugo/common/herrors"
    18  	"github.com/gohugoio/hugo/common/hexec"
    19  	"github.com/gohugoio/hugo/common/loggers"
    20  	"github.com/gohugoio/hugo/config"
    21  	"github.com/gohugoio/hugo/config/security"
    22  	"github.com/gohugoio/hugo/deps"
    23  	"github.com/gohugoio/hugo/helpers"
    24  	"github.com/gohugoio/hugo/htesting"
    25  	"github.com/gohugoio/hugo/hugofs"
    26  	"github.com/spf13/afero"
    27  	"golang.org/x/tools/txtar"
    28  )
    29  
    30  func NewIntegrationTestBuilder(conf IntegrationTestConfig) *IntegrationTestBuilder {
    31  	data := txtar.Parse([]byte(conf.TxtarString))
    32  
    33  	c, ok := conf.T.(*qt.C)
    34  	if !ok {
    35  		c = qt.New(conf.T)
    36  	}
    37  
    38  	if conf.NeedsOsFS {
    39  		doClean := true
    40  		tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test")
    41  		c.Assert(err, qt.IsNil)
    42  		conf.WorkingDir = filepath.Join(tempDir, conf.WorkingDir)
    43  		if doClean {
    44  			c.Cleanup(clean)
    45  		}
    46  	}
    47  
    48  	return &IntegrationTestBuilder{
    49  		Cfg:  conf,
    50  		C:    c,
    51  		data: data,
    52  	}
    53  }
    54  
    55  // IntegrationTestBuilder is a (partial) rewrite of sitesBuilder.
    56  // The main problem with the "old" one was that it was that the test data was often a little hidden,
    57  // so it became hard to look at a test and determine what it should do, especially coming back to the
    58  // test after a year or so.
    59  type IntegrationTestBuilder struct {
    60  	*qt.C
    61  
    62  	data *txtar.Archive
    63  
    64  	fs *hugofs.Fs
    65  	H  *HugoSites
    66  
    67  	Cfg IntegrationTestConfig
    68  
    69  	changedFiles []string
    70  	createdFiles []string
    71  	removedFiles []string
    72  	renamedFiles []string
    73  
    74  	buildCount int
    75  	counters   *testCounters
    76  	logBuff    lockingBuffer
    77  
    78  	builderInit sync.Once
    79  }
    80  
    81  type lockingBuffer struct {
    82  	sync.Mutex
    83  	bytes.Buffer
    84  }
    85  
    86  func (b *lockingBuffer) Write(p []byte) (n int, err error) {
    87  	b.Lock()
    88  	n, err = b.Buffer.Write(p)
    89  	b.Unlock()
    90  	return
    91  }
    92  
    93  func (s *IntegrationTestBuilder) AssertLogContains(text string) {
    94  	s.Helper()
    95  	s.Assert(s.logBuff.String(), qt.Contains, text)
    96  }
    97  
    98  func (s *IntegrationTestBuilder) AssertBuildCountData(count int) {
    99  	s.Helper()
   100  	s.Assert(s.H.init.data.InitCount(), qt.Equals, count)
   101  }
   102  
   103  func (s *IntegrationTestBuilder) AssertBuildCountGitInfo(count int) {
   104  	s.Helper()
   105  	s.Assert(s.H.init.gitInfo.InitCount(), qt.Equals, count)
   106  }
   107  
   108  func (s *IntegrationTestBuilder) AssertBuildCountLayouts(count int) {
   109  	s.Helper()
   110  	s.Assert(s.H.init.layouts.InitCount(), qt.Equals, count)
   111  }
   112  
   113  func (s *IntegrationTestBuilder) AssertBuildCountTranslations(count int) {
   114  	s.Helper()
   115  	s.Assert(s.H.init.translations.InitCount(), qt.Equals, count)
   116  }
   117  
   118  func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...string) {
   119  	s.Helper()
   120  	content := strings.TrimSpace(s.FileContent(filename))
   121  	for _, m := range matches {
   122  		lines := strings.Split(m, "\n")
   123  		for _, match := range lines {
   124  			match = strings.TrimSpace(match)
   125  			if match == "" || strings.HasPrefix(match, "#") {
   126  				continue
   127  			}
   128  			s.Assert(content, qt.Contains, match, qt.Commentf(m))
   129  		}
   130  	}
   131  }
   132  
   133  func (s *IntegrationTestBuilder) AssertDestinationExists(filename string, b bool) {
   134  	checker := qt.IsTrue
   135  	if !b {
   136  		checker = qt.IsFalse
   137  	}
   138  	s.Assert(s.destinationExists(filepath.Clean(filename)), checker)
   139  }
   140  
   141  func (s *IntegrationTestBuilder) destinationExists(filename string) bool {
   142  	b, err := helpers.Exists(filename, s.fs.Destination)
   143  	if err != nil {
   144  		panic(err)
   145  	}
   146  	return b
   147  }
   148  
   149  func (s *IntegrationTestBuilder) AssertIsFileError(err error) {
   150  	var ferr *herrors.ErrorWithFileContext
   151  	s.Assert(err, qt.ErrorAs, &ferr)
   152  }
   153  
   154  func (s *IntegrationTestBuilder) AssertRenderCountContent(count int) {
   155  	s.Helper()
   156  	s.Assert(s.counters.contentRenderCounter, qt.Equals, uint64(count))
   157  }
   158  
   159  func (s *IntegrationTestBuilder) AssertRenderCountPage(count int) {
   160  	s.Helper()
   161  	s.Assert(s.counters.pageRenderCounter, qt.Equals, uint64(count))
   162  }
   163  
   164  func (s *IntegrationTestBuilder) Build() *IntegrationTestBuilder {
   165  	s.Helper()
   166  	_, err := s.BuildE()
   167  	if s.Cfg.Verbose || err != nil {
   168  		fmt.Println(s.logBuff.String())
   169  	}
   170  	s.Assert(err, qt.IsNil)
   171  	return s
   172  }
   173  
   174  func (s *IntegrationTestBuilder) BuildE() (*IntegrationTestBuilder, error) {
   175  	s.Helper()
   176  	s.initBuilder()
   177  	err := s.build(BuildCfg{})
   178  	return s, err
   179  }
   180  
   181  type IntegrationTestDebugConfig struct {
   182  	Out io.Writer
   183  
   184  	PrintDestinationFs bool
   185  	PrintPagemap       bool
   186  
   187  	PrefixDestinationFs string
   188  	PrefixPagemap       string
   189  }
   190  
   191  func (s *IntegrationTestBuilder) EditFileReplace(filename string, replacementFunc func(s string) string) *IntegrationTestBuilder {
   192  	absFilename := s.absFilename(filename)
   193  	b, err := afero.ReadFile(s.fs.Source, absFilename)
   194  	s.Assert(err, qt.IsNil)
   195  	s.changedFiles = append(s.changedFiles, absFilename)
   196  	oldContent := string(b)
   197  	s.writeSource(absFilename, replacementFunc(oldContent))
   198  	return s
   199  }
   200  
   201  func (s *IntegrationTestBuilder) EditFiles(filenameContent ...string) *IntegrationTestBuilder {
   202  	for i := 0; i < len(filenameContent); i += 2 {
   203  		filename, content := filepath.FromSlash(filenameContent[i]), filenameContent[i+1]
   204  		absFilename := s.absFilename(filename)
   205  		s.changedFiles = append(s.changedFiles, absFilename)
   206  		s.writeSource(absFilename, content)
   207  	}
   208  	return s
   209  }
   210  
   211  func (s *IntegrationTestBuilder) AddFiles(filenameContent ...string) *IntegrationTestBuilder {
   212  	for i := 0; i < len(filenameContent); i += 2 {
   213  		filename, content := filepath.FromSlash(filenameContent[i]), filenameContent[i+1]
   214  		absFilename := s.absFilename(filename)
   215  		s.createdFiles = append(s.createdFiles, absFilename)
   216  		s.writeSource(absFilename, content)
   217  	}
   218  	return s
   219  }
   220  
   221  func (s *IntegrationTestBuilder) RemoveFiles(filenames ...string) *IntegrationTestBuilder {
   222  	for _, filename := range filenames {
   223  		absFilename := s.absFilename(filename)
   224  		s.removedFiles = append(s.removedFiles, absFilename)
   225  		s.Assert(s.fs.Source.Remove(absFilename), qt.IsNil)
   226  
   227  	}
   228  
   229  	return s
   230  }
   231  
   232  func (s *IntegrationTestBuilder) RenameFile(old, new string) *IntegrationTestBuilder {
   233  	absOldFilename := s.absFilename(old)
   234  	absNewFilename := s.absFilename(new)
   235  	s.renamedFiles = append(s.renamedFiles, absOldFilename)
   236  	s.createdFiles = append(s.createdFiles, absNewFilename)
   237  	s.Assert(s.fs.Source.Rename(absOldFilename, absNewFilename), qt.IsNil)
   238  	return s
   239  }
   240  
   241  func (s *IntegrationTestBuilder) FileContent(filename string) string {
   242  	s.Helper()
   243  	filename = filepath.FromSlash(filename)
   244  	if !strings.HasPrefix(filename, s.Cfg.WorkingDir) {
   245  		filename = filepath.Join(s.Cfg.WorkingDir, filename)
   246  	}
   247  	return s.readDestination(s, s.fs, filename)
   248  }
   249  
   250  func (s *IntegrationTestBuilder) initBuilder() {
   251  	s.builderInit.Do(func() {
   252  		var afs afero.Fs
   253  		if s.Cfg.NeedsOsFS {
   254  			afs = afero.NewOsFs()
   255  		} else {
   256  			afs = afero.NewMemMapFs()
   257  		}
   258  
   259  		if s.Cfg.LogLevel == 0 {
   260  			s.Cfg.LogLevel = jww.LevelWarn
   261  		}
   262  
   263  		logger := loggers.NewBasicLoggerForWriter(s.Cfg.LogLevel, &s.logBuff)
   264  
   265  		fs := hugofs.NewFrom(afs, config.New())
   266  
   267  		for _, f := range s.data.Files {
   268  			filename := filepath.Join(s.Cfg.WorkingDir, f.Name)
   269  			s.Assert(afs.MkdirAll(filepath.Dir(filename), 0777), qt.IsNil)
   270  			s.Assert(afero.WriteFile(afs, filename, bytes.TrimSuffix(f.Data, []byte("\n")), 0666), qt.IsNil)
   271  		}
   272  
   273  		cfg, _, err := LoadConfig(
   274  			ConfigSourceDescriptor{
   275  				WorkingDir: s.Cfg.WorkingDir,
   276  				Fs:         afs,
   277  				Logger:     logger,
   278  				Environ:    []string{},
   279  				Filename:   "config.toml",
   280  			},
   281  			func(cfg config.Provider) error {
   282  				return nil
   283  			},
   284  		)
   285  
   286  		s.Assert(err, qt.IsNil)
   287  
   288  		cfg.Set("workingDir", s.Cfg.WorkingDir)
   289  
   290  		depsCfg := deps.DepsCfg{Cfg: cfg, Fs: fs, Running: s.Cfg.Running, Logger: logger}
   291  		sites, err := NewHugoSites(depsCfg)
   292  		s.Assert(err, qt.IsNil)
   293  
   294  		s.H = sites
   295  		s.fs = fs
   296  
   297  		if s.Cfg.NeedsNpmInstall {
   298  			wd, _ := os.Getwd()
   299  			s.Assert(os.Chdir(s.Cfg.WorkingDir), qt.IsNil)
   300  			s.C.Cleanup(func() { os.Chdir(wd) })
   301  			sc := security.DefaultConfig
   302  			sc.Exec.Allow = security.NewWhitelist("npm")
   303  			ex := hexec.New(sc)
   304  			command, err := ex.New("npm", "install")
   305  			s.Assert(err, qt.IsNil)
   306  			s.Assert(command.Run(), qt.IsNil)
   307  
   308  		}
   309  	})
   310  }
   311  
   312  func (s *IntegrationTestBuilder) absFilename(filename string) string {
   313  	filename = filepath.FromSlash(filename)
   314  	if filepath.IsAbs(filename) {
   315  		return filename
   316  	}
   317  	if s.Cfg.WorkingDir != "" && !strings.HasPrefix(filename, s.Cfg.WorkingDir) {
   318  		filename = filepath.Join(s.Cfg.WorkingDir, filename)
   319  	}
   320  	return filename
   321  }
   322  
   323  func (s *IntegrationTestBuilder) build(cfg BuildCfg) error {
   324  	s.Helper()
   325  	defer func() {
   326  		s.changedFiles = nil
   327  		s.createdFiles = nil
   328  		s.removedFiles = nil
   329  		s.renamedFiles = nil
   330  	}()
   331  
   332  	changeEvents := s.changeEvents()
   333  	s.logBuff.Reset()
   334  	s.counters = &testCounters{}
   335  	cfg.testCounters = s.counters
   336  
   337  	if s.buildCount > 0 && (len(changeEvents) == 0) {
   338  		return nil
   339  	}
   340  
   341  	s.buildCount++
   342  
   343  	err := s.H.Build(cfg, changeEvents...)
   344  	if err != nil {
   345  		return err
   346  	}
   347  	logErrorCount := s.H.NumLogErrors()
   348  	if logErrorCount > 0 {
   349  		return fmt.Errorf("logged %d error(s): %s", logErrorCount, s.logBuff.String())
   350  	}
   351  
   352  	return nil
   353  }
   354  
   355  func (s *IntegrationTestBuilder) changeEvents() []fsnotify.Event {
   356  	var events []fsnotify.Event
   357  	for _, v := range s.removedFiles {
   358  		events = append(events, fsnotify.Event{
   359  			Name: v,
   360  			Op:   fsnotify.Remove,
   361  		})
   362  	}
   363  	for _, v := range s.renamedFiles {
   364  		events = append(events, fsnotify.Event{
   365  			Name: v,
   366  			Op:   fsnotify.Rename,
   367  		})
   368  	}
   369  	for _, v := range s.changedFiles {
   370  		events = append(events, fsnotify.Event{
   371  			Name: v,
   372  			Op:   fsnotify.Write,
   373  		})
   374  	}
   375  	for _, v := range s.createdFiles {
   376  		events = append(events, fsnotify.Event{
   377  			Name: v,
   378  			Op:   fsnotify.Create,
   379  		})
   380  	}
   381  
   382  	return events
   383  }
   384  
   385  func (s *IntegrationTestBuilder) readDestination(t testing.TB, fs *hugofs.Fs, filename string) string {
   386  	t.Helper()
   387  	return s.readFileFromFs(t, fs.Destination, filename)
   388  }
   389  
   390  func (s *IntegrationTestBuilder) readFileFromFs(t testing.TB, fs afero.Fs, filename string) string {
   391  	t.Helper()
   392  	filename = filepath.Clean(filename)
   393  	b, err := afero.ReadFile(fs, filename)
   394  	if err != nil {
   395  		// Print some debug info
   396  		hadSlash := strings.HasPrefix(filename, helpers.FilePathSeparator)
   397  		start := 0
   398  		if hadSlash {
   399  			start = 1
   400  		}
   401  		end := start + 1
   402  
   403  		parts := strings.Split(filename, helpers.FilePathSeparator)
   404  		if parts[start] == "work" {
   405  			end++
   406  		}
   407  
   408  		s.Assert(err, qt.IsNil)
   409  
   410  	}
   411  	return string(b)
   412  }
   413  
   414  func (s *IntegrationTestBuilder) writeSource(filename, content string) {
   415  	s.Helper()
   416  	s.writeToFs(s.fs.Source, filename, content)
   417  }
   418  
   419  func (s *IntegrationTestBuilder) writeToFs(fs afero.Fs, filename, content string) {
   420  	s.Helper()
   421  	if err := afero.WriteFile(fs, filepath.FromSlash(filename), []byte(content), 0755); err != nil {
   422  		s.Fatalf("Failed to write file: %s", err)
   423  	}
   424  }
   425  
   426  type IntegrationTestConfig struct {
   427  	T testing.TB
   428  
   429  	// The files to use on txtar format, see
   430  	// https://pkg.go.dev/golang.org/x/exp/cmd/txtar
   431  	TxtarString string
   432  
   433  	// Whether to simulate server mode.
   434  	Running bool
   435  
   436  	// Will print the log buffer after the build
   437  	Verbose bool
   438  
   439  	LogLevel jww.Threshold
   440  
   441  	// Whether it needs the real file system (e.g. for js.Build tests).
   442  	NeedsOsFS bool
   443  
   444  	// Whether to run npm install before Build.
   445  	NeedsNpmInstall bool
   446  
   447  	WorkingDir string
   448  }