diff options
| author | Ophestra <cat@gensokyo.uk> | 2026-10-06 22:23:20 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2026-10-06 23:09:19 +0900 |
| commit | a9e2749f6654d0aa07b274a45c9177d10323f80a (patch) | |
| tree | 291f5b23a67af036cbe5d374b3d3fb0240fc438c /internal/testsuite/testsuite.go | |
| parent | 19f36491f2e2a5029ac396c10408d653cad6c81b (diff) | |
internal/testsuite: move from test
This structure is a lot less clumsy than the old nix-centric layout.
Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'internal/testsuite/testsuite.go')
| -rw-r--r-- | internal/testsuite/testsuite.go | 290 |
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") + }() +} |
