| // 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) |
| } |
| }) |
| } |
| } |