summaryrefslogtreecommitdiff
path: root/testcapture
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-06-18 23:23:36 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-06-19 20:16:11 +0200
commit71d31d73e7dc3b9cdb3c53b4f343b1928a9e858b (patch)
treee1cd6d01837ef6ef07504ba3893c6f5e55f8740b /testcapture
parentb95ec7449ebec844221f80ecbf7976f3af878eee (diff)
downloadgo-gg-71d31d73e7dc3b9cdb3c53b4f343b1928a9e858b.tar.gz
add package testcapture, package assert (minimal implementation)
Diffstat (limited to 'testcapture')
-rw-r--r--testcapture/capture.go416
-rw-r--r--testcapture/capture_test.go403
2 files changed, 819 insertions, 0 deletions
diff --git a/testcapture/capture.go b/testcapture/capture.go
new file mode 100644
index 0000000..3ac4f52
--- /dev/null
+++ b/testcapture/capture.go
@@ -0,0 +1,416 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+// Package testcapture contains [Capture], a function that executes test code in a way that captures error messages and side effects without failing the overall test.
+//
+// The main intended use case is testing test assertions where calls to e.g. t.Error() are an expected part of a successful test run.
+package testcapture
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "sync"
+ "sync/atomic"
+
+ "go.xyrillian.de/gg/assert"
+)
+
+// Result is returned by func [Capture].
+type Result struct {
+ // Outcome describes how the test ended.
+ Outcome Outcome
+ // Panic contains a payload recovered from a panic(), if Outcome is [OutcomePanicked].
+ Panic any
+ // Messages contains log lines captured from t.Log() calls, or functions calling t.Log(), such as t.Error() and t.Fatal();
+ // as well as data captured from t.Output().Write() calls.
+ Messages []Message
+ // Attrs contains attributes captured in t.Attr() calls.
+ Attrs map[string]string
+ // Artifacts holds the contents of any regular files that were created below t.ArtifactDir(), keyed with the path relative to t.ArtifactDir().
+ Artifacts map[string]string
+}
+
+// Outcome is an enum.
+// It appears in type [Result].
+type Outcome string
+
+const (
+ // OutcomeFinished describes a [Capture] that ended with the test running to completion.
+ OutcomeFinished Outcome = "finished"
+ // OutcomeFailed describes a [Capture] that ended early because of a t.FailNow() call.
+ OutcomeFailed Outcome = "failed"
+ // OutcomeSkipped describes a [Capture] that ended early because of a t.SkipNow() call.
+ OutcomeSkipped Outcome = "skipped"
+ // OutcomePanicked describes a [Capture] that ended early because of a panic() call.
+ OutcomePanicked Outcome = "panicked"
+)
+
+// Message is a piece of log output captured by func [Capture].
+// It appears in type [Result].
+// - Each call to t.Log(), t.Logf() or their derived functions results in one Message instance of type [Log].
+// - Writing into t.Output() between two calls to t.Log(), t.Logf() etc. results in a single Message instance of type [Output], even if Write() is called multiple times.
+type Message struct {
+ Message string
+ Type MessageType
+}
+
+// Log is a shorthand for constructing [Message] objects of type [MessageTypeLog].
+func Log[T interface{ ~string }](message T) Message {
+ return Message{string(message), MessageTypeLog}
+}
+
+// Output is a shorthand for constructing [Message] objects of type [MessageTypeOutput].
+func Output[T interface{ ~string | ~[]byte }](message T) Message {
+ return Message{string(message), MessageTypeOutput}
+}
+
+// MessageType is an enum.
+// It appears in type [Message].
+type MessageType string
+
+const (
+ // MessageTypeLog describes [Message] instances created by calls to t.Log(), t.Logf(), or functions calling them, such as t.Error() and t.Fatal().
+ MessageTypeLog MessageType = "log"
+ // MessageTypeOutput describes [Message] instances created by calls to t.Output().Write().
+ MessageTypeOutput MessageType = "output"
+)
+
+// Capture executes a test function with a stub implementation of [assert.TestingTB] that captures all calls to it.
+// It is intended for unit-testing test assertions.
+//
+// The name argument is what will be reported in t.Name() within the test.
+func Capture(ctx context.Context, name string, test func(assert.TestingTB)) Result {
+ r := Result{
+ Outcome: OutcomeFinished, // can be overridden by Fail() or SkipNow()
+ }
+ executeCapture(ctx, name, &r, test)
+ return r
+}
+
+// capturer is the implementation of [assert.TestingTB] used by func [Capture].
+type capturer struct {
+ context context.Context
+ cleanups []func()
+ name string
+ result *Result
+ state struct {
+ ArtifactDir string
+ }
+
+ cleanupsMutex sync.Mutex // lock for access to the `cleanups` field
+ resultMutex sync.RWMutex // lock for access to the `result` field
+ stateMutex sync.Mutex // lock for access to the `state` field
+ nonlocalMutex sync.Mutex // lock for non-local effects like Setenv() or filesystem operations
+}
+
+func executeCapture(ctx context.Context, name string, r *Result, test func(assert.TestingTB)) {
+ ctx, cancel := context.WithCancel(ctx)
+ t := capturer{
+ context: ctx,
+ cleanups: nil,
+ name: name,
+ result: r,
+ }
+ defer func() {
+ t.setOutcome(recover())
+ cancel() // T.Context() demands that the context be canceled before any cleanup handlers
+ for _, cleanup := range slices.Backward(t.cleanups) {
+ cleanup()
+ }
+ }()
+ test(&t)
+}
+
+func (t *capturer) setOutcome(panicPayload any) {
+ t.resultMutex.Lock()
+ defer t.resultMutex.Unlock()
+ if panicPayload == nil {
+ return
+ } else if outcome, ok := panicPayload.(Outcome); ok {
+ t.result.Outcome = outcome
+ } else {
+ t.result.Outcome = OutcomePanicked
+ t.result.Panic = panicPayload
+ }
+}
+
+func (t *capturer) pushOutput(buf []byte, msgType MessageType) {
+ t.resultMutex.Lock()
+ defer t.resultMutex.Unlock()
+
+ // try to merge consecutive t.Output().Write() calls together
+ if msgType == MessageTypeOutput && len(t.result.Messages) > 0 {
+ idx := len(t.result.Messages) - 1
+ msg := t.result.Messages[idx]
+ if msg.Type == MessageTypeOutput {
+ msg.Message = msg.Message + string(buf)
+ t.result.Messages[idx] = msg
+ return
+ }
+ }
+
+ t.result.Messages = append(t.result.Messages, Message{
+ Message: string(buf),
+ Type: msgType,
+ })
+}
+
+var tempdirID atomic.Uint64
+
+func pickTempdir() (string, error) {
+ path := filepath.Join(os.TempDir(), fmt.Sprintf("gg-assert-capture-%d", tempdirID.Add(1)))
+ return path, os.MkdirAll(path, 0777)
+}
+
+func collectArtifacts(dirPath string) (map[string]string, error) {
+ dir, err := os.OpenRoot(dirPath)
+ if err != nil {
+ return nil, err
+ }
+ dirFS := dir.FS()
+
+ result := make(map[string]string)
+ err = fs.WalkDir(dirFS, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.Type().IsRegular() {
+ buf, err := fs.ReadFile(dirFS, path)
+ if err != nil {
+ return err
+ }
+ result[path] = string(buf)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return result, os.RemoveAll(dirPath)
+}
+
+// ArtifactDir implements the [assert.TestingTB] interface.
+func (t *capturer) ArtifactDir() string {
+ t.stateMutex.Lock()
+ defer t.stateMutex.Unlock()
+ if t.state.ArtifactDir == "" {
+ path, err := pickTempdir()
+ if err != nil {
+ t.Fatal("in t.ArtifactDir(): ", err)
+ }
+ t.state.ArtifactDir = path
+
+ t.Cleanup(func() {
+ artifacts, err := collectArtifacts(path)
+ if err == nil {
+ t.resultMutex.Lock()
+ defer t.resultMutex.Unlock()
+ t.result.Artifacts = artifacts
+ } else {
+ t.Error(err)
+ }
+ })
+ }
+ return t.state.ArtifactDir
+}
+
+// Attr implements the [assert.TestingTB] interface.
+func (t *capturer) Attr(key, value string) {
+ t.resultMutex.Lock()
+ defer t.resultMutex.Unlock()
+ if t.result.Attrs == nil {
+ t.result.Attrs = make(map[string]string)
+ }
+ t.result.Attrs[key] = value
+}
+
+// Chdir implements the [assert.TestingTB] interface.
+func (t *capturer) Chdir(dir string) {
+ t.doChdir(dir)
+
+ // the following is done outside of doChdir() because t.Setenv() also locks t.nonlocalMutex
+ switch runtime.GOOS {
+ case "windows", "plan9":
+ // these platforms do not use the PWD variable
+ default:
+ dir, err := os.Getwd() // returns an absolute path even if `dir` is not one
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PWD", dir)
+ }
+}
+
+func (t *capturer) doChdir(dir string) {
+ t.nonlocalMutex.Lock()
+ defer t.nonlocalMutex.Unlock()
+
+ oldDir, err := os.Open(".")
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = os.Chdir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Cleanup(func() {
+ err := oldDir.Chdir()
+ if err != nil {
+ t.Error("could not reset cwd changed by t.Chdir(): ", err)
+ }
+ })
+}
+
+// Cleanup implements the [assert.TestingTB] interface.
+func (t *capturer) Cleanup(action func()) {
+ t.cleanupsMutex.Lock()
+ defer t.cleanupsMutex.Unlock()
+ t.cleanups = append(t.cleanups, action)
+}
+
+// Context implements the [assert.TestingTB] interface.
+func (t *capturer) Context() context.Context {
+ return t.context
+}
+
+// Error implements the [assert.TestingTB] interface.
+func (t *capturer) Error(args ...any) {
+ t.Log(args...)
+ t.Fail()
+}
+
+// Errorf implements the [assert.TestingTB] interface.
+func (t *capturer) Errorf(format string, args ...any) {
+ t.Logf(format, args...)
+ t.Fail()
+}
+
+// Fail implements the [assert.TestingTB] interface.
+func (t *capturer) Fail() {
+ t.resultMutex.Lock()
+ defer t.resultMutex.Unlock()
+ t.result.Outcome = OutcomeFailed
+}
+
+// Failed implements the [assert.TestingTB] interface.
+func (t *capturer) Failed() bool {
+ t.resultMutex.RLock()
+ defer t.resultMutex.RUnlock()
+ return t.result.Outcome == OutcomeFailed
+}
+
+// FailNow implements the [assert.TestingTB] interface.
+func (t *capturer) FailNow() {
+ panic(OutcomeFailed)
+}
+
+// Fatal implements the [assert.TestingTB] interface.
+func (t *capturer) Fatal(args ...any) {
+ t.Log(args...)
+ t.FailNow()
+}
+
+// Fatalf implements the [assert.TestingTB] interface.
+func (t *capturer) Fatalf(format string, args ...any) {
+ t.Logf(format, args...)
+ t.FailNow()
+}
+
+// Helper implements the [assert.TestingTB] interface.
+func (t *capturer) Helper() {
+ // no-op because we do not collect file and line information at the moment
+}
+
+// Log implements the [assert.TestingTB] interface.
+func (t *capturer) Log(args ...any) {
+ t.pushOutput(fmt.Append(nil, args...), MessageTypeLog)
+}
+
+// Logf implements the [assert.TestingTB] interface.
+func (t *capturer) Logf(format string, args ...any) {
+ t.pushOutput(fmt.Appendf(nil, format, args...), MessageTypeLog)
+}
+
+// Name implements the [assert.TestingTB] interface.
+func (t *capturer) Name() string {
+ return t.name
+}
+
+// Output implements the [assert.TestingTB] interface.
+func (t *capturer) Output() io.Writer {
+ return outputCapturer{t}
+}
+
+type outputCapturer struct {
+ t *capturer
+}
+
+// Write implements the [io.Writer] interface.
+func (c outputCapturer) Write(buf []byte) (int, error) {
+ c.t.pushOutput(buf, MessageTypeOutput)
+ return len(buf), nil
+}
+
+// Setenv implements the [assert.TestingTB] interface.
+func (t *capturer) Setenv(key, value string) {
+ t.nonlocalMutex.Lock()
+ defer t.nonlocalMutex.Unlock()
+
+ oldValue, hasOldValue := os.LookupEnv(key)
+ os.Setenv(key, value)
+
+ t.Cleanup(func() {
+ if hasOldValue {
+ os.Setenv(key, oldValue)
+ } else {
+ os.Unsetenv(key)
+ }
+ })
+}
+
+// Skip implements the [assert.TestingTB] interface.
+func (t *capturer) Skip(args ...any) {
+ t.Log(args...)
+ t.SkipNow()
+}
+
+// Skipf implements the [assert.TestingTB] interface.
+func (t *capturer) Skipf(format string, args ...any) {
+ t.Logf(format, args...)
+ t.SkipNow()
+}
+
+// SkipNow implements the [assert.TestingTB] interface.
+func (t *capturer) SkipNow() {
+ panic(OutcomeSkipped)
+}
+
+// Skipped implements the [assert.TestingTB] interface.
+func (t *capturer) Skipped() bool {
+ t.resultMutex.RLock()
+ defer t.resultMutex.RUnlock()
+ return t.result.Outcome == OutcomeSkipped
+}
+
+// TempDir implements the [assert.TestingTB] interface.
+func (t *capturer) TempDir() string {
+ path, err := pickTempdir()
+ if err != nil {
+ t.Fatal("in t.TempDir(): ", err)
+ }
+ t.Cleanup(func() {
+ err := os.RemoveAll(path)
+ if err != nil {
+ t.Error(err)
+ }
+ })
+ return path
+}
diff --git a/testcapture/capture_test.go b/testcapture/capture_test.go
new file mode 100644
index 0000000..9e53b71
--- /dev/null
+++ b/testcapture/capture_test.go
@@ -0,0 +1,403 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package testcapture_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "go.xyrillian.de/gg/assert"
+ "go.xyrillian.de/gg/testcapture"
+)
+
+func TestCaptureArtifactDir(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ // test that writing a regular file works
+ fooPath := filepath.Join(t.ArtifactDir(), "foo.txt")
+ err := os.WriteFile(fooPath, []byte("Hello World."), 0666)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // test that nesting regular files into directories works
+ // (and also, implicitly, that multiple calls to ArtifactDir() return the same path)
+ barPath := filepath.Join(t.ArtifactDir(), "bar/a/b/c/d/data.json")
+ err = os.MkdirAll(filepath.Dir(barPath), 0777)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = os.WriteFile(barPath, []byte(`{"bar":42}`), 0666)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // test that empty directories are ignored by the capture
+ err = os.MkdirAll(filepath.Join(t.ArtifactDir(), "unused"), 0777)
+ if err != nil {
+ t.Fatal(err)
+ }
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ Artifacts: map[string]string{
+ "foo.txt": "Hello World.",
+ "bar/a/b/c/d/data.json": `{"bar":42}`,
+ },
+ })
+}
+
+func TestCaptureAttr(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Attr("foo", "bar")
+ t.Attr("foo", "baz")
+ t.Attr("hello", "world")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ Attrs: map[string]string{
+ "foo": "baz",
+ "hello": "world",
+ },
+ })
+}
+
+func TestCaptureChdir(t *testing.T) {
+ var cwdInCapture string
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ // ArtifactDir() is used as a target for Chdir()...
+ dir := t.ArtifactDir()
+ t.Chdir(dir)
+ // ...because we have an easy way to check if we are actually in that dir
+ err := os.WriteFile("foo.txt", []byte("foo"), 0666)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // smuggle the cwd out of the capture for the cleanup test below
+ cwdInCapture, err = os.Getwd()
+ if err != nil {
+ t.Error(err)
+ }
+
+ // t.Chdir() should have set $PWD to an absolute path
+ assert.Equal(t, os.Getenv("PWD"), cwdInCapture)
+ assert.Equal(t, filepath.IsAbs(cwdInCapture), true)
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ Artifacts: map[string]string{"foo.txt": "foo"},
+ })
+
+ // check that we reset the working directory at the end of the test
+ cwdAfterCapture, err := os.Getwd()
+ if err != nil {
+ t.Error(err)
+ }
+ if cwdAfterCapture == cwdInCapture {
+ t.Error("cwd should have been reset, but still is", cwdInCapture)
+ }
+}
+
+func TestCaptureCleanup(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ // test that cleanups run in reverse order of registration, and only after the test itself is done
+ t.Log("starting up")
+ t.Cleanup(func() { t.Log("first cleanup") })
+ t.Cleanup(func() { t.Log("second cleanup") })
+ t.Cleanup(func() { t.Log("third cleanup") })
+ t.Log("shutting down")
+
+ // test that cleanups run even after a panic
+ panic("kaboom")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomePanicked,
+ Messages: []testcapture.Message{
+ testcapture.Log("starting up"),
+ testcapture.Log("shutting down"),
+ testcapture.Log("third cleanup"),
+ testcapture.Log("second cleanup"),
+ testcapture.Log("first cleanup"),
+ },
+ Panic: "kaboom",
+ })
+}
+
+func TestCaptureContext(t *testing.T) {
+ ctx := context.WithValue(t.Context(), "foo", "bar") //nolint:staticcheck // we do not care about type collision risks for this simple test
+ result := testcapture.Capture(ctx, t.Name(), func(t assert.TestingTB) {
+ // test that the context is live within the test
+ err := t.Context().Err()
+ if err != nil {
+ t.Error(err)
+ }
+
+ // test that the context is expired at cleanup time
+ t.Cleanup(func() {
+ err := t.Context().Err()
+ if err == nil {
+ t.Error("still alive!?")
+ }
+ })
+
+ // test that the context is derived from the one passed to Capture()
+ value := ctx.Value("foo")
+ if value != "bar" {
+ t.Error("did not see the value")
+ }
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ })
+}
+
+func TestCaptureError(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ simpleError := errors.New("bar")
+ t.Error("foo: ", simpleError)
+ // check that the test keeps going, and that t.Log() does not reset the Outcome
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("foo: bar"),
+ testcapture.Log("still going"),
+ },
+ })
+}
+
+func TestCaptureErrorf(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Errorf("foo = %d", 42)
+ // check that the test keeps going, and that t.Log() does not reset the Outcome
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("foo = 42"),
+ testcapture.Log("still going"),
+ },
+ })
+}
+
+func TestCaptureFail(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ if !t.Failed() {
+ t.Log("looking good so far")
+ }
+ t.Fail()
+ if t.Failed() {
+ t.Log("still going")
+ }
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("looking good so far"),
+ testcapture.Log("still going"),
+ },
+ })
+}
+
+func TestCaptureFailNow(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ if !t.Failed() {
+ t.Log("looking good so far")
+ }
+ t.FailNow()
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("looking good so far"),
+ // "still going" is not logged because FailNow() bails
+ },
+ })
+}
+
+func TestCaptureFatal(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Log("looking good so far")
+ t.Fatal("kaboom")
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("looking good so far"),
+ testcapture.Log("kaboom"),
+ // "still going" is not logged because Fatal() bails
+ },
+ })
+}
+
+func TestCaptureFatalf(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Log("looking good so far")
+ t.Fatalf("kaboom %d", 42)
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFailed,
+ Messages: []testcapture.Message{
+ testcapture.Log("looking good so far"),
+ testcapture.Log("kaboom 42"),
+ // "still going" is not logged because Fatalf() bails
+ },
+ })
+}
+
+func TestCaptureName(t *testing.T) {
+ result := testcapture.Capture(t.Context(), "Harold", func(t assert.TestingTB) {
+ panic(t.Name() + " died")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomePanicked,
+ Panic: "Harold died",
+ })
+}
+
+func TestCaptureOutput(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Log("hello 1")
+ for range 10 {
+ fmt.Fprintln(t.Output(), "a")
+ }
+ t.Log("hello 2")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ Messages: []testcapture.Message{
+ testcapture.Log("hello 1"),
+ testcapture.Output(strings.Repeat("a\n", 10)),
+ testcapture.Log("hello 2"),
+ },
+ })
+}
+
+func TestCapturePanic(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ t.Error("we are going to blow up")
+ panic("kaboom")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomePanicked, // Panicked takes precedence over Failed
+ Messages: []testcapture.Message{
+ testcapture.Log("we are going to blow up"),
+ },
+ Panic: "kaboom",
+ })
+}
+
+func TestCaptureSetenv(t *testing.T) {
+ t.Setenv("GG_TEST_SETENV_SCOPE", "outer")
+
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ // test t.Setenv() overriding an existing variable
+ assert.Equal(t, os.Getenv("GG_TEST_SETENV_SCOPE"), "outer")
+ t.Setenv("GG_TEST_SETENV_SCOPE", "inner")
+ assert.Equal(t, os.Getenv("GG_TEST_SETENV_SCOPE"), "inner")
+
+ // test t.Setenv() setting a fresh variable
+ // (this variable should be completely removed after the end of the test)
+ t.Setenv("GG_TEST_SETENV_PAYLOAD", "42")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ })
+
+ assert.Equal(t, os.Getenv("GG_TEST_SETENV_SCOPE"), "outer")
+ _, ok := os.LookupEnv("GG_TEST_SETENV_PAYLOAD")
+ assert.Equal(t, ok, false)
+}
+
+func TestCaptureSkip(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ if !t.Skipped() {
+ t.Skip("this looks uninteresting")
+ }
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeSkipped,
+ Messages: []testcapture.Message{
+ testcapture.Log("this looks uninteresting"),
+ // "still going" is not logged because Skip() bails
+ },
+ })
+}
+
+func TestCaptureSkipf(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ if !t.Skipped() {
+ t.Skipf("pretty sure the answer is %d", 42)
+ }
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeSkipped,
+ Messages: []testcapture.Message{
+ testcapture.Log("pretty sure the answer is 42"),
+ // "still going" is not logged because Skipf() bails
+ },
+ })
+}
+
+func TestCaptureSkipNow(t *testing.T) {
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ if !t.Skipped() {
+ t.Log("this looks uninteresting")
+ }
+ t.SkipNow()
+ t.Log("still going")
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeSkipped,
+ Messages: []testcapture.Message{
+ testcapture.Log("this looks uninteresting"),
+ // "still going" is not logged because Skip() bails
+ },
+ })
+}
+
+func TestCaptureTempDir(t *testing.T) {
+ var path string
+ result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) {
+ // fill a TempDir with some stuff
+ path = t.TempDir()
+ err := os.MkdirAll(filepath.Join(path, "emptydir"), 0777)
+ if err != nil {
+ t.Error(err)
+ }
+ err = os.WriteFile(filepath.Join(path, "data.json"), []byte(`{"username":"admin"}`), 0666)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // check that each call to TempDir returns a new dir
+ otherPath := t.TempDir()
+ if otherPath == path {
+ t.Error("should have returned a different TempDir")
+ }
+ })
+ assert.Equal(t, result, testcapture.Result{
+ Outcome: testcapture.OutcomeFinished,
+ })
+
+ // check that TempDir was cleaned up
+ _, err := os.Stat(path)
+ if err == nil {
+ t.Error("TempDir was not cleaned up")
+ } else if !os.IsNotExist(err) {
+ t.Error(err)
+ }
+}