blob: cf29d1c9e771c78e2a797e061ce49fae7717fe50 [file] [edit]
// Copyright 2026 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// These tests are an entry point to our CI code.
// We want LUCI builders that run "go test ./..." to execute our scripts.
//
// [TestLint] needs nothing but a Go toolchain, so it runs on every Linux
// builder. [TestIntegration] stands up our services in containers, so it runs
// only on Go builders with Docker, with PKGSITE_CI_MODE selecting which of its
// stages to run.
package pkgsite
import (
"context"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"testing"
"time"
)
// modeEnv names the environment variable that selects which [TestIntegration]
// stages to run. On a Go builder with Docker an unset value means modeCore,
// while the "screentest" run mod sets it to modeScreentest.
const modeEnv = "PKGSITE_CI_MODE"
const (
// modeCore runs the deterministic stages: the NPM lint and tests, all.bash
// ci (the PostgreSQL-backed test suite), and the search integration tests.
modeCore = "core"
// modeScreentest runs only the headless-Chrome visual regression suite,
// which is prone to rendering and timing flakes.
modeScreentest = "screentest"
)
// TestLint runs the checks that need nothing but a Go toolchain, so that they
// run on every Linux builder rather than only the few that have Docker.
func TestLint(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("skipping on non-Linux machines")
}
if os.Getenv("GO_DISCOVERY_TESTDB") != "" {
t.Skip("skipping inside all.bash ci container")
}
runStage(t, "AllBash", "./all.bash", "lint")
}
// TestIntegration runs the CI stages that stand up our services in containers:
// PostgreSQL for the database tests, headless Chrome for the screenshot tests
// and Node for the JavaScript ones.
//
// It runs on Go builders that have Docker. Elsewhere, including on a developer
// machine running "go test ./...", it skips unless PKGSITE_CI_MODE opts in.
func TestIntegration(t *testing.T) {
mode := os.Getenv(modeEnv)
switch mode {
case "":
if os.Getenv("GO_BUILDER_NAME") == "" {
t.Skipf("neither %s nor GO_BUILDER_NAME is set", modeEnv)
}
mode = modeCore
case modeCore, modeScreentest:
// Valid modes.
default:
t.Fatalf("unknown %s=%q, want %q or %q", modeEnv, mode, modeCore, modeScreentest)
}
if _, err := exec.LookPath("docker"); err != nil {
if os.Getenv(modeEnv) != "" || strings.Contains(os.Getenv("GO_BUILDER_NAME"), "docker") {
t.Fatalf("docker is not installed: %v", err)
}
t.Skipf("docker is not installed: %v", err)
}
// Tear the compose project down on the way in as well as the way out. A
// build that is killed part way through leaves containers behind holding
// the database port, which would otherwise fail every later build that
// lands on the same reused builder.
composeDown(t)
t.Cleanup(func() {
chownRepo(t)
composeDown(t)
})
if mode == modeScreentest {
t.Run("Screentest", func(t *testing.T) {
t.Cleanup(func() {
if !t.Failed() {
return
}
const outDir = "tests/screentest/output"
if _, err := os.Stat(outDir); err != nil {
t.Logf("stat screentest output: %v", err)
return
}
if err := os.CopyFS(t.ArtifactDir(), os.DirFS(outDir)); err != nil {
t.Logf("copy screentest output: %v", err)
}
})
runCmd(t, "./tests/screentest/run.sh", "-rm", "ci", "-concurrency", "1")
})
return
}
t.Run("NPM", func(t *testing.T) {
t.Setenv("CI", "true")
if !runStage(t, "Install", "./devtools/nodejs.sh", "npm", "ci") {
return
}
runStage(t, "Lint", "./devtools/nodejs.sh", "npm", "run", "lint")
runStage(t, "Test", "./devtools/nodejs.sh", "npm", "run", "test")
})
runStage(t, "AllBash", "./devtools/docker/compose.sh", "run", "--rm", "allbash", "ci")
runStage(t, "SearchTest", "./tests/search/run.sh")
}
// composeDownTimeout bounds teardown, which must not hang a build.
const composeDownTimeout = 5 * time.Minute
// composeDown removes the containers, volumes and networks of the compose
// project used by the CI stages.
func composeDown(t *testing.T) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), composeDownTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "./devtools/docker/compose.sh",
"down", "--volumes", "--remove-orphans")
if out, err := cmd.CombinedOutput(); err != nil {
t.Logf("compose down failed; containers may be left behind for the next build on this machine: %v\n%s", err, out)
}
}
// chownRepo resets ownership of files created by root inside Docker containers
// (such as node_modules) back to the host user so Swarming can delete the workdir.
func chownRepo(t *testing.T) {
if runtime.GOOS != "linux" {
return
}
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), composeDownTimeout)
defer cancel()
ug := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())
cmd := exec.CommandContext(ctx, "./devtools/nodejs.sh", "chown", "-R", ug, "/pkgsite")
if out, err := cmd.CombinedOutput(); err != nil {
t.Logf("chown failed: %v\n%s", err, out)
}
}
// runStage runs a CI stage as a subtest, streaming its output into the test
// log, and reports whether the subtest passed.
func runStage(t *testing.T, stage, name string, args ...string) bool {
t.Helper()
return t.Run(stage, func(t *testing.T) {
t.Helper()
runCmd(t, name, args...)
})
}
// runCmd streams a command's output into the test log. t.Output is line
// buffered, so that the output of a stage is attributed to the test and appears
// as it is produced rather than only once the stage finishes.
func runCmd(t *testing.T, name string, args ...string) {
t.Helper()
t.Logf("$ %s %s", name, strings.Join(args, " "))
cmd := exec.CommandContext(t.Context(), name, args...)
cmd.Stdout = t.Output()
cmd.Stderr = t.Output()
if err := cmd.Run(); err != nil {
t.Fatalf("%s: %v", name, err)
}
}