aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/testsuite/testsuite.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/testsuite/testsuite.go')
-rw-r--r--internal/testsuite/testsuite.go290
1 files changed, 290 insertions, 0 deletions
diff --git a/internal/testsuite/testsuite.go b/internal/testsuite/testsuite.go
new file mode 100644
index 00000000..456c36b4
--- /dev/null
+++ b/internal/testsuite/testsuite.go
@@ -0,0 +1,290 @@
+// 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")
+ }()
+}