blob: 59bafa8352d6336eda431bd131d0ef3eb8bbae33 [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package webui
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
"github.com/google/go-cmp/cmp"
"go.chromium.org/build/siso/build/ninjabuild"
)
func TestServer_MissingWorkspace(t *testing.T) {
dir := t.TempDir()
dir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
t.Chdir(dir)
_, err = NewServer(t.Context(), ServerConfig{
Version: "test-version",
LocalDevelopment: false,
Port: 8080,
OutDir: ninjabuild.DirFlag{
Dir: "non_existent_dir",
ConfigRepoDir: "build/config/siso",
},
ManifestPath: "build.ninja",
})
if err == nil {
t.Fatal("server got err = nil; want error")
}
if _, ok := errors.AsType[*ErrWorkspaceNotExist](err); !ok {
t.Fatalf("server got err = %v; want ErrWorkspaceNotExist", err)
}
}
func mustServer(ctx context.Context, t *testing.T) (*WebuiServer, string) {
t.Helper()
dir := t.TempDir()
dir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := os.MkdirAll("build/config/siso", 0755); err != nil {
t.Fatal(err)
}
// Chromium style outdir.
if err := os.MkdirAll("out/Default", 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("out/Default/build.ninja", []byte(`rule cc
command = clang++
rule link
command = ld
build foo.o: cc foo.c
build foo: link foo.o
build all: phony foo`), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("out/Default/siso_metrics.json", []byte(`{"build_id": "test-rev"}
{"step_id": "step-1", "rule": "cc", "action": "clang", "outputs": ["out1.o"]}
`), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("out/Default/siso_metadata.json", []byte(`
{
"build_id": "test-rev",
"siso_version": "0.0.1",
"targets": ["all"],
"machine": {"platform": {"os": "linux", "architecture": "amd64"}, "cpu": {"brand": "Intel CPU", "logical_cores": 8, "physical_cores": 4}, "memory": {"total": 17179869184}}
}`), 0644); err != nil {
t.Fatal(err)
}
for file, content := range map[string]string{
"out/Default/.siso_config": "siso_config default",
"out/Default/.siso_filegroups": "siso_filegroups default",
"out/Default/siso_localexec": "siso_localexec default",
"out/Default/siso_output": "siso_output default",
"out/Default/siso_trace.json": `{"trace": "default"}`,
} {
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
// Chromium style outdir with characters that will be URL-encoded.
if err := os.MkdirAll("out/Default final v2 (1)", 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("out/Default final v2 (1)/build.ninja", []byte(""), 0644); err != nil {
t.Fatal(err)
}
// ChromiumOS style outdir.
// https://chromium.googlesource.com/chromium/src/+/18f03121b045467feac4bcc30f51b7dbf28e5a64/docs/chromeos_build_instructions.md#building-for-the-board
if err := os.MkdirAll("out_amd64-generic/Release", 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("out_amd64-generic/Release/build.ninja", []byte(""), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("out_amd64-generic/Release/siso_metrics.json", []byte(`{"build_id": "cros-rev"}
{"step_id": "step-1", "rule": "cc", "action": "clang", "outputs": ["out2.o"]}
`), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("out_amd64-generic/Release/.siso_config", []byte("siso_config cros"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("out_amd64-generic/Release/siso_output", []byte("siso_output cros"), 0644); err != nil {
t.Fatal(err)
}
// Outdir that is not scanned by default (glob for out*/* only).
if err := os.MkdirAll("tmp-out/Default", 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("tmp-out/Default/build.ninja", []byte(""), 0644); err != nil {
t.Fatal(err)
}
// Now init the server.
s, err := NewServer(ctx, ServerConfig{
Version: "test-version",
LocalDevelopment: false,
Port: 8080,
OutDir: ninjabuild.DirFlag{
Dir: "out/Default",
ConfigRepoDir: "build/config/siso",
},
ManifestPath: "build.ninja",
})
if err != nil {
t.Fatalf("server err = %v; want nil err", err)
}
return s, dir
}
func TestServer_InitialState(t *testing.T) {
s, tmp := mustServer(t.Context(), t)
if s.workspaceRoot != tmp {
t.Errorf("workspaceRoot = %q; want %q", s.workspaceRoot, tmp)
}
if s.defaultOutdir != "out/Default" {
t.Errorf("defaultOutdir = %q; want %q", s.defaultOutdir, "out/Default")
}
}
func TestRoutes_Outdirs(t *testing.T) {
s, _ := mustServer(t.Context(), t)
for _, tc := range []struct {
path string
want int
}{
{"/out/Default/builds/", http.StatusOK},
{"/out/Default/builds/test-rev/steps/", http.StatusOK},
{"/out/Default/builds/test-rev/steps/step-1/", http.StatusOK},
{"/out/Default/builds/test-rev/steps/step-0/", http.StatusNotFound},
{"/out/Default/builds/nonexistent-rev/steps/step-1/", http.StatusNotFound},
{"/out/Default/builds/cros-rev/steps/step-1/", http.StatusNotFound},
{"/out/Default/builds/test-rev/details/", http.StatusOK},
{"/out/Default/builds/test-rev/aggregates/", http.StatusOK},
{"/out/Default/targets/all/", http.StatusOK},
{"/out/Default/targets/foo.o/", http.StatusOK},
{"/out/Default/targets/nonexistent.o/", http.StatusNotFound},
{"/out/Default/builds/test-rev/logs/.siso_config", http.StatusOK},
{"/out/Default/builds/test-rev/logs/.siso_filegroups", http.StatusOK},
{"/out/Default/builds/test-rev/logs/siso_localexec", http.StatusOK},
{"/out/Default/builds/test-rev/logs/siso_output", http.StatusOK},
{"/out/Default/builds/test-rev/logs/siso_trace.json", http.StatusOK},
{"/out/Default/builds/test-rev/logs/unknown_file", http.StatusNotFound},
{"/out/Default/builds/nonexistent-rev/logs/.siso_config", http.StatusNotFound},
{"/out_amd64-generic/Release/builds/cros-rev/steps/step-1/", http.StatusOK},
{"/out_amd64-generic/Release/builds/cros-rev/details/", http.StatusOK},
{"/out_amd64-generic/Release/builds/cros-rev/logs/.siso_config", http.StatusOK},
{"/out_amd64-generic/Release/builds/cros-rev/logs/siso_output", http.StatusOK},
} {
rec := httptest.NewRecorder()
s.mux().ServeHTTP(rec, httptest.NewRequest("GET", tc.path, nil))
if rec.Code != tc.want {
t.Errorf("GET %s = %d; want %d", tc.path, rec.Code, tc.want)
}
}
}
func TestRoutes_InvocationSeriesList(t *testing.T) {
s, _ := mustServer(t.Context(), t)
rec := httptest.NewRecorder()
s.mux().ServeHTTP(rec, httptest.NewRequest("GET", "/out/Default/builds/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET /out/Default/builds/ = %d; want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "test-rev") {
t.Errorf("body missing test-rev: %s", body)
}
if !strings.Contains(body, "Invocations") {
t.Errorf("body missing Invocations title: %s", body)
}
}
func TestRoutes_UploadedMetrics(t *testing.T) {
s, _ := mustServer(t.Context(), t)
tmp := t.TempDir()
metricsFile := filepath.Join(tmp, "my_custom_metrics.json")
if err := os.WriteFile(metricsFile, []byte(`{"build_id": "uploaded-build-id"}
{"step_id": "step-1", "rule": "cc", "action": "clang", "outputs": ["out.o"]}
`), 0644); err != nil {
t.Fatalf("failed to write standalone metrics: %v", err)
}
if err := s.LoadStandaloneMetrics(metricsFile); err != nil {
t.Fatalf("failed to load standalone metrics: %v", err)
}
for _, tc := range []struct {
path string
want int
}{
{"/uploads/view/builds/", http.StatusOK},
{"/uploads/view/builds/uploaded-build-id/steps/", http.StatusOK},
{"/uploads/view/builds/uploaded-build-id/steps/step-1/", http.StatusOK},
{"/uploads/view/builds/uploaded-build-id/details/", http.StatusOK},
{"/uploads/view/builds/nonexistent-rev/steps/", http.StatusNotFound},
{"/uploads/view/builds/uploaded-build-id/aggregates/", http.StatusOK},
} {
rec := httptest.NewRecorder()
s.mux().ServeHTTP(rec, httptest.NewRequest("GET", tc.path, nil))
if rec.Code != tc.want {
t.Errorf("GET %s = %d; want %d", tc.path, rec.Code, tc.want)
}
}
}
func TestRedirects(t *testing.T) {
s, _ := mustServer(t.Context(), t)
for _, tc := range []struct {
path string
want string
}{
{"/", "/out/Default/"},
{"/out/Default/", "/out/Default/builds/test-rev/steps/"},
{"/out/Default/targets/", "/out/Default/targets/all/"},
{"/out/Default/reload", "/out/Default"},
{"/out/Default/builds/test-rev/logs/", "/out/Default/builds/test-rev/logs/.siso_config"},
{"/out_amd64-generic/Release/builds/cros-rev/logs/", "/out_amd64-generic/Release/builds/cros-rev/logs/.siso_config"},
} {
rec := httptest.NewRecorder()
s.mux().ServeHTTP(rec, httptest.NewRequest("GET", tc.path, nil))
if rec.Code != http.StatusTemporaryRedirect {
t.Errorf("GET %s = %d; want %d", tc.path, rec.Code, http.StatusTemporaryRedirect)
continue
}
if got := rec.Header().Get("Location"); got != tc.want {
t.Errorf("GET %s redirect = %q; want %q", tc.path, got, tc.want)
}
}
}
func TestRoutes_ViewLog(t *testing.T) {
s, _ := mustServer(t.Context(), t)
for _, tc := range []struct {
path, want string
}{
{"/out/Default/builds/test-rev/logs/siso_output", "siso_output default"},
{"/out_amd64-generic/Release/builds/cros-rev/logs/siso_output", "siso_output cros"},
} {
// Test the HTML page.
t.Run(tc.path, func(t *testing.T) {
rec := httptest.NewRecorder()
s.mux().ServeHTTP(rec, httptest.NewRequest("GET", tc.path, nil))
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d; want %d", tc.path, rec.Code, http.StatusOK)
}
if !strings.Contains(rec.Body.String(), tc.want) {
t.Errorf("GET %s body = %q; want it to contain %q", tc.path, rec.Body.String(), tc.want)
}
})
// Test the raw file.
t.Run(tc.path+"?raw=true", func(t *testing.T) {
rec := httptest.NewRecorder()
s.mux().ServeHTTP(rec, httptest.NewRequest("GET", tc.path+"?raw=true", nil))
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d; want %d", tc.path, rec.Code, http.StatusOK)
}
if got := rec.Body.String(); got != tc.want {
t.Errorf("GET %s body = %q; want %q", tc.path, got, tc.want)
}
})
}
}
// Test that outdir URLs are rendered correctly.
// We apply custom escaping in the template, so unit testing the data model is inadequate.
// TODO: test for android-style outdirs as well?
func TestOutdirMenu_RendersCorrectURLs(t *testing.T) {
s, _ := mustServer(t.Context(), t)
rec := httptest.NewRecorder()
s.mux().ServeHTTP(rec, httptest.NewRequest("GET", "/out/Default/builds/test-rev/steps/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET steps = %d; want 200", rec.Code)
}
items := make(map[string]string)
doc, err := goquery.NewDocumentFromReader(rec.Body)
if err != nil {
t.Fatalf("failed to parse page: %v", err)
}
doc.Find("#invocation-series-menu md-menu-item").Each(func(_ int, menuItem *goquery.Selection) {
href, _ := menuItem.Attr("href")
name := strings.TrimSpace(menuItem.Text())
items[name] = href
})
// The slash between out/Default must be left unescaped.
want := map[string]string{
"out/Default": "/out/Default/",
"out/Default final v2 (1)": "/out/Default%20final%20v2%20%281%29/",
"out_amd64-generic/Release": "/out_amd64-generic/Release/",
// Other outdirs not included (glob for out*/* only).
}
if diff := cmp.Diff(want, items); diff != "" {
t.Errorf("outdir menu mismatch (-want +got):\n%s", diff)
}
}
func TestBreadcrumbs(t *testing.T) {
s, tmp := mustServer(t.Context(), t)
// Since the outdir path is absolute and dynamically generated via t.TempDir(),
// we determine the expected outdir abbrev label dynamically.
outdirAbbrev := filepath.Join(tmp, "out/Default")
for _, tc := range []struct {
path string
want []string
}{
{"/out/Default/builds/", []string{outdirAbbrev, "Invocations"}},
{"/out/Default/builds/test-rev/steps/", []string{outdirAbbrev, "Invocation test-rev", "Build Steps"}},
{"/out/Default/builds/test-rev/steps/step-1/", []string{outdirAbbrev, "Invocation test-rev", "Build Steps", "out1.o"}},
{"/out/Default/builds/test-rev/details/", []string{outdirAbbrev, "Invocation test-rev", "Details"}},
{"/out/Default/builds/test-rev/aggregates/", []string{outdirAbbrev, "Invocation test-rev", "Aggregates"}},
{"/out/Default/targets/all/", []string{outdirAbbrev, "Targets", "all"}},
{"/out/Default/targets/foo.o/", []string{outdirAbbrev, "Targets", "foo.o"}},
{"/out/Default/builds/test-rev/logs/.siso_config", []string{outdirAbbrev, "Invocation test-rev", "Raw Logs", ".siso_config"}},
{"/out/Default/runbuild/", []string{outdirAbbrev, "Run Build"}},
{"/out/Default/watch/", []string{outdirAbbrev, "Watch"}},
} {
t.Run(tc.path, func(t *testing.T) {
rec := httptest.NewRecorder()
s.mux().ServeHTTP(rec, httptest.NewRequest("GET", tc.path, nil))
doc, err := goquery.NewDocumentFromReader(rec.Body)
if err != nil {
t.Fatalf("failed to parse HTML: %v", err)
}
var got []string
doc.Find("#breadcrumbs .breadcrumb-label").Each(func(_ int, s *goquery.Selection) {
got = append(got, strings.TrimSpace(s.Text()))
})
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("breadcrumbs mismatch for %s (-want +got):\n%s", tc.path, diff)
}
})
}
}