From bac583f89edd1360150143ff51f7264c01aa69ec Mon Sep 17 00:00:00 2001 From: Ophestra Date: Tue, 17 Mar 2026 16:09:14 +0900 Subject: internal/stub: move from container This package solves a very specific stubbing use case, in a less than elegant manner. Signed-off-by: Ophestra --- cmd/hakurei/json_test.go | 2 +- container/autoetc_test.go | 2 +- container/autoroot_test.go | 2 +- container/dispatcher_test.go | 2 +- container/errors_test.go | 2 +- container/init_test.go | 2 +- container/initbind_test.go | 2 +- container/initdaemon_test.go | 2 +- container/initdev_test.go | 2 +- container/initmkdir_test.go | 2 +- container/initoverlay_test.go | 2 +- container/initplace_test.go | 2 +- container/initproc_test.go | 2 +- container/initremount_test.go | 2 +- container/initsymlink_test.go | 2 +- container/inittmpfs_test.go | 2 +- container/mount_test.go | 2 +- container/stub/call.go | 37 ---- container/stub/call_test.go | 27 --- container/stub/errors.go | 25 --- container/stub/errors_test.go | 42 ----- container/stub/exit.go | 44 ----- container/stub/exit_test.go | 111 ------------ container/stub/stub.go | 153 ---------------- container/stub/stub_test.go | 335 ----------------------------------- hst/hst_test.go | 2 +- internal/env/env_test.go | 2 +- internal/outcome/dispatcher_test.go | 2 +- internal/outcome/hsu_test.go | 2 +- internal/outcome/shim_test.go | 2 +- internal/outcome/spaccount_test.go | 2 +- internal/outcome/spcontainer_test.go | 2 +- internal/outcome/spdbus_test.go | 2 +- internal/outcome/sppipewire_test.go | 2 +- internal/outcome/sppulse_test.go | 2 +- internal/outcome/spruntime_test.go | 2 +- internal/outcome/sptmpdir_test.go | 2 +- internal/outcome/spwayland_test.go | 2 +- internal/outcome/spx11_test.go | 2 +- internal/pipewire/pipewire_test.go | 2 +- internal/pkg/exec_test.go | 2 +- internal/pkg/pkg_test.go | 2 +- internal/pkg/tar_test.go | 2 +- internal/store/data_test.go | 2 +- internal/store/segment_test.go | 2 +- internal/stub/call.go | 37 ++++ internal/stub/call_test.go | 27 +++ internal/stub/errors.go | 28 +++ internal/stub/errors_test.go | 42 +++++ internal/stub/exit.go | 44 +++++ internal/stub/exit_test.go | 111 ++++++++++++ internal/stub/stub.go | 153 ++++++++++++++++ internal/stub/stub_test.go | 335 +++++++++++++++++++++++++++++++++++ internal/system/acl_test.go | 2 +- internal/system/dbus_test.go | 2 +- internal/system/dispatcher_test.go | 2 +- internal/system/link_test.go | 2 +- internal/system/mkdir_test.go | 2 +- internal/system/pipewire_test.go | 2 +- internal/system/system_test.go | 2 +- internal/system/wayland_test.go | 2 +- internal/system/xhost_test.go | 2 +- internal/wayland/wayland_test.go | 2 +- message/message_test.go | 2 +- message/output_test.go | 2 +- 65 files changed, 826 insertions(+), 823 deletions(-) delete mode 100644 container/stub/call.go delete mode 100644 container/stub/call_test.go delete mode 100644 container/stub/errors.go delete mode 100644 container/stub/errors_test.go delete mode 100644 container/stub/exit.go delete mode 100644 container/stub/exit_test.go delete mode 100644 container/stub/stub.go delete mode 100644 container/stub/stub_test.go create mode 100644 internal/stub/call.go create mode 100644 internal/stub/call_test.go create mode 100644 internal/stub/errors.go create mode 100644 internal/stub/errors_test.go create mode 100644 internal/stub/exit.go create mode 100644 internal/stub/exit_test.go create mode 100644 internal/stub/stub.go create mode 100644 internal/stub/stub_test.go diff --git a/cmd/hakurei/json_test.go b/cmd/hakurei/json_test.go index 13e7e9f3..c6649567 100644 --- a/cmd/hakurei/json_test.go +++ b/cmd/hakurei/json_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestDecodeJSON(t *testing.T) { diff --git a/container/autoetc_test.go b/container/autoetc_test.go index 232413b5..d76309fd 100644 --- a/container/autoetc_test.go +++ b/container/autoetc_test.go @@ -6,7 +6,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestAutoEtcOp(t *testing.T) { diff --git a/container/autoroot_test.go b/container/autoroot_test.go index 282a41fb..0692f743 100644 --- a/container/autoroot_test.go +++ b/container/autoroot_test.go @@ -7,7 +7,7 @@ import ( "hakurei.app/check" "hakurei.app/container/std" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" "hakurei.app/message" ) diff --git a/container/dispatcher_test.go b/container/dispatcher_test.go index fe0c4dbc..ee2c3f19 100644 --- a/container/dispatcher_test.go +++ b/container/dispatcher_test.go @@ -18,7 +18,7 @@ import ( "hakurei.app/container/seccomp" "hakurei.app/container/std" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" "hakurei.app/message" ) diff --git a/container/errors_test.go b/container/errors_test.go index 27dafbcd..1cede407 100644 --- a/container/errors_test.go +++ b/container/errors_test.go @@ -9,7 +9,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" "hakurei.app/vfs" ) diff --git a/container/init_test.go b/container/init_test.go index 4f067fd5..02531e99 100644 --- a/container/init_test.go +++ b/container/init_test.go @@ -10,7 +10,7 @@ import ( "hakurei.app/check" "hakurei.app/container/seccomp" "hakurei.app/container/std" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestInitEntrypoint(t *testing.T) { diff --git a/container/initbind_test.go b/container/initbind_test.go index 7d1cc36b..02438105 100644 --- a/container/initbind_test.go +++ b/container/initbind_test.go @@ -8,7 +8,7 @@ import ( "hakurei.app/check" "hakurei.app/container/std" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestBindMountOp(t *testing.T) { diff --git a/container/initdaemon_test.go b/container/initdaemon_test.go index 262e6bde..0f75498e 100644 --- a/container/initdaemon_test.go +++ b/container/initdaemon_test.go @@ -5,7 +5,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" "hakurei.app/message" ) diff --git a/container/initdev_test.go b/container/initdev_test.go index 0566e9af..bcb7c751 100644 --- a/container/initdev_test.go +++ b/container/initdev_test.go @@ -5,7 +5,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestMountDevOp(t *testing.T) { diff --git a/container/initmkdir_test.go b/container/initmkdir_test.go index 65da693d..e9d04c25 100644 --- a/container/initmkdir_test.go +++ b/container/initmkdir_test.go @@ -5,7 +5,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestMkdirOp(t *testing.T) { diff --git a/container/initoverlay_test.go b/container/initoverlay_test.go index 4047e4f6..30dd3237 100644 --- a/container/initoverlay_test.go +++ b/container/initoverlay_test.go @@ -6,7 +6,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestMountOverlayOp(t *testing.T) { diff --git a/container/initplace_test.go b/container/initplace_test.go index dd2f982e..0640400b 100644 --- a/container/initplace_test.go +++ b/container/initplace_test.go @@ -5,7 +5,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestTmpfileOp(t *testing.T) { diff --git a/container/initproc_test.go b/container/initproc_test.go index 51311f02..dd8d93ec 100644 --- a/container/initproc_test.go +++ b/container/initproc_test.go @@ -5,7 +5,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestMountProcOp(t *testing.T) { diff --git a/container/initremount_test.go b/container/initremount_test.go index 3855c18d..bb06e863 100644 --- a/container/initremount_test.go +++ b/container/initremount_test.go @@ -5,7 +5,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestRemountOp(t *testing.T) { diff --git a/container/initsymlink_test.go b/container/initsymlink_test.go index 3a4144d3..6dd065a5 100644 --- a/container/initsymlink_test.go +++ b/container/initsymlink_test.go @@ -5,7 +5,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestSymlinkOp(t *testing.T) { diff --git a/container/inittmpfs_test.go b/container/inittmpfs_test.go index e8a36602..b016c79d 100644 --- a/container/inittmpfs_test.go +++ b/container/inittmpfs_test.go @@ -6,7 +6,7 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestMountTmpfsOp(t *testing.T) { diff --git a/container/mount_test.go b/container/mount_test.go index ffacf18e..36dc5ad9 100644 --- a/container/mount_test.go +++ b/container/mount_test.go @@ -5,7 +5,7 @@ import ( "syscall" "testing" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" "hakurei.app/vfs" ) diff --git a/container/stub/call.go b/container/stub/call.go deleted file mode 100644 index 585a69ef..00000000 --- a/container/stub/call.go +++ /dev/null @@ -1,37 +0,0 @@ -package stub - -import ( - "slices" -) - -// ExpectArgs is an array primarily for storing expected function arguments. -// Its actual use is defined by the implementation. -type ExpectArgs = [5]any - -// An Expect stores expected calls of a goroutine. -type Expect struct { - Calls []Call - - // Tracks are handed out to descendant goroutines in order. - Tracks []Expect -} - -// A Call holds expected arguments of a function call and its outcome. -type Call struct { - // Name is the function Name of this call. Must be unique. - Name string - // Args are the expected arguments of this Call. - Args ExpectArgs - // Ret is the return value of this Call. - Ret any - // Err is the returned error of this Call. - Err error -} - -// Error returns [Call.Err] if all arguments are true, or [ErrCheck] otherwise. -func (k *Call) Error(ok ...bool) error { - if !slices.Contains(ok, false) { - return k.Err - } - return ErrCheck -} diff --git a/container/stub/call_test.go b/container/stub/call_test.go deleted file mode 100644 index db0b59dc..00000000 --- a/container/stub/call_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package stub_test - -import ( - "reflect" - "testing" - - "hakurei.app/container/stub" -) - -func TestCallError(t *testing.T) { - t.Parallel() - - t.Run("contains false", func(t *testing.T) { - t.Parallel() - if err := new(stub.Call).Error(true, false, true); !reflect.DeepEqual(err, stub.ErrCheck) { - t.Errorf("Error: %#v, want %#v", err, stub.ErrCheck) - } - }) - - t.Run("passthrough", func(t *testing.T) { - t.Parallel() - wantErr := stub.UniqueError(0xbabe) - if err := (&stub.Call{Err: wantErr}).Error(true); !reflect.DeepEqual(err, wantErr) { - t.Errorf("Error: %#v, want %#v", err, wantErr) - } - }) -} diff --git a/container/stub/errors.go b/container/stub/errors.go deleted file mode 100644 index d97b6256..00000000 --- a/container/stub/errors.go +++ /dev/null @@ -1,25 +0,0 @@ -package stub - -import ( - "errors" - "strconv" -) - -var ( - ErrCheck = errors.New("one or more arguments did not match") -) - -// UniqueError is an error that only equivalates to other [UniqueError] with the same magic value. -type UniqueError uintptr - -func (e UniqueError) Error() string { - return "unique error " + strconv.FormatUint(uint64(e), 10) + " injected by the test suite" -} - -func (e UniqueError) Is(target error) bool { - var u UniqueError - if !errors.As(target, &u) { - return false - } - return e == u -} diff --git a/container/stub/errors_test.go b/container/stub/errors_test.go deleted file mode 100644 index 604841ac..00000000 --- a/container/stub/errors_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package stub_test - -import ( - "errors" - "syscall" - "testing" - - "hakurei.app/container/stub" -) - -func TestUniqueError(t *testing.T) { - t.Parallel() - - t.Run("format", func(t *testing.T) { - t.Parallel() - want := "unique error 2989 injected by the test suite" - if got := stub.UniqueError(0xbad).Error(); got != want { - t.Errorf("Error: %q, want %q", got, want) - } - }) - - t.Run("is", func(t *testing.T) { - t.Parallel() - - t.Run("type", func(t *testing.T) { - t.Parallel() - if errors.Is(stub.UniqueError(0), syscall.ENOTRECOVERABLE) { - t.Error("Is: unexpected true") - } - }) - - t.Run("val", func(t *testing.T) { - t.Parallel() - if errors.Is(stub.UniqueError(0), stub.UniqueError(1)) { - t.Error("Is: unexpected true") - } - if !errors.Is(stub.UniqueError(0xbad), stub.UniqueError(0xbad)) { - t.Error("Is: unexpected false") - } - }) - }) -} diff --git a/container/stub/exit.go b/container/stub/exit.go deleted file mode 100644 index 062ee2e0..00000000 --- a/container/stub/exit.go +++ /dev/null @@ -1,44 +0,0 @@ -package stub - -import "testing" - -// PanicExit is a magic panic value treated as a simulated exit. -const PanicExit = 0xdead - -const ( - panicFailNow = 0xcafe0 + iota - panicFatal - panicFatalf -) - -// HandleExit must be deferred before calling with the stub. -func HandleExit(t testing.TB) { - switch r := recover(); r { - case PanicExit: - break - - case panicFailNow: - t.FailNow() - - case panicFatal, panicFatalf, nil: - break - - default: - panic(r) - } -} - -// handleExitNew handles exits from goroutines created by [Stub.New]. -func handleExitNew(t testing.TB) { - switch r := recover(); r { - case PanicExit, panicFatal, panicFatalf, nil: - break - - case panicFailNow: - t.Fail() - break - - default: - panic(r) - } -} diff --git a/container/stub/exit_test.go b/container/stub/exit_test.go deleted file mode 100644 index 3e71c580..00000000 --- a/container/stub/exit_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package stub_test - -import ( - "testing" - _ "unsafe" // for go:linkname - - "hakurei.app/container/stub" -) - -// Made available here to check panic recovery behaviour. -// -//go:linkname handleExitNew hakurei.app/container/stub.handleExitNew -func handleExitNew(t testing.TB) - -// overrideTFailNow overrides the Fail and FailNow method. -type overrideTFailNow struct { - *testing.T - failNow bool - fail bool -} - -func (o *overrideTFailNow) FailNow() { - if o.failNow { - o.Errorf("attempted to FailNow twice") - } - o.failNow = true -} - -func (o *overrideTFailNow) Fail() { - if o.fail { - o.Errorf("attempted to Fail twice") - } - o.fail = true -} - -func TestHandleExit(t *testing.T) { - t.Parallel() - - t.Run("exit", func(t *testing.T) { - t.Parallel() - defer stub.HandleExit(t) - panic(stub.PanicExit) - }) - - t.Run("goexit", func(t *testing.T) { - t.Parallel() - - t.Run("FailNow", func(t *testing.T) { - t.Parallel() - - ot := &overrideTFailNow{T: t} - defer func() { - if !ot.failNow { - t.Errorf("FailNow was never called") - } - }() - defer stub.HandleExit(ot) - panic(0xcafe0) - }) - - t.Run("Fail", func(t *testing.T) { - t.Parallel() - - ot := &overrideTFailNow{T: t} - defer func() { - if !ot.fail { - t.Errorf("Fail was never called") - } - }() - defer handleExitNew(ot) - panic(0xcafe0) - }) - }) - - t.Run("nil", func(t *testing.T) { - t.Parallel() - defer stub.HandleExit(t) - }) - - t.Run("passthrough", func(t *testing.T) { - t.Parallel() - - t.Run("toplevel", func(t *testing.T) { - t.Parallel() - - defer func() { - want := 0xcafe - if r := recover(); r != want { - t.Errorf("recover: %v, want %v", r, want) - } - - }() - defer stub.HandleExit(t) - panic(0xcafe) - }) - - t.Run("new", func(t *testing.T) { - t.Parallel() - - defer func() { - want := 0xcafe - if r := recover(); r != want { - t.Errorf("recover: %v, want %v", r, want) - } - - }() - defer handleExitNew(t) - panic(0xcafe) - }) - }) -} diff --git a/container/stub/stub.go b/container/stub/stub.go deleted file mode 100644 index a945fc0a..00000000 --- a/container/stub/stub.go +++ /dev/null @@ -1,153 +0,0 @@ -// Package stub provides function call level stubbing and validation -// for library functions that are impossible to check otherwise. -package stub - -import ( - "reflect" - "sync" - "testing" -) - -// this should prevent stub from being inadvertently imported outside tests -var _ = func() { - if !testing.Testing() { - panic("stub imported while not in a test") - } -} - -const ( - // A CallSeparator denotes an injected separation between two groups of calls. - CallSeparator = "\x00" -) - -// A Stub is a collection of tracks of expected calls. -type Stub[K any] struct { - testing.TB - - // makeK creates a new K for a descendant [Stub]. - // This function may be called concurrently. - makeK func(s *Stub[K]) K - - // want is a hierarchy of expected calls. - want Expect - // pos is the current position in [Expect.Calls]. - pos int - // goroutine counts the number of goroutines created by this [Stub]. - goroutine int - // sub stores the addresses of descendant [Stub] created by New. - sub []*Stub[K] - // wg waits for all descendants to complete. - wg *sync.WaitGroup -} - -// New creates a root [Stub]. -func New[K any](tb testing.TB, makeK func(s *Stub[K]) K, want Expect) *Stub[K] { - return &Stub[K]{TB: tb, makeK: makeK, want: want, wg: new(sync.WaitGroup)} -} - -func (s *Stub[K]) FailNow() { s.Helper(); panic(panicFailNow) } -func (s *Stub[K]) Fatal(args ...any) { s.Helper(); s.Error(args...); panic(panicFatal) } -func (s *Stub[K]) Fatalf(format string, args ...any) { - s.Helper() - s.Errorf(format, args...) - panic(panicFatalf) -} - -func (s *Stub[K]) SkipNow() { s.Helper(); panic("invalid call to SkipNow") } -func (s *Stub[K]) Skip(...any) { s.Helper(); panic("invalid call to Skip") } -func (s *Stub[K]) Skipf(string, ...any) { s.Helper(); panic("invalid call to Skipf") } - -// New calls f in a new goroutine -func (s *Stub[K]) New(f func(k K)) { - s.Helper() - - s.Expects("New") - if len(s.want.Tracks) <= s.goroutine { - s.Fatal("New: track overrun") - } - ds := &Stub[K]{TB: s.TB, makeK: s.makeK, want: s.want.Tracks[s.goroutine], wg: s.wg} - s.goroutine++ - s.sub = append(s.sub, ds) - s.wg.Add(1) - go func() { - s.Helper() - - defer s.wg.Done() - defer handleExitNew(s.TB) - f(s.makeK(ds)) - }() -} - -// Pos returns the current position of [Stub] in its [Expect.Calls] -func (s *Stub[K]) Pos() int { return s.pos } - -// Len returns the length of [Expect.Calls]. -func (s *Stub[K]) Len() int { return len(s.want.Calls) } - -// VisitIncomplete calls f on an incomplete s and all its descendants. -func (s *Stub[K]) VisitIncomplete(f func(s *Stub[K])) { - s.Helper() - s.wg.Wait() - - if s.want.Calls != nil && len(s.want.Calls) != s.pos { - f(s) - } - for _, ds := range s.sub { - ds.VisitIncomplete(f) - } -} - -// Expects checks the name of and returns the current [Call] and advances pos. -func (s *Stub[K]) Expects(name string) (expect *Call) { - s.Helper() - - if len(s.want.Calls) == s.pos { - s.Fatal("Expects: advancing beyond expected calls") - } - expect = &s.want.Calls[s.pos] - if name != expect.Name { - if expect.Name == CallSeparator { - s.Fatalf("Expects: func = %s, separator overrun", name) - } - if name == CallSeparator { - s.Fatalf("Expects: separator, want %s", expect.Name) - } - s.Fatalf("Expects: func = %s, want %s", name, expect.Name) - } - s.pos++ - return -} - -// CheckArg checks an argument comparable with the == operator. Avoid using this with pointers. -func CheckArg[T comparable, K any](s *Stub[K], arg string, got T, n int) bool { - s.Helper() - - pos := s.pos - 1 - if pos < 0 || pos >= len(s.want.Calls) { - panic("invalid call to CheckArg") - } - expect := s.want.Calls[pos] - want, ok := expect.Args[n].(T) - if !ok || got != want { - s.Errorf("%s: %s = %#v, want %#v (%d)", expect.Name, arg, got, want, pos) - return false - } - return true -} - -// CheckArgReflect checks an argument of any type. -func CheckArgReflect[K any](s *Stub[K], arg string, got any, n int) bool { - s.Helper() - - pos := s.pos - 1 - if pos < 0 || pos >= len(s.want.Calls) { - panic("invalid call to CheckArgReflect") - } - expect := s.want.Calls[pos] - want := expect.Args[n] - if !reflect.DeepEqual(got, want) { - s.Errorf("%s: %s = %#v, want %#v (%d)", expect.Name, arg, got, want, pos) - return false - } - return true -} diff --git a/container/stub/stub_test.go b/container/stub/stub_test.go deleted file mode 100644 index 9c9b0eb8..00000000 --- a/container/stub/stub_test.go +++ /dev/null @@ -1,335 +0,0 @@ -package stub - -import ( - "reflect" - "sync/atomic" - "testing" -) - -// stubHolder embeds [Stub]. -type stubHolder struct{ *Stub[stubHolder] } - -// overrideT allows some methods of [testing.T] to be overridden. -type overrideT struct { - *testing.T - - error atomic.Pointer[func(args ...any)] - errorf atomic.Pointer[func(format string, args ...any)] -} - -func (t *overrideT) Error(args ...any) { - fp := t.error.Load() - if fp == nil || *fp == nil { - t.T.Error(args...) - return - } - (*fp)(args...) -} - -func (t *overrideT) Errorf(format string, args ...any) { - fp := t.errorf.Load() - if fp == nil || *fp == nil { - t.T.Errorf(format, args...) - return - } - (*fp)(format, args...) -} - -func TestStub(t *testing.T) { - t.Parallel() - - t.Run("goexit", func(t *testing.T) { - t.Parallel() - - t.Run("FailNow", func(t *testing.T) { - t.Parallel() - - defer func() { - if r := recover(); r != panicFailNow { - t.Errorf("recover: %v", r) - } - }() - stubHolder{&Stub[stubHolder]{TB: t}}.FailNow() - }) - - t.Run("SkipNow", func(t *testing.T) { - t.Parallel() - - defer func() { - want := "invalid call to SkipNow" - if r := recover(); r != want { - t.Errorf("recover: %v, want %v", r, want) - } - }() - stubHolder{&Stub[stubHolder]{TB: t}}.SkipNow() - }) - - t.Run("Skip", func(t *testing.T) { - t.Parallel() - - defer func() { - want := "invalid call to Skip" - if r := recover(); r != want { - t.Errorf("recover: %v, want %v", r, want) - } - }() - stubHolder{&Stub[stubHolder]{TB: t}}.Skip() - }) - - t.Run("Skipf", func(t *testing.T) { - t.Parallel() - - defer func() { - want := "invalid call to Skipf" - if r := recover(); r != want { - t.Errorf("recover: %v, want %v", r, want) - } - }() - stubHolder{&Stub[stubHolder]{TB: t}}.Skipf("") - }) - }) - - t.Run("new", func(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - s := New(t, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ - {"New", ExpectArgs{}, nil, nil}, - }, Tracks: []Expect{{Calls: []Call{ - {"done", ExpectArgs{0xbabe}, nil, nil}, - }}}}) - - s.New(func(k stubHolder) { - expect := k.Expects("done") - if expect.Name != "done" { - t.Errorf("New: Name = %s, want done", expect.Name) - } - if expect.Args != (ExpectArgs{0xbabe}) { - t.Errorf("New: Args = %#v", expect.Args) - } - if expect.Ret != nil { - t.Errorf("New: Ret = %#v", expect.Ret) - } - if expect.Err != nil { - t.Errorf("New: Err = %#v", expect.Err) - } - }) - - if pos := s.Pos(); pos != 1 { - t.Errorf("Pos: %d, want 1", pos) - } - if l := s.Len(); l != 1 { - t.Errorf("Len: %d, want 1", l) - } - - s.VisitIncomplete(func(s *Stub[stubHolder]) { panic("unreachable") }) - }) - - t.Run("overrun", func(t *testing.T) { - t.Parallel() - - ot := &overrideT{T: t} - ot.error.Store(checkError(t, "New: track overrun")) - s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ - {"New", ExpectArgs{}, nil, nil}, - {"panic", ExpectArgs{"unreachable"}, nil, nil}, - }}) - func() { defer HandleExit(t); s.New(func(k stubHolder) { panic("unreachable") }) }() - - var visit int - s.VisitIncomplete(func(s *Stub[stubHolder]) { - visit++ - if visit > 1 { - panic("unexpected visit count") - } - - want := Call{"panic", ExpectArgs{"unreachable"}, nil, nil} - if got := s.want.Calls[s.pos]; !reflect.DeepEqual(got, want) { - t.Errorf("VisitIncomplete: %#v, want %#v", got, want) - } - }) - }) - - t.Run("expects", func(t *testing.T) { - t.Parallel() - - t.Run("overrun", func(t *testing.T) { - t.Parallel() - - ot := &overrideT{T: t} - ot.error.Store(checkError(t, "Expects: advancing beyond expected calls")) - s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{}) - func() { defer HandleExit(t); s.Expects("unreachable") }() - }) - - t.Run("separator", func(t *testing.T) { - t.Parallel() - - t.Run("overrun", func(t *testing.T) { - t.Parallel() - - ot := &overrideT{T: t} - ot.errorf.Store(checkErrorf(t, "Expects: func = %s, separator overrun", "meow")) - s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ - {CallSeparator, ExpectArgs{}, nil, nil}, - }}) - func() { defer HandleExit(t); s.Expects("meow") }() - }) - - t.Run("mismatch", func(t *testing.T) { - t.Parallel() - - ot := &overrideT{T: t} - ot.errorf.Store(checkErrorf(t, "Expects: separator, want %s", "panic")) - s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ - {"panic", ExpectArgs{}, nil, nil}, - }}) - func() { defer HandleExit(t); s.Expects(CallSeparator) }() - }) - }) - - t.Run("mismatch", func(t *testing.T) { - t.Parallel() - - ot := &overrideT{T: t} - ot.errorf.Store(checkErrorf(t, "Expects: func = %s, want %s", "meow", "nya")) - s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ - {"nya", ExpectArgs{}, nil, nil}, - }}) - func() { defer HandleExit(t); s.Expects("meow") }() - }) - }) - }) -} - -func TestCheckArg(t *testing.T) { - t.Run("oob negative", func(t *testing.T) { - t.Parallel() - - defer func() { - want := "invalid call to CheckArg" - if r := recover(); r != want { - t.Errorf("recover: %v, want %v", r, want) - } - }() - s := New(t, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{}) - CheckArg(s, "unreachable", struct{}{}, 0) - }) - - ot := &overrideT{T: t} - s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ - {"panic", ExpectArgs{PanicExit}, nil, nil}, - {"meow", ExpectArgs{-1}, nil, nil}, - }}) - - t.Run("match", func(t *testing.T) { - s.Expects("panic") - if !CheckArg(s, "v", PanicExit, 0) { - t.Errorf("CheckArg: unexpected false") - } - }) - - t.Run("mismatch", func(t *testing.T) { - defer HandleExit(t) - s.Expects("meow") - ot.errorf.Store(checkErrorf(t, "%s: %s = %#v, want %#v (%d)", "meow", "time", 0, -1, 1)) - if CheckArg(s, "time", 0, 0) { - t.Errorf("CheckArg: unexpected true") - } - }) - - t.Run("oob", func(t *testing.T) { - s.pos++ - defer func() { - want := "invalid call to CheckArg" - if r := recover(); r != want { - t.Errorf("recover: %v, want %v", r, want) - } - }() - CheckArg(s, "unreachable", struct{}{}, 0) - }) -} - -func TestCheckArgReflect(t *testing.T) { - t.Parallel() - - t.Run("oob lower", func(t *testing.T) { - t.Parallel() - - defer func() { - want := "invalid call to CheckArgReflect" - if r := recover(); r != want { - t.Errorf("recover: %v, want %v", r, want) - } - }() - s := New(t, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{}) - CheckArgReflect(s, "unreachable", struct{}{}, 0) - }) - - ot := &overrideT{T: t} - s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ - {"panic", ExpectArgs{PanicExit}, nil, nil}, - {"meow", ExpectArgs{-1}, nil, nil}, - }}) - t.Run("match", func(t *testing.T) { - s.Expects("panic") - if !CheckArgReflect(s, "v", PanicExit, 0) { - t.Errorf("CheckArgReflect: unexpected false") - } - }) - t.Run("mismatch", func(t *testing.T) { - defer HandleExit(t) - s.Expects("meow") - ot.errorf.Store(checkErrorf(t, "%s: %s = %#v, want %#v (%d)", "meow", "time", 0, -1, 1)) - if CheckArgReflect(s, "time", 0, 0) { - t.Errorf("CheckArgReflect: unexpected true") - } - }) - t.Run("oob", func(t *testing.T) { - s.pos++ - defer func() { - want := "invalid call to CheckArgReflect" - if r := recover(); r != want { - t.Errorf("recover: %v, want %v", r, want) - } - }() - CheckArgReflect(s, "unreachable", struct{}{}, 0) - }) -} - -func checkError(t *testing.T, wantArgs ...any) *func(args ...any) { - var called bool - f := func(args ...any) { - if called { - panic("invalid call to error") - } - called = true - - if !reflect.DeepEqual(args, wantArgs) { - t.Errorf("Error: %#v, want %#v", args, wantArgs) - } - panic(PanicExit) - } - return &f -} - -func checkErrorf(t *testing.T, wantFormat string, wantArgs ...any) *func(format string, args ...any) { - var called bool - f := func(format string, args ...any) { - if called { - panic("invalid call to errorf") - } - called = true - - if format != wantFormat { - t.Errorf("Errorf: format = %q, want %q", format, wantFormat) - } - if !reflect.DeepEqual(args, wantArgs) { - t.Errorf("Errorf: args = %#v, want %#v", args, wantArgs) - } - panic(PanicExit) - } - return &f -} diff --git a/hst/hst_test.go b/hst/hst_test.go index e3b98614..54a81284 100644 --- a/hst/hst_test.go +++ b/hst/hst_test.go @@ -9,8 +9,8 @@ import ( "syscall" "testing" - "hakurei.app/container/stub" "hakurei.app/hst" + "hakurei.app/internal/stub" "hakurei.app/message" ) diff --git a/internal/env/env_test.go b/internal/env/env_test.go index 0bfcf83b..0e5db4a9 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -7,10 +7,10 @@ import ( "hakurei.app/check" "hakurei.app/container" - "hakurei.app/container/stub" "hakurei.app/fhs" "hakurei.app/hst" "hakurei.app/internal/env" + "hakurei.app/internal/stub" ) func TestPaths(t *testing.T) { diff --git a/internal/outcome/dispatcher_test.go b/internal/outcome/dispatcher_test.go index a11fb869..a099df58 100644 --- a/internal/outcome/dispatcher_test.go +++ b/internal/outcome/dispatcher_test.go @@ -22,8 +22,8 @@ import ( "hakurei.app/container" "hakurei.app/container/seccomp" "hakurei.app/container/std" - "hakurei.app/container/stub" "hakurei.app/hst" + "hakurei.app/internal/stub" "hakurei.app/internal/system" "hakurei.app/message" ) diff --git a/internal/outcome/hsu_test.go b/internal/outcome/hsu_test.go index 4ee7fa9c..f5654445 100644 --- a/internal/outcome/hsu_test.go +++ b/internal/outcome/hsu_test.go @@ -9,8 +9,8 @@ import ( "testing" "unsafe" - "hakurei.app/container/stub" "hakurei.app/hst" + "hakurei.app/internal/stub" ) func TestHsu(t *testing.T) { diff --git a/internal/outcome/shim_test.go b/internal/outcome/shim_test.go index aa8220b8..08f037da 100644 --- a/internal/outcome/shim_test.go +++ b/internal/outcome/shim_test.go @@ -12,11 +12,11 @@ import ( "hakurei.app/container" "hakurei.app/container/seccomp" "hakurei.app/container/std" - "hakurei.app/container/stub" "hakurei.app/ext" "hakurei.app/fhs" "hakurei.app/hst" "hakurei.app/internal/env" + "hakurei.app/internal/stub" ) func TestShimEntrypoint(t *testing.T) { diff --git a/internal/outcome/spaccount_test.go b/internal/outcome/spaccount_test.go index c1693dd8..f94e4591 100644 --- a/internal/outcome/spaccount_test.go +++ b/internal/outcome/spaccount_test.go @@ -6,8 +6,8 @@ import ( "testing" "hakurei.app/container" - "hakurei.app/container/stub" "hakurei.app/hst" + "hakurei.app/internal/stub" ) func TestSpAccountOp(t *testing.T) { diff --git a/internal/outcome/spcontainer_test.go b/internal/outcome/spcontainer_test.go index d1f26468..7dd47735 100644 --- a/internal/outcome/spcontainer_test.go +++ b/internal/outcome/spcontainer_test.go @@ -11,11 +11,11 @@ import ( "hakurei.app/container" "hakurei.app/container/seccomp" "hakurei.app/container/std" - "hakurei.app/container/stub" "hakurei.app/fhs" "hakurei.app/hst" "hakurei.app/internal/acl" "hakurei.app/internal/dbus" + "hakurei.app/internal/stub" "hakurei.app/internal/system" ) diff --git a/internal/outcome/spdbus_test.go b/internal/outcome/spdbus_test.go index 350fc326..6f357193 100644 --- a/internal/outcome/spdbus_test.go +++ b/internal/outcome/spdbus_test.go @@ -5,11 +5,11 @@ import ( "testing" "hakurei.app/container" - "hakurei.app/container/stub" "hakurei.app/hst" "hakurei.app/internal/acl" "hakurei.app/internal/dbus" "hakurei.app/internal/helper" + "hakurei.app/internal/stub" "hakurei.app/internal/system" "hakurei.app/message" ) diff --git a/internal/outcome/sppipewire_test.go b/internal/outcome/sppipewire_test.go index 3259915c..1e3dab6a 100644 --- a/internal/outcome/sppipewire_test.go +++ b/internal/outcome/sppipewire_test.go @@ -4,9 +4,9 @@ import ( "testing" "hakurei.app/container" - "hakurei.app/container/stub" "hakurei.app/hst" "hakurei.app/internal/pipewire" + "hakurei.app/internal/stub" "hakurei.app/internal/system" ) diff --git a/internal/outcome/sppulse_test.go b/internal/outcome/sppulse_test.go index 9b014470..f1abccaa 100644 --- a/internal/outcome/sppulse_test.go +++ b/internal/outcome/sppulse_test.go @@ -9,9 +9,9 @@ import ( "hakurei.app/check" "hakurei.app/container" - "hakurei.app/container/stub" "hakurei.app/hst" "hakurei.app/internal/acl" + "hakurei.app/internal/stub" "hakurei.app/internal/system" ) diff --git a/internal/outcome/spruntime_test.go b/internal/outcome/spruntime_test.go index bd7ad37c..e083f7c3 100644 --- a/internal/outcome/spruntime_test.go +++ b/internal/outcome/spruntime_test.go @@ -5,10 +5,10 @@ import ( "hakurei.app/container" "hakurei.app/container/std" - "hakurei.app/container/stub" "hakurei.app/fhs" "hakurei.app/hst" "hakurei.app/internal/acl" + "hakurei.app/internal/stub" "hakurei.app/internal/system" ) diff --git a/internal/outcome/sptmpdir_test.go b/internal/outcome/sptmpdir_test.go index fd9dc1df..620f51b0 100644 --- a/internal/outcome/sptmpdir_test.go +++ b/internal/outcome/sptmpdir_test.go @@ -5,10 +5,10 @@ import ( "hakurei.app/container" "hakurei.app/container/std" - "hakurei.app/container/stub" "hakurei.app/fhs" "hakurei.app/hst" "hakurei.app/internal/acl" + "hakurei.app/internal/stub" "hakurei.app/internal/system" ) diff --git a/internal/outcome/spwayland_test.go b/internal/outcome/spwayland_test.go index 828cc86c..7cc4ba84 100644 --- a/internal/outcome/spwayland_test.go +++ b/internal/outcome/spwayland_test.go @@ -4,9 +4,9 @@ import ( "testing" "hakurei.app/container" - "hakurei.app/container/stub" "hakurei.app/hst" "hakurei.app/internal/acl" + "hakurei.app/internal/stub" "hakurei.app/internal/system" "hakurei.app/internal/wayland" ) diff --git a/internal/outcome/spx11_test.go b/internal/outcome/spx11_test.go index d4c10902..0ede2d65 100644 --- a/internal/outcome/spx11_test.go +++ b/internal/outcome/spx11_test.go @@ -5,9 +5,9 @@ import ( "testing" "hakurei.app/container" - "hakurei.app/container/stub" "hakurei.app/hst" "hakurei.app/internal/acl" + "hakurei.app/internal/stub" ) func TestSpX11Op(t *testing.T) { diff --git a/internal/pipewire/pipewire_test.go b/internal/pipewire/pipewire_test.go index 9305e7ec..0187391c 100644 --- a/internal/pipewire/pipewire_test.go +++ b/internal/pipewire/pipewire_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "hakurei.app/container/stub" "hakurei.app/internal/pipewire" + "hakurei.app/internal/stub" ) func TestContext(t *testing.T) { diff --git a/internal/pkg/exec_test.go b/internal/pkg/exec_test.go index 5b0fa765..baf9b8de 100644 --- a/internal/pkg/exec_test.go +++ b/internal/pkg/exec_test.go @@ -14,9 +14,9 @@ import ( "unique" "hakurei.app/check" - "hakurei.app/container/stub" "hakurei.app/hst" "hakurei.app/internal/pkg" + "hakurei.app/internal/stub" ) // testtoolBin is the container test tool binary made available to the diff --git a/internal/pkg/pkg_test.go b/internal/pkg/pkg_test.go index 3c68dbcc..6b5d5fc4 100644 --- a/internal/pkg/pkg_test.go +++ b/internal/pkg/pkg_test.go @@ -23,9 +23,9 @@ import ( "hakurei.app/check" "hakurei.app/container" - "hakurei.app/container/stub" "hakurei.app/fhs" "hakurei.app/internal/pkg" + "hakurei.app/internal/stub" "hakurei.app/message" ) diff --git a/internal/pkg/tar_test.go b/internal/pkg/tar_test.go index 09e1e4db..c604dc78 100644 --- a/internal/pkg/tar_test.go +++ b/internal/pkg/tar_test.go @@ -13,8 +13,8 @@ import ( "testing/fstest" "hakurei.app/check" - "hakurei.app/container/stub" "hakurei.app/internal/pkg" + "hakurei.app/internal/stub" ) func TestTar(t *testing.T) { diff --git a/internal/store/data_test.go b/internal/store/data_test.go index fe0e2124..08eb4f9c 100644 --- a/internal/store/data_test.go +++ b/internal/store/data_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" - "hakurei.app/container/stub" "hakurei.app/hst" + "hakurei.app/internal/stub" ) func TestEntryData(t *testing.T) { diff --git a/internal/store/segment_test.go b/internal/store/segment_test.go index 671f27a0..61b97c26 100644 --- a/internal/store/segment_test.go +++ b/internal/store/segment_test.go @@ -13,9 +13,9 @@ import ( _ "unsafe" // for go:linkname "hakurei.app/check" - "hakurei.app/container/stub" "hakurei.app/hst" "hakurei.app/internal/store" + "hakurei.app/internal/stub" ) // Made available here for direct validation of state entry files. diff --git a/internal/stub/call.go b/internal/stub/call.go new file mode 100644 index 00000000..585a69ef --- /dev/null +++ b/internal/stub/call.go @@ -0,0 +1,37 @@ +package stub + +import ( + "slices" +) + +// ExpectArgs is an array primarily for storing expected function arguments. +// Its actual use is defined by the implementation. +type ExpectArgs = [5]any + +// An Expect stores expected calls of a goroutine. +type Expect struct { + Calls []Call + + // Tracks are handed out to descendant goroutines in order. + Tracks []Expect +} + +// A Call holds expected arguments of a function call and its outcome. +type Call struct { + // Name is the function Name of this call. Must be unique. + Name string + // Args are the expected arguments of this Call. + Args ExpectArgs + // Ret is the return value of this Call. + Ret any + // Err is the returned error of this Call. + Err error +} + +// Error returns [Call.Err] if all arguments are true, or [ErrCheck] otherwise. +func (k *Call) Error(ok ...bool) error { + if !slices.Contains(ok, false) { + return k.Err + } + return ErrCheck +} diff --git a/internal/stub/call_test.go b/internal/stub/call_test.go new file mode 100644 index 00000000..76705c8d --- /dev/null +++ b/internal/stub/call_test.go @@ -0,0 +1,27 @@ +package stub_test + +import ( + "reflect" + "testing" + + "hakurei.app/internal/stub" +) + +func TestCallError(t *testing.T) { + t.Parallel() + + t.Run("contains false", func(t *testing.T) { + t.Parallel() + if err := new(stub.Call).Error(true, false, true); !reflect.DeepEqual(err, stub.ErrCheck) { + t.Errorf("Error: %#v, want %#v", err, stub.ErrCheck) + } + }) + + t.Run("passthrough", func(t *testing.T) { + t.Parallel() + wantErr := stub.UniqueError(0xbabe) + if err := (&stub.Call{Err: wantErr}).Error(true); !reflect.DeepEqual(err, wantErr) { + t.Errorf("Error: %#v, want %#v", err, wantErr) + } + }) +} diff --git a/internal/stub/errors.go b/internal/stub/errors.go new file mode 100644 index 00000000..c89149be --- /dev/null +++ b/internal/stub/errors.go @@ -0,0 +1,28 @@ +package stub + +import ( + "errors" + "strconv" +) + +var ( + ErrCheck = errors.New("one or more arguments did not match") +) + +// UniqueError is an error that only equivalates to another [UniqueError] with +// the same magic value. +type UniqueError uintptr + +func (e UniqueError) Error() string { + return "unique error " + + strconv.FormatUint(uint64(e), 10) + + " injected by the test suite" +} + +func (e UniqueError) Is(target error) bool { + var u UniqueError + if !errors.As(target, &u) { + return false + } + return e == u +} diff --git a/internal/stub/errors_test.go b/internal/stub/errors_test.go new file mode 100644 index 00000000..2e5d9930 --- /dev/null +++ b/internal/stub/errors_test.go @@ -0,0 +1,42 @@ +package stub_test + +import ( + "errors" + "syscall" + "testing" + + "hakurei.app/internal/stub" +) + +func TestUniqueError(t *testing.T) { + t.Parallel() + + t.Run("format", func(t *testing.T) { + t.Parallel() + want := "unique error 2989 injected by the test suite" + if got := stub.UniqueError(0xbad).Error(); got != want { + t.Errorf("Error: %q, want %q", got, want) + } + }) + + t.Run("is", func(t *testing.T) { + t.Parallel() + + t.Run("type", func(t *testing.T) { + t.Parallel() + if errors.Is(stub.UniqueError(0), syscall.ENOTRECOVERABLE) { + t.Error("Is: unexpected true") + } + }) + + t.Run("val", func(t *testing.T) { + t.Parallel() + if errors.Is(stub.UniqueError(0), stub.UniqueError(1)) { + t.Error("Is: unexpected true") + } + if !errors.Is(stub.UniqueError(0xbad), stub.UniqueError(0xbad)) { + t.Error("Is: unexpected false") + } + }) + }) +} diff --git a/internal/stub/exit.go b/internal/stub/exit.go new file mode 100644 index 00000000..062ee2e0 --- /dev/null +++ b/internal/stub/exit.go @@ -0,0 +1,44 @@ +package stub + +import "testing" + +// PanicExit is a magic panic value treated as a simulated exit. +const PanicExit = 0xdead + +const ( + panicFailNow = 0xcafe0 + iota + panicFatal + panicFatalf +) + +// HandleExit must be deferred before calling with the stub. +func HandleExit(t testing.TB) { + switch r := recover(); r { + case PanicExit: + break + + case panicFailNow: + t.FailNow() + + case panicFatal, panicFatalf, nil: + break + + default: + panic(r) + } +} + +// handleExitNew handles exits from goroutines created by [Stub.New]. +func handleExitNew(t testing.TB) { + switch r := recover(); r { + case PanicExit, panicFatal, panicFatalf, nil: + break + + case panicFailNow: + t.Fail() + break + + default: + panic(r) + } +} diff --git a/internal/stub/exit_test.go b/internal/stub/exit_test.go new file mode 100644 index 00000000..c977d2ec --- /dev/null +++ b/internal/stub/exit_test.go @@ -0,0 +1,111 @@ +package stub_test + +import ( + "testing" + _ "unsafe" // for go:linkname + + "hakurei.app/internal/stub" +) + +// Made available here to check panic recovery behaviour. +// +//go:linkname handleExitNew hakurei.app/internal/stub.handleExitNew +func handleExitNew(t testing.TB) + +// overrideTFailNow overrides the Fail and FailNow method. +type overrideTFailNow struct { + *testing.T + failNow bool + fail bool +} + +func (o *overrideTFailNow) FailNow() { + if o.failNow { + o.Errorf("attempted to FailNow twice") + } + o.failNow = true +} + +func (o *overrideTFailNow) Fail() { + if o.fail { + o.Errorf("attempted to Fail twice") + } + o.fail = true +} + +func TestHandleExit(t *testing.T) { + t.Parallel() + + t.Run("exit", func(t *testing.T) { + t.Parallel() + defer stub.HandleExit(t) + panic(stub.PanicExit) + }) + + t.Run("goexit", func(t *testing.T) { + t.Parallel() + + t.Run("FailNow", func(t *testing.T) { + t.Parallel() + + ot := &overrideTFailNow{T: t} + defer func() { + if !ot.failNow { + t.Errorf("FailNow was never called") + } + }() + defer stub.HandleExit(ot) + panic(0xcafe0) + }) + + t.Run("Fail", func(t *testing.T) { + t.Parallel() + + ot := &overrideTFailNow{T: t} + defer func() { + if !ot.fail { + t.Errorf("Fail was never called") + } + }() + defer handleExitNew(ot) + panic(0xcafe0) + }) + }) + + t.Run("nil", func(t *testing.T) { + t.Parallel() + defer stub.HandleExit(t) + }) + + t.Run("passthrough", func(t *testing.T) { + t.Parallel() + + t.Run("toplevel", func(t *testing.T) { + t.Parallel() + + defer func() { + want := 0xcafe + if r := recover(); r != want { + t.Errorf("recover: %v, want %v", r, want) + } + + }() + defer stub.HandleExit(t) + panic(0xcafe) + }) + + t.Run("new", func(t *testing.T) { + t.Parallel() + + defer func() { + want := 0xcafe + if r := recover(); r != want { + t.Errorf("recover: %v, want %v", r, want) + } + + }() + defer handleExitNew(t) + panic(0xcafe) + }) + }) +} diff --git a/internal/stub/stub.go b/internal/stub/stub.go new file mode 100644 index 00000000..d60e92dd --- /dev/null +++ b/internal/stub/stub.go @@ -0,0 +1,153 @@ +// Package stub provides function call level stubbing and validation for library +// functions that are impossible to check otherwise. +package stub + +import ( + "reflect" + "sync" + "testing" +) + +// this should prevent stub from being inadvertently imported outside tests +var _ = func() { + if !testing.Testing() { + panic("stub imported while not in a test") + } +} + +const ( + // A CallSeparator denotes an injected separation between two groups of calls. + CallSeparator = "\x00" +) + +// A Stub is a collection of tracks of expected calls. +type Stub[K any] struct { + testing.TB + + // makeK creates a new K for a descendant [Stub]. + // This function may be called concurrently. + makeK func(s *Stub[K]) K + + // want is a hierarchy of expected calls. + want Expect + // pos is the current position in [Expect.Calls]. + pos int + // goroutine counts the number of goroutines created by this [Stub]. + goroutine int + // sub stores the addresses of descendant [Stub] created by New. + sub []*Stub[K] + // wg waits for all descendants to complete. + wg *sync.WaitGroup +} + +// New creates a root [Stub]. +func New[K any](tb testing.TB, makeK func(s *Stub[K]) K, want Expect) *Stub[K] { + return &Stub[K]{TB: tb, makeK: makeK, want: want, wg: new(sync.WaitGroup)} +} + +func (s *Stub[K]) FailNow() { s.Helper(); panic(panicFailNow) } +func (s *Stub[K]) Fatal(args ...any) { s.Helper(); s.Error(args...); panic(panicFatal) } +func (s *Stub[K]) Fatalf(format string, args ...any) { + s.Helper() + s.Errorf(format, args...) + panic(panicFatalf) +} + +func (s *Stub[K]) SkipNow() { s.Helper(); panic("invalid call to SkipNow") } +func (s *Stub[K]) Skip(...any) { s.Helper(); panic("invalid call to Skip") } +func (s *Stub[K]) Skipf(string, ...any) { s.Helper(); panic("invalid call to Skipf") } + +// New calls f in a new goroutine +func (s *Stub[K]) New(f func(k K)) { + s.Helper() + + s.Expects("New") + if len(s.want.Tracks) <= s.goroutine { + s.Fatal("New: track overrun") + } + ds := &Stub[K]{TB: s.TB, makeK: s.makeK, want: s.want.Tracks[s.goroutine], wg: s.wg} + s.goroutine++ + s.sub = append(s.sub, ds) + s.wg.Add(1) + go func() { + s.Helper() + + defer s.wg.Done() + defer handleExitNew(s.TB) + f(s.makeK(ds)) + }() +} + +// Pos returns the current position of [Stub] in its [Expect.Calls] +func (s *Stub[K]) Pos() int { return s.pos } + +// Len returns the length of [Expect.Calls]. +func (s *Stub[K]) Len() int { return len(s.want.Calls) } + +// VisitIncomplete calls f on an incomplete s and all its descendants. +func (s *Stub[K]) VisitIncomplete(f func(s *Stub[K])) { + s.Helper() + s.wg.Wait() + + if s.want.Calls != nil && len(s.want.Calls) != s.pos { + f(s) + } + for _, ds := range s.sub { + ds.VisitIncomplete(f) + } +} + +// Expects checks the name of and returns the current [Call] and advances pos. +func (s *Stub[K]) Expects(name string) (expect *Call) { + s.Helper() + + if len(s.want.Calls) == s.pos { + s.Fatal("Expects: advancing beyond expected calls") + } + expect = &s.want.Calls[s.pos] + if name != expect.Name { + if expect.Name == CallSeparator { + s.Fatalf("Expects: func = %s, separator overrun", name) + } + if name == CallSeparator { + s.Fatalf("Expects: separator, want %s", expect.Name) + } + s.Fatalf("Expects: func = %s, want %s", name, expect.Name) + } + s.pos++ + return +} + +// CheckArg checks an argument comparable with the == operator. Avoid using this with pointers. +func CheckArg[T comparable, K any](s *Stub[K], arg string, got T, n int) bool { + s.Helper() + + pos := s.pos - 1 + if pos < 0 || pos >= len(s.want.Calls) { + panic("invalid call to CheckArg") + } + expect := s.want.Calls[pos] + want, ok := expect.Args[n].(T) + if !ok || got != want { + s.Errorf("%s: %s = %#v, want %#v (%d)", expect.Name, arg, got, want, pos) + return false + } + return true +} + +// CheckArgReflect checks an argument of any type. +func CheckArgReflect[K any](s *Stub[K], arg string, got any, n int) bool { + s.Helper() + + pos := s.pos - 1 + if pos < 0 || pos >= len(s.want.Calls) { + panic("invalid call to CheckArgReflect") + } + expect := s.want.Calls[pos] + want := expect.Args[n] + if !reflect.DeepEqual(got, want) { + s.Errorf("%s: %s = %#v, want %#v (%d)", expect.Name, arg, got, want, pos) + return false + } + return true +} diff --git a/internal/stub/stub_test.go b/internal/stub/stub_test.go new file mode 100644 index 00000000..9c9b0eb8 --- /dev/null +++ b/internal/stub/stub_test.go @@ -0,0 +1,335 @@ +package stub + +import ( + "reflect" + "sync/atomic" + "testing" +) + +// stubHolder embeds [Stub]. +type stubHolder struct{ *Stub[stubHolder] } + +// overrideT allows some methods of [testing.T] to be overridden. +type overrideT struct { + *testing.T + + error atomic.Pointer[func(args ...any)] + errorf atomic.Pointer[func(format string, args ...any)] +} + +func (t *overrideT) Error(args ...any) { + fp := t.error.Load() + if fp == nil || *fp == nil { + t.T.Error(args...) + return + } + (*fp)(args...) +} + +func (t *overrideT) Errorf(format string, args ...any) { + fp := t.errorf.Load() + if fp == nil || *fp == nil { + t.T.Errorf(format, args...) + return + } + (*fp)(format, args...) +} + +func TestStub(t *testing.T) { + t.Parallel() + + t.Run("goexit", func(t *testing.T) { + t.Parallel() + + t.Run("FailNow", func(t *testing.T) { + t.Parallel() + + defer func() { + if r := recover(); r != panicFailNow { + t.Errorf("recover: %v", r) + } + }() + stubHolder{&Stub[stubHolder]{TB: t}}.FailNow() + }) + + t.Run("SkipNow", func(t *testing.T) { + t.Parallel() + + defer func() { + want := "invalid call to SkipNow" + if r := recover(); r != want { + t.Errorf("recover: %v, want %v", r, want) + } + }() + stubHolder{&Stub[stubHolder]{TB: t}}.SkipNow() + }) + + t.Run("Skip", func(t *testing.T) { + t.Parallel() + + defer func() { + want := "invalid call to Skip" + if r := recover(); r != want { + t.Errorf("recover: %v, want %v", r, want) + } + }() + stubHolder{&Stub[stubHolder]{TB: t}}.Skip() + }) + + t.Run("Skipf", func(t *testing.T) { + t.Parallel() + + defer func() { + want := "invalid call to Skipf" + if r := recover(); r != want { + t.Errorf("recover: %v, want %v", r, want) + } + }() + stubHolder{&Stub[stubHolder]{TB: t}}.Skipf("") + }) + }) + + t.Run("new", func(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + s := New(t, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ + {"New", ExpectArgs{}, nil, nil}, + }, Tracks: []Expect{{Calls: []Call{ + {"done", ExpectArgs{0xbabe}, nil, nil}, + }}}}) + + s.New(func(k stubHolder) { + expect := k.Expects("done") + if expect.Name != "done" { + t.Errorf("New: Name = %s, want done", expect.Name) + } + if expect.Args != (ExpectArgs{0xbabe}) { + t.Errorf("New: Args = %#v", expect.Args) + } + if expect.Ret != nil { + t.Errorf("New: Ret = %#v", expect.Ret) + } + if expect.Err != nil { + t.Errorf("New: Err = %#v", expect.Err) + } + }) + + if pos := s.Pos(); pos != 1 { + t.Errorf("Pos: %d, want 1", pos) + } + if l := s.Len(); l != 1 { + t.Errorf("Len: %d, want 1", l) + } + + s.VisitIncomplete(func(s *Stub[stubHolder]) { panic("unreachable") }) + }) + + t.Run("overrun", func(t *testing.T) { + t.Parallel() + + ot := &overrideT{T: t} + ot.error.Store(checkError(t, "New: track overrun")) + s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ + {"New", ExpectArgs{}, nil, nil}, + {"panic", ExpectArgs{"unreachable"}, nil, nil}, + }}) + func() { defer HandleExit(t); s.New(func(k stubHolder) { panic("unreachable") }) }() + + var visit int + s.VisitIncomplete(func(s *Stub[stubHolder]) { + visit++ + if visit > 1 { + panic("unexpected visit count") + } + + want := Call{"panic", ExpectArgs{"unreachable"}, nil, nil} + if got := s.want.Calls[s.pos]; !reflect.DeepEqual(got, want) { + t.Errorf("VisitIncomplete: %#v, want %#v", got, want) + } + }) + }) + + t.Run("expects", func(t *testing.T) { + t.Parallel() + + t.Run("overrun", func(t *testing.T) { + t.Parallel() + + ot := &overrideT{T: t} + ot.error.Store(checkError(t, "Expects: advancing beyond expected calls")) + s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{}) + func() { defer HandleExit(t); s.Expects("unreachable") }() + }) + + t.Run("separator", func(t *testing.T) { + t.Parallel() + + t.Run("overrun", func(t *testing.T) { + t.Parallel() + + ot := &overrideT{T: t} + ot.errorf.Store(checkErrorf(t, "Expects: func = %s, separator overrun", "meow")) + s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ + {CallSeparator, ExpectArgs{}, nil, nil}, + }}) + func() { defer HandleExit(t); s.Expects("meow") }() + }) + + t.Run("mismatch", func(t *testing.T) { + t.Parallel() + + ot := &overrideT{T: t} + ot.errorf.Store(checkErrorf(t, "Expects: separator, want %s", "panic")) + s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ + {"panic", ExpectArgs{}, nil, nil}, + }}) + func() { defer HandleExit(t); s.Expects(CallSeparator) }() + }) + }) + + t.Run("mismatch", func(t *testing.T) { + t.Parallel() + + ot := &overrideT{T: t} + ot.errorf.Store(checkErrorf(t, "Expects: func = %s, want %s", "meow", "nya")) + s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ + {"nya", ExpectArgs{}, nil, nil}, + }}) + func() { defer HandleExit(t); s.Expects("meow") }() + }) + }) + }) +} + +func TestCheckArg(t *testing.T) { + t.Run("oob negative", func(t *testing.T) { + t.Parallel() + + defer func() { + want := "invalid call to CheckArg" + if r := recover(); r != want { + t.Errorf("recover: %v, want %v", r, want) + } + }() + s := New(t, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{}) + CheckArg(s, "unreachable", struct{}{}, 0) + }) + + ot := &overrideT{T: t} + s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ + {"panic", ExpectArgs{PanicExit}, nil, nil}, + {"meow", ExpectArgs{-1}, nil, nil}, + }}) + + t.Run("match", func(t *testing.T) { + s.Expects("panic") + if !CheckArg(s, "v", PanicExit, 0) { + t.Errorf("CheckArg: unexpected false") + } + }) + + t.Run("mismatch", func(t *testing.T) { + defer HandleExit(t) + s.Expects("meow") + ot.errorf.Store(checkErrorf(t, "%s: %s = %#v, want %#v (%d)", "meow", "time", 0, -1, 1)) + if CheckArg(s, "time", 0, 0) { + t.Errorf("CheckArg: unexpected true") + } + }) + + t.Run("oob", func(t *testing.T) { + s.pos++ + defer func() { + want := "invalid call to CheckArg" + if r := recover(); r != want { + t.Errorf("recover: %v, want %v", r, want) + } + }() + CheckArg(s, "unreachable", struct{}{}, 0) + }) +} + +func TestCheckArgReflect(t *testing.T) { + t.Parallel() + + t.Run("oob lower", func(t *testing.T) { + t.Parallel() + + defer func() { + want := "invalid call to CheckArgReflect" + if r := recover(); r != want { + t.Errorf("recover: %v, want %v", r, want) + } + }() + s := New(t, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{}) + CheckArgReflect(s, "unreachable", struct{}{}, 0) + }) + + ot := &overrideT{T: t} + s := New(ot, func(s *Stub[stubHolder]) stubHolder { return stubHolder{s} }, Expect{Calls: []Call{ + {"panic", ExpectArgs{PanicExit}, nil, nil}, + {"meow", ExpectArgs{-1}, nil, nil}, + }}) + t.Run("match", func(t *testing.T) { + s.Expects("panic") + if !CheckArgReflect(s, "v", PanicExit, 0) { + t.Errorf("CheckArgReflect: unexpected false") + } + }) + t.Run("mismatch", func(t *testing.T) { + defer HandleExit(t) + s.Expects("meow") + ot.errorf.Store(checkErrorf(t, "%s: %s = %#v, want %#v (%d)", "meow", "time", 0, -1, 1)) + if CheckArgReflect(s, "time", 0, 0) { + t.Errorf("CheckArgReflect: unexpected true") + } + }) + t.Run("oob", func(t *testing.T) { + s.pos++ + defer func() { + want := "invalid call to CheckArgReflect" + if r := recover(); r != want { + t.Errorf("recover: %v, want %v", r, want) + } + }() + CheckArgReflect(s, "unreachable", struct{}{}, 0) + }) +} + +func checkError(t *testing.T, wantArgs ...any) *func(args ...any) { + var called bool + f := func(args ...any) { + if called { + panic("invalid call to error") + } + called = true + + if !reflect.DeepEqual(args, wantArgs) { + t.Errorf("Error: %#v, want %#v", args, wantArgs) + } + panic(PanicExit) + } + return &f +} + +func checkErrorf(t *testing.T, wantFormat string, wantArgs ...any) *func(format string, args ...any) { + var called bool + f := func(format string, args ...any) { + if called { + panic("invalid call to errorf") + } + called = true + + if format != wantFormat { + t.Errorf("Errorf: format = %q, want %q", format, wantFormat) + } + if !reflect.DeepEqual(args, wantArgs) { + t.Errorf("Errorf: args = %#v, want %#v", args, wantArgs) + } + panic(PanicExit) + } + return &f +} diff --git a/internal/system/acl_test.go b/internal/system/acl_test.go index 70feaa8b..01f9d9b1 100644 --- a/internal/system/acl_test.go +++ b/internal/system/acl_test.go @@ -5,9 +5,9 @@ import ( "syscall" "testing" - "hakurei.app/container/stub" "hakurei.app/hst" "hakurei.app/internal/acl" + "hakurei.app/internal/stub" ) func TestACLUpdateOp(t *testing.T) { diff --git a/internal/system/dbus_test.go b/internal/system/dbus_test.go index 26e5b87a..f71a6cb5 100644 --- a/internal/system/dbus_test.go +++ b/internal/system/dbus_test.go @@ -9,10 +9,10 @@ import ( "syscall" "testing" - "hakurei.app/container/stub" "hakurei.app/hst" "hakurei.app/internal/dbus" "hakurei.app/internal/helper" + "hakurei.app/internal/stub" ) func TestDBusProxyOp(t *testing.T) { diff --git a/internal/system/dispatcher_test.go b/internal/system/dispatcher_test.go index 01236e4b..977140ec 100644 --- a/internal/system/dispatcher_test.go +++ b/internal/system/dispatcher_test.go @@ -10,11 +10,11 @@ import ( "unsafe" "hakurei.app/check" - "hakurei.app/container/stub" "hakurei.app/hst" "hakurei.app/internal/acl" "hakurei.app/internal/dbus" "hakurei.app/internal/pipewire" + "hakurei.app/internal/stub" "hakurei.app/internal/xcb" ) diff --git a/internal/system/link_test.go b/internal/system/link_test.go index b1d845b6..9297cf00 100644 --- a/internal/system/link_test.go +++ b/internal/system/link_test.go @@ -3,8 +3,8 @@ package system import ( "testing" - "hakurei.app/container/stub" "hakurei.app/hst" + "hakurei.app/internal/stub" ) func TestHardlinkOp(t *testing.T) { diff --git a/internal/system/mkdir_test.go b/internal/system/mkdir_test.go index e5d97b3a..f226a48d 100644 --- a/internal/system/mkdir_test.go +++ b/internal/system/mkdir_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestMkdirOp(t *testing.T) { diff --git a/internal/system/pipewire_test.go b/internal/system/pipewire_test.go index a39237f8..052495cc 100644 --- a/internal/system/pipewire_test.go +++ b/internal/system/pipewire_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "hakurei.app/container/stub" "hakurei.app/internal/acl" "hakurei.app/internal/pipewire" + "hakurei.app/internal/stub" ) func TestPipeWireOp(t *testing.T) { diff --git a/internal/system/system_test.go b/internal/system/system_test.go index f82aa932..d0b1154e 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -9,8 +9,8 @@ import ( "testing" "hakurei.app/check" - "hakurei.app/container/stub" "hakurei.app/hst" + "hakurei.app/internal/stub" "hakurei.app/internal/xcb" "hakurei.app/message" ) diff --git a/internal/system/wayland_test.go b/internal/system/wayland_test.go index 0fdaf818..76e6cc93 100644 --- a/internal/system/wayland_test.go +++ b/internal/system/wayland_test.go @@ -4,8 +4,8 @@ import ( "os" "testing" - "hakurei.app/container/stub" "hakurei.app/internal/acl" + "hakurei.app/internal/stub" ) func TestWaylandOp(t *testing.T) { diff --git a/internal/system/xhost_test.go b/internal/system/xhost_test.go index 72ecf2d6..b7a591d3 100644 --- a/internal/system/xhost_test.go +++ b/internal/system/xhost_test.go @@ -3,8 +3,8 @@ package system import ( "testing" - "hakurei.app/container/stub" "hakurei.app/hst" + "hakurei.app/internal/stub" "hakurei.app/internal/xcb" ) diff --git a/internal/wayland/wayland_test.go b/internal/wayland/wayland_test.go index 1db5f35d..8cc0a10e 100644 --- a/internal/wayland/wayland_test.go +++ b/internal/wayland/wayland_test.go @@ -7,7 +7,7 @@ import ( "syscall" "testing" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" ) func TestError(t *testing.T) { diff --git a/message/message_test.go b/message/message_test.go index 6b07713e..4570b90a 100644 --- a/message/message_test.go +++ b/message/message_test.go @@ -10,7 +10,7 @@ import ( "testing" "hakurei.app/container" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" "hakurei.app/message" ) diff --git a/message/output_test.go b/message/output_test.go index 0305f3f2..e0af9cfa 100644 --- a/message/output_test.go +++ b/message/output_test.go @@ -8,7 +8,7 @@ import ( "syscall" "testing" - "hakurei.app/container/stub" + "hakurei.app/internal/stub" "hakurei.app/message" ) -- cgit v1.3.1