// Package testsuite provides many quick-and-dirty integration testing utilities. // // Attempting to import this package outside testing causes the resulting // program to panic. package testsuite import ( "bufio" "context" "crypto/sha512" "errors" "log" "os" "os/exec" "os/signal" "sync" "syscall" "time" ) // ReceiveSignals blocks until a termination signal arrives, and terminates. func ReceiveSignals() { s := make(chan os.Signal, 3) signal.Notify(s, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) log.Fatalf("terminating on signal %s", <-s) } // MustRun runs command and terminates the testsuite on error. func MustRun(cred *syscall.Credential, extraEnv []string, command ...string) { cmd := exec.Command(command[0], command[1:]...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr cmd.SysProcAttr = &syscall.SysProcAttr{ Pdeathsig: syscall.SIGKILL, Credential: cred, } if len(extraEnv) != 0 { cmd.Env = append(cmd.Environ(), extraEnv...) } if err := cmd.Run(); err != nil { log.Fatal(err) } } // ErrUnexpectedSuccess is returned for processes expected to exit with a // non-zero code, but failed to do so. var ErrUnexpectedSuccess = errors.New("process unexpectedly exited with code 0") // MustFail runs command and terminates the testsuite if the program fails to // start or exits with code 0. func MustFail(cred *syscall.Credential, extraEnv []string, command ...string) { cmd := exec.Command(command[0], command[1:]...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr cmd.SysProcAttr = &syscall.SysProcAttr{ Pdeathsig: syscall.SIGKILL, Credential: cred, } if len(extraEnv) != 0 { cmd.Env = append(cmd.Environ(), extraEnv...) } if err := cmd.Run(); err == nil { log.Fatal(ErrUnexpectedSuccess) } else if e, ok := errors.AsType[*exec.ExitError](err); !ok { log.Fatal(err) } else if !e.Exited() { log.Fatal(e) } } // MustStart starts cmd and returns a channel delivering its wait error. func MustStart(cmd *exec.Cmd) (done <-chan error) { if err := cmd.Start(); err != nil { log.Fatal(err) } d := make(chan error) go func() { d <- cmd.Wait() }() return d } // MustStartWith wraps [MustStart] and creates the [exec.Cmd] object internally. func MustStartWith( ctx context.Context, cred *syscall.Credential, extraEnv []string, files []*os.File, command ...string, ) (proc *os.Process, done <-chan error) { cmd := exec.CommandContext(ctx, command[0], command[1:]...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr cmd.ExtraFiles = files cmd.SysProcAttr = &syscall.SysProcAttr{ Pdeathsig: syscall.SIGTERM, Credential: cred, } if len(extraEnv) != 0 { cmd.Env = append(cmd.Environ(), extraEnv...) } return cmd.Process, MustStart(cmd) } // MustCheckFilter is like [CheckFilter], but terminates the test suite if a // non-nil error is returned. Otherwise, the tracee is terminated after it // resumes. func MustCheckFilter(pid int, sum [sha512.Size]byte) { // podman installs its own filter if err := CheckFilter(pid, 1, sum); err != nil { log.Fatal(err) } else if err = syscall.Kill(pid, syscall.SIGTERM); err != nil { log.Fatalf("cannot terminate tracee: %v", err) } } // FilterTerminated returns a non-nil error if err is not an [exec.ExitError] // describing a process terminated by a syscall.SIGTERM signal. func FilterTerminated(err error) error { if err == nil { return ErrUnexpectedSuccess } e, ok := errors.AsType[*exec.ExitError](err) if !ok { return err } if e.ExitCode() == 0x80+int(syscall.SIGTERM) { return nil } return e } // Poll repeatedly runs command until it succeeds. func Poll( d time.Duration, cred *syscall.Credential, extraEnv []string, command ...string, ) { for range time.NewTicker(d).C { cmd := exec.Command(command[0], command[1:]...) cmd.SysProcAttr = &syscall.SysProcAttr{ Pdeathsig: syscall.SIGKILL, Credential: cred, } if len(extraEnv) != 0 { cmd.Env = append(cmd.Environ(), extraEnv...) } if err := cmd.Run(); err != nil { if e, ok := errors.AsType[*exec.ExitError](err); ok && e.Exited() { continue } log.Fatal(err) } break } } const ( // XDGRuntimeDir is the hardcoded XDG runtime directory for the user // described by [GetUser]. XDGRuntimeDir = "/var/run/user/1000" // XDGRuntimeEnv is the environment variable string for XDG_RUNTIME_DIR. XDGRuntimeEnv = "XDG_RUNTIME_DIR=" + XDGRuntimeDir ) // MustStartSessionBus starts a session bus that is never explicitly terminated. // The test suite is terminated if the session bus daemon terminates. func MustStartSessionBus(cred *syscall.Credential) (dbusEnv string) { r, w, err := os.Pipe() if err != nil { log.Fatal(err) } // this is never explicitly terminated _, done := MustStartWith( context.Background(), cred, nil, []*os.File{w}, "dbus-daemon", "--print-address=3", "--address=unix:path="+XDGRuntimeDir+"/dbus", "--session", "--nofork", "--nopidfile", ) go func() { if _err := <-done; _err != nil { log.Fatal(_err) } log.Fatal("session bus terminated unexpectedly") }() dbusEnv, err = bufio.NewReader(r).ReadString('\n') if err != nil { log.Fatal(err) } dbusEnv = dbusEnv[:len(dbusEnv)-1] log.Printf("dbus listening on %s", dbusEnv) dbusEnv = "DBUS_SESSION_BUS_ADDRESS=" + dbusEnv if err = r.Close(); err != nil { log.Fatal(err) } return } const ( // SwayEnv is the environment variable string for the sway IPC socket. SwayEnv = "SWAYSOCK=" + XDGRuntimeDir + "/sway" // WaylandEnv is the environment variable string for the wayland display. WaylandEnv = "WAYLAND_DISPLAY=wayland-1" ) // MustStartSway starts the sway wayland display server which must be terminated // by calling [TerminateSway]. func MustStartSway( wg *sync.WaitGroup, cred *syscall.Credential, dbusEnv string, ) { wg.Go(func() { // this is terminated via swaymsg _, done := MustStartWith( context.Background(), cred, []string{ "WLR_BACKENDS=headless", XDGRuntimeEnv, SwayEnv, dbusEnv, }, nil, "sway", ) if err := <-done; err != nil { log.Fatal(err) } }) Poll( 50*time.Millisecond, cred, []string{SwayEnv}, "swaymsg", ) log.Printf("sway available via %s", SwayEnv) } // TerminateSway requests for the sway server to terminate via sway IPC. func TerminateSway(cred *syscall.Credential) { MustFail(cred, []string{SwayEnv}, "swaymsg", "exit") } // MustStartPipeWire starts a PipeWire server that is never explicitly // terminated. The test suite is terminated if the PipeWire server terminates. func MustStartPipeWire(cred *syscall.Credential, dbusEnv string) { // this is never explicitly terminated _, done := MustStartWith( context.Background(), cred, []string{ XDGRuntimeEnv, dbusEnv, }, nil, "pipewire", ) go func() { if _err := <-done; _err != nil { log.Fatal(_err) } log.Fatal("pipewire terminated unexpectedly") }() Poll(50*time.Millisecond, cred, []string{ XDGRuntimeEnv, dbusEnv, }, "wpctl", "status", ) _, _done := MustStartWith( context.Background(), cred, []string{ XDGRuntimeEnv, dbusEnv, }, nil, "wireplumber", ) go func() { if _err := <-_done; _err != nil { log.Fatal(_err) } log.Fatal("wireplumber terminated unexpectedly") }() }