Files
hugo/hugolib/integrationtest_builder.go
T
Bjørn Erik Pedersen a54c398b93 Harden Node tool execution with --permission flag
Add security.node.permissions config to run Node tools (PostCSS, Babel,
TailwindCSS) under Node's permission model, restricting file system access
to the working directory by default.

The binary resolution is simplified to node_modules/.bin → PATH (npx removed).
For both locations, the actual JS entry point is resolved via symlinks (macOS/Linux)
or by parsing npm wrapper scripts (Windows .cmd), then executed as
"node --permission --allow-fs-read=<path> --allow-fs-write=<path> <script>".

Users can opt out by removing "node" from security.exec.allow.

Closes #7287
2026-04-22 15:47:34 +02:00

1230 lines
32 KiB
Go

package hugolib
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"image"
"io"
"math/rand"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"testing"
"github.com/bep/logg"
"github.com/yuin/goldmark/util"
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/himage"
"github.com/gohugoio/hugo/common/hmaps"
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/common/types"
"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/gohugoio/hugo/hugofs/hglob"
"github.com/gohugoio/hugo/hugolib/sitesmatrix"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/media"
"github.com/spf13/afero"
"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
}
}
// TestOptInfo will enable info logging in integration tests.
func TestOptInfo() TestOpt {
return func(c *IntegrationTestConfig) {
c.LogLevel = logg.LevelInfo
}
}
// TestOptWarn will enable warn logging in integration tests.
func TestOptWarn() TestOpt {
return func(c *IntegrationTestConfig) {
c.LogLevel = logg.LevelWarn
}
}
// TestOptSkipRender will skip the render phase in integration tests.
func TestOptSkipRender() TestOpt {
return func(c *IntegrationTestConfig) {
c.BuildCfg = BuildCfg{
SkipRender: true,
}
}
}
// TestOptOsFs will enable the real file system in integration tests.
func TestOptOsFs() TestOpt {
return func(c *IntegrationTestConfig) {
c.NeedsOsFS = true
}
}
// TestOptWithNpmInstall will enable npm install in integration tests.
func TestOptWithNpmInstall() TestOpt {
return func(c *IntegrationTestConfig) {
c.NeedsNpmInstall = true
}
}
// TestOptWithNpmInstallGlobal enables global npm package installation in integration tests.
// This mutates the host environment and is therefore restricted to real CI.
func TestOptWithNpmInstallGlobal(packages ...string) TestOpt {
return func(c *IntegrationTestConfig) {
c.NeedsNpmGlobalInstall = packages
}
}
// TestOptWithNFDOnDarwin will normalize the Unicode filenames to NFD on Darwin.
func TestOptWithNFDOnDarwin() TestOpt {
return func(c *IntegrationTestConfig) {
c.NFDFormOnDarwin = true
}
}
// TestOptWithOSFs enables the real file system.
func TestOptWithOSFs() TestOpt {
return func(c *IntegrationTestConfig) {
c.NeedsOsFS = true
}
}
func TestOptWithPrintAndKeepTempDir(b bool) TestOpt {
return func(c *IntegrationTestConfig) {
c.PrintAndKeepTempDir = b
}
}
// 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 IntegrationTestSiteHelper struct {
*qt.C
b *IntegrationTestBuilder
S *Site
}
type IntegrationTestPageHelper struct {
*qt.C
b *IntegrationTestBuilder
p *pageState
}
func (s *IntegrationTestPageHelper) siteIntsToMap(matrix, complements sitesmatrix.VectorStore) map[string]map[string][]string {
dconf := s.p.s.Conf.ConfiguredDimensions()
intSetsToMap := func(intSets sitesmatrix.VectorStore) map[string][]string {
var languages, versions, roles []string
keys1, keys2, keys3 := intSets.KeysSorted()
for _, v := range keys1 {
languages = append(languages, dconf.ConfiguredLanguages.ResolveName(v))
}
for _, v := range keys2 {
versions = append(versions, dconf.ConfiguredVersions.ResolveName(v))
}
for _, v := range keys3 {
roles = append(roles, dconf.ConfiguredRoles.ResolveName(v))
}
return map[string][]string{
"languages": languages,
"versions": versions,
"roles": roles,
}
}
return map[string]map[string][]string{
"matrix": intSetsToMap(matrix),
"complements": intSetsToMap(complements),
}
}
func (s *IntegrationTestPageHelper) MatrixFromPageConfig() map[string]map[string][]string {
pc := s.p.m.pageConfigSource
return s.siteIntsToMap(pc.SitesMatrix, pc.SitesComplements)
}
func (s *IntegrationTestPageHelper) MatrixFromFile() map[string]map[string][]string {
if s.p.m.f == nil {
return nil
}
m := s.p.m.f.FileInfo().Meta()
return s.siteIntsToMap(m.SitesMatrix, m.SitesComplements)
}
func (s *IntegrationTestSiteHelper) PageHelper(path string) *IntegrationTestPageHelper {
p, err := s.S.GetPage(path)
s.Assert(err, qt.IsNil)
s.Assert(p, qt.Not(qt.IsNil), qt.Commentf("Page not found: %s", path))
ps, ok := p.(*pageState)
s.Assert(ok, qt.IsTrue, qt.Commentf("Expected pageState, got %T", p))
return &IntegrationTestPageHelper{
C: s.C,
b: s.b,
p: ps,
}
}
func (s *IntegrationTestSiteHelper) DimensionNames() types.Strings3 {
return types.Strings3{
s.S.Language().Name(),
s.S.Version().Name(),
s.S.Role().Name(),
}
}
type lockingBuffer struct {
sync.Mutex
buf bytes.Buffer
}
func (b *lockingBuffer) String() string {
b.Lock()
defer b.Unlock()
return b.buf.String()
}
func (b *lockingBuffer) Reset() {
b.Lock()
defer b.Unlock()
b.buf.Reset()
}
func (b *lockingBuffer) ReadFrom(r io.Reader) (n int64, err error) {
b.Lock()
n, err = b.buf.ReadFrom(r)
b.Unlock()
return
}
func (b *lockingBuffer) Write(p []byte) (n int, err error) {
b.Lock()
n, err = b.buf.Write(p)
b.Unlock()
return
}
// SiteHelper returns a helper for the given language, version and role.
// Note that a blank value for an argument will use the default value for that dimension.
func (s *IntegrationTestBuilder) SiteHelper(language, version, role string) *IntegrationTestSiteHelper {
s.Helper()
if s.H == nil {
s.Fatal("SiteHelper: no sites available")
}
v := s.H.Conf.ConfiguredDimensions().ResolveVector(types.Strings3{language, version, role})
site, found := s.H.sitesVersionsRolesMap[v]
if !found {
s.Fatalf("SiteHelper: no site found for vector %v", v)
}
return &IntegrationTestSiteHelper{
C: s.C,
b: s,
S: site,
}
}
// AssertLogContains asserts that the last build log contains the given strings.
// Each string can be negated with a hglob.NegationPrefix 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)
}
}
// AssertLogMatches asserts that the last build log matches the given regular expressions.
// The regular expressions can be negated with a hglob.NegationPrefix 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) 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, hglob.NegationPrefix) {
negate = true
match = strings.TrimPrefix(match, hglob.NegationPrefix)
}
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 Expect:\n%s Got:\n%s\nWith Space Visuals:\n%s", filename, m, content, util.VisualizeSpaces([]byte(content)))
lines := strings.SplitSeq(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 {
cm := qt.Commentf("File: %s Match %s\nContent:\n%s", filename, m, content)
s.Assert(content, qt.Contains, m, cm)
}
}
func (s *IntegrationTestBuilder) AssertNoRenderShortcodesArtifacts() {
s.Helper()
afero.Walk(s.fs.PublishDir, "", func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
ext := strings.TrimPrefix(filepath.Ext(path), ".")
if !s.H.Conf.GetConfigSection("mediaTypes").(media.Types).IsTextSuffix(ext) {
return nil
}
content, err := afero.ReadFile(s.fs.PublishDir, path)
s.Assert(err, qt.IsNil)
comment := qt.Commentf("File: %s\n%s", path, string(content))
s.Assert(strings.Contains(string(content), "__hugo_ctx"), qt.IsFalse, comment)
return nil
})
}
type IntegrationTestImageHelper struct {
*qt.C
b *IntegrationTestBuilder
img image.Image
formatName string
congig image.Config
}
func (h *IntegrationTestImageHelper) AssertFormat(formatName string) *IntegrationTestImageHelper {
h.Assert(h.formatName, qt.Equals, formatName, qt.Commentf("Expected format %q, got %q", formatName, h.formatName))
return h
}
func (h *IntegrationTestImageHelper) AssertFrameDurations(expect []int) *IntegrationTestImageHelper {
anim, ok := h.img.(himage.AnimatedImage)
h.Assert(ok, qt.IsTrue, qt.Commentf("Image is not animated"))
h.Assert(anim.GetFrameDurations(), qt.DeepEquals, expect, qt.Commentf("Frame durations do not match"))
return h
}
func (h *IntegrationTestImageHelper) AssertLoopCount(expect int) *IntegrationTestImageHelper {
anim, ok := h.img.(himage.AnimatedImage)
h.Assert(ok, qt.IsTrue, qt.Commentf("Image is not animated"))
h.Assert(anim.GetLoopCount(), qt.Equals, expect, qt.Commentf("Loop count does not match"))
return h
}
func (h *IntegrationTestImageHelper) AssertIsAnimated(b bool) *IntegrationTestImageHelper {
_, ok := h.img.(himage.AnimatedImage)
if b {
h.Assert(ok, qt.IsTrue, qt.Commentf("Image is not animated"))
return h
}
h.Assert(ok, qt.IsFalse, qt.Commentf("Image is animated"))
return h
}
func (s *IntegrationTestBuilder) ImageHelper(filename string) *IntegrationTestImageHelper {
filename = filepath.Clean(filename)
fs := s.fs.WorkingDirReadOnly
b, err := afero.ReadFile(fs, filename)
s.Assert(err, qt.IsNil)
conf, format, err := s.H.ResourceSpec.Imaging.Codec.DecodeConfig(0, bytes.NewReader(b))
s.Assert(err, qt.IsNil)
img, err := s.H.ResourceSpec.Imaging.Codec.Decode(bytes.NewReader(b))
s.Assert(err, qt.IsNil)
return &IntegrationTestImageHelper{
C: s.C,
b: s,
img: img,
formatName: format,
congig: conf,
}
}
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.SplitSeq(m, "\n")
for match := range lines {
match = strings.TrimSpace(match)
var negate bool
if strings.HasPrefix(match, hglob.NegationPrefix) {
negate = true
match = strings.TrimPrefix(match, hglob.NegationPrefix)
}
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 = "."
}
var size int64
if !info.IsDir() {
size = info.Size()
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.Fprintf(w, "%06d %s %t\n", size, 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 err != nil && !herrors.IsNotExist(err) {
s.Assert(err, qt.IsNil)
return
}
s.Assert(err, checker)
}
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 err != nil && strings.Contains(err.Error(), "error(s)") {
err = fmt.Errorf("%w: %s", err, s.lastBuildLog)
}
if s.Cfg.Verbose || err != nil {
if s.H != nil && err == nil {
for s := range s.H.allSites(nil) {
m := s.pageMap
var buff bytes.Buffer
fmt.Fprintf(&buff, "======= PageMap for site %q =======\n\n", s.resolveDimensionNames())
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) BuildPartial(urls ...string) *IntegrationTestBuilder {
if _, err := s.BuildPartialE(urls...); err != nil {
s.Fatal(err)
}
return s
}
func (s *IntegrationTestBuilder) BuildPartialE(urls ...string) (*IntegrationTestBuilder, error) {
if s.buildCount == 0 {
panic("BuildPartial can only be used after a full build")
}
if !s.Cfg.Running {
panic("BuildPartial can only be used in server mode")
}
visited := types.NewEvictingQueue[string](len(urls))
for _, url := range urls {
visited.Add(url)
}
buildCfg := BuildCfg{RecentlyTouched: visited, PartialReRender: true}
return s, s.build(buildCfg)
}
func (s *IntegrationTestBuilder) Close() {
s.Helper()
s.Assert(s.H.Close(), qt.IsNil)
}
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) EditFileAppend(filename, contnt 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, oldContent+contnt)
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) CreateDirs(dirnames ...string) *IntegrationTestBuilder {
for _, dirname := range dirnames {
absDir := s.absFilename(filepath.FromSlash(dirname))
s.Assert(s.fs.Source.MkdirAll(absDir, 0o777), qt.IsNil)
s.createdFiles = append(s.createdFiles, absDir)
}
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) RemovePublishDir() *IntegrationTestBuilder {
s.Helper()
if err := s.fs.PublishDir.RemoveAll(""); err != nil && !herrors.IsNotExist(err) {
s.Fatalf("Failed to remove publish dir: %s", err)
}
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", hmaps.Params{
"running": s.Cfg.Running,
"watch": s.Cfg.Running,
})
} else if s.Cfg.Watching {
flags.Set("internal", hmaps.Params{
"watch": s.Cfg.Watching,
})
}
if s.Cfg.WorkingDir != "" {
flags.Set("workingDir", s.Cfg.WorkingDir)
}
var w io.Writer
if s.Cfg.Verbose || s.Cfg.LogLevel == logg.LevelTrace {
w = io.MultiWriter(os.Stdout, &s.logBuff)
} else {
w = &s.logBuff
}
var logHookLast func(e *logg.Entry) error
if s.Cfg.PanicOnWarning {
logHookLast = loggers.PanicOnWarningHook
}
logger := loggers.New(
loggers.Options{
StdOut: w,
StdErr: w,
Level: s.Cfg.LogLevel,
DistinctLevel: logg.LevelWarn,
HandlerPost: logHookLast,
},
)
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)
// changes received from Hugo in watch mode.
// In the full setup, this channel is created in the commands package.
changesFromBuild := make(chan []identity.Identity, 10)
depsCfg := deps.DepsCfg{Configs: res, Fs: fs, LogLevel: logger.Level(), StdErr: logger.StdErr(), ChangesFromBuild: changesFromBuild, IsIntegrationTest: true}
sites, err := NewHugoSites(depsCfg)
if err != nil {
initErr = err
return
}
if sites == nil {
initErr = errors.New("no sites")
return
}
go func() {
for id := range changesFromBuild {
whatChanged := &WhatChanged{}
for _, v := range id {
whatChanged.Add(v)
}
bcfg := s.Cfg.BuildCfg
bcfg.WhatChanged = whatChanged
if err := s.build(bcfg); err != nil {
s.Fatalf("Build failed after change: %s", err)
}
}
}()
s.H = sites
s.fs = fs
if s.Cfg.NeedsNpmInstall || len(s.Cfg.NeedsNpmGlobalInstall) > 0 {
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, loggers.NewDefault())
if len(s.Cfg.NeedsNpmGlobalInstall) > 0 {
if !htesting.IsRealCI() && !htesting.IsGitHubAction() {
panic("NeedsNpmGlobalInstall is restricted to real CI because it performs global npm installs")
}
for _, pkg := range s.Cfg.NeedsNpmGlobalInstall {
command, err := ex.New("npm", "install", "-g", pkg)
s.Assert(err, qt.IsNil)
s.Assert(command.Run(), qt.IsNil)
}
s.C.Cleanup(func() {
for _, pkg := range s.Cfg.NeedsNpmGlobalInstall {
ex.New("npm", "uninstall", "-g", pkg)
}
})
} else {
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
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 contentions 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
// Enable verbose logging.
Verbose bool
// The log level to use.
LogLevel logg.Level
// Whether to panic on warnings.
PanicOnWarning bool
// 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
// Global npm packages to install before Build. This is used for testing the npm integration in the Hugo Pipes.
// Only used on CI.
NeedsNpmGlobalInstall []string
// 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
}