blob: 2f87fd2e838f570d1d7055595a7ebcc3718fe150 [file]
// 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.
package workflowtest
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
wf "golang.org/x/build/internal/workflow"
)
type Listener struct {
addr net.Addr
ch chan net.Conn
done chan struct{}
once sync.Once
}
var nextPort atomic.Int64
func NewListener() *Listener {
return &Listener{
addr: net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), uint16(10000+nextPort.Add(1)))),
ch: make(chan net.Conn),
done: make(chan struct{}),
}
}
func (l *Listener) Accept() (net.Conn, error) {
select {
case c := <-l.ch:
return c, nil
case <-l.done:
return nil, net.ErrClosed
}
}
func (l *Listener) Close() error {
l.once.Do(func() { close(l.done) })
return nil
}
func (l *Listener) Addr() net.Addr { return l.addr }
func (l *Listener) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
server, client := net.Pipe()
select {
case l.ch <- server:
return client, nil
case <-l.done:
server.Close()
client.Close()
return nil, net.ErrClosed
case <-ctx.Done():
server.Close()
client.Close()
return nil, ctx.Err()
}
}
func NewClient(listeners ...*Listener) *http.Client {
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
for _, l := range listeners {
if l.Addr().String() == addr {
return l.DialContext(ctx, network, addr)
}
}
return nil, fmt.Errorf("no listener for address %q", addr)
},
},
}
}
// NewInMemoryServer provides the necessary abstraction to
// cleanly use synctest and httptest.
//
// TODO(nealpatel): Remove these abstractions once x/build
// uses go1.27.
func NewInMemoryServer(handler http.Handler) (url string, client *http.Client, cleanup func()) {
li := NewListener()
srv := &http.Server{Handler: handler}
go srv.Serve(li)
return "http://" + li.Addr().String(), NewClient(li), func() { srv.Close() }
}
type Logger struct {
T testing.TB
Task string
}
func (l *Logger) Printf(format string, v ...any) {
l.T.Logf("%v\ttask %-10v: LOG: %s", time.Now(), l.Task, fmt.Sprintf(format, v...))
}
type VerboseListener struct {
T testing.TB
OutputListener func(string, any)
OnStall func() error
}
func (l *VerboseListener) WorkflowStalled(workflowID uuid.UUID) error {
l.T.Logf("workflow %q: stalled", workflowID.String())
if l.OnStall != nil {
return l.OnStall()
}
return fmt.Errorf("workflow %s stalled with no OnStall handler", workflowID)
}
func (l *VerboseListener) TaskStateChanged(_ uuid.UUID, _ string, st *wf.TaskState) error {
switch {
case !st.Finished:
l.T.Logf("task %-10v: started", st.Name)
case st.Error != "":
l.T.Logf("task %-10v: error: %v", st.Name, st.Error)
default:
l.T.Logf("task %-10v: done: %v", st.Name, st.Result)
if l.OutputListener != nil {
l.OutputListener(st.Name, st.Result)
}
}
return nil
}
func (l *VerboseListener) Logger(_ uuid.UUID, task string) wf.Logger {
return &Logger{T: l.T, Task: task}
}
type ErrorListener struct {
TaskName string
Callback func(string)
wf.Listener
}
func (l *ErrorListener) TaskStateChanged(id uuid.UUID, taskID string, st *wf.TaskState) error {
if st.Name == l.TaskName && st.Finished && st.Error != "" {
l.Callback(st.Error)
}
return l.Listener.TaskStateChanged(id, taskID, st)
}
type CapturingLogger struct {
Lines []string
}
func (l *CapturingLogger) Printf(format string, v ...any) {
l.Lines = append(l.Lines, fmt.Sprintf(format, v...))
}
type LoggerListener struct {
wf.Listener
Log wf.Logger
}
func (l *LoggerListener) Logger(_ uuid.UUID, _ string) wf.Logger {
return l.Log
}
type MapListener struct {
wf.Listener
States map[uuid.UUID]map[string]*wf.TaskState
}
func (l *MapListener) TaskStateChanged(workflowID uuid.UUID, taskID string, state *wf.TaskState) error {
if l.States == nil {
l.States = map[uuid.UUID]map[string]*wf.TaskState{}
}
if l.States[workflowID] == nil {
l.States[workflowID] = map[string]*wf.TaskState{}
}
l.States[workflowID][taskID] = state
return l.Listener.TaskStateChanged(workflowID, taskID, state)
}
func (l *MapListener) AssertState(t *testing.T, w *wf.Workflow, want map[string]*wf.TaskState) {
t.Helper()
if diff := cmp.Diff(l.States[w.ID], want, cmpopts.IgnoreFields(wf.TaskState{}, "SerializedResult")); diff != "" {
t.Errorf("task state didn't match expectations: %v", diff)
}
}
// Subtest shadows [synctest.Subtest] behavior in go1.27+.
func Subtest(t *testing.T, name string, f func(*testing.T)) {
t.Helper()
t.Run(name, func(t *testing.T) {
t.Helper()
synctest.Test(t, f)
})
}
func Start(t testing.TB, wd *wf.Definition, params map[string]any) *wf.Workflow {
t.Helper()
w, err := wf.Start(wd, params)
if err != nil {
t.Fatal(err)
}
return w
}
// Run runs w to completion and returns its outputs, failing t on error.
// If listener is nil, a [VerboseListener] logging to t is used.
func Run(t testing.TB, ctx context.Context, w *wf.Workflow, listener wf.Listener) map[string]any {
t.Helper()
if listener == nil {
listener = &VerboseListener{T: t}
}
outputs, err := w.Run(ctx, listener)
if err != nil {
t.Fatalf("w.Run() = _, %v, wanted no error", err)
}
return outputs
}
func RunToFailure(t testing.TB, ctx context.Context, w *wf.Workflow, task string, listener wf.Listener) string {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
t.Helper()
var message string
l := &ErrorListener{
TaskName: task,
Callback: func(m string) {
message = m
cancel()
},
Listener: listener,
}
_, err := w.Run(ctx, l)
if err == nil {
t.Fatalf("workflow unexpectedly succeeded")
}
return message
}