Files
kjol/tools/tsgo/internal/fswatch/testutil_test.go
2026-07-09 16:50:43 -04:00

194 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package fswatch
import "testing"
// testingT is the subset of [testing.T] (and [testing.TB]) used by every
// test in this package. It exists so that the per-backend test bodies
// dispatched through [runForEachWatcher] can be re-run by a fake T that
// captures Fatal/Skip via panic+recover instead of terminating the
// goroutine. macOS event-delivery stalls (which are not regressions but
// environmental flakes) can then be transparently retried before
// propagating to the real *testing.T.
//
// Restricted vs *testing.T:
// - No Parallel, Run, or other subtest plumbing.
// - No Setenv / Chdir (would race across retries).
//
// All helper functions in this file accept testingT rather than
// *testing.T so they work with both the real test runner and the retry
// wrapper.
type testingT interface {
Helper()
Cleanup(fn func())
TempDir() string
Name() string
Log(args ...any)
Logf(format string, args ...any)
Error(args ...any)
Errorf(format string, args ...any)
Fatal(args ...any)
Fatalf(format string, args ...any)
Skip(args ...any)
Skipf(format string, args ...any)
SkipNow()
Failed() bool
}
// Compile-time assertion that *testing.T satisfies testingT.
var _ testingT = (*testing.T)(nil)
// retryAttempts is the number of times runForEachWatcher will re-run a
// failing per-backend test body before propagating the failure to the
// real *testing.T. The per-event timeouts inside the body scale with
// the attempt number (1×, 5×, 15×), so the fast-path is cheap and only
// real environmental flakes pay the cost of longer waits.
const retryAttempts = 3
// retryTimeoutScale returns the multiplier applied to per-event timeouts
// on the given (1-based) attempt. 1× on first try, 5× on second,
// 15× on third.
func retryTimeoutScale(attempt int) int {
switch attempt {
case 1:
return 1
case 2:
return 5
default:
return 15
}
}
// retryT is a fake [testingT] used to run a test body and decide
// whether it passed without committing the verdict to the real
// *testing.T on intermediate attempts.
//
// Most methods (Helper, Cleanup, TempDir, Name, Log, Logf) are direct
// passthroughs to the real T so messages stream to test output as they
// happen rather than waiting for a verdict. Error/Errorf record a
// failure locally but also log to the real T (so the message is visible
// even on a successful retry). Fatal/Fatalf/Skip[Now/f] additionally
// panic with [retryBail] to unwind the goroutine; the retry driver
// recovers and either retries or surfaces a final failure.
type retryT struct {
t *testing.T
// attempt is 1-based and increases on each retry. Per-event
// timeouts in helpers (waitForEvent etc.) scale from this so the
// fast-path uses a short deadline and only retries pay the cost of
// longer waits.
attempt int
failed bool
skipped bool
}
// retryBail is panicked by Fatal/Fatalf/SkipNow/Skip[f] to abort the
// test body. The retry driver recovers it and inspects the retryT
// state to decide whether to retry, surface a skip, or accept success.
type retryBail struct{}
func newRetryT(t *testing.T, attempt int) *retryT {
return &retryT{t: t, attempt: attempt}
}
func (r *retryT) Helper() { r.t.Helper() }
func (r *retryT) Cleanup(fn func()) { r.t.Cleanup(fn) }
func (r *retryT) TempDir() string { return r.t.TempDir() }
func (r *retryT) Name() string { return r.t.Name() }
func (r *retryT) Log(args ...any) { r.t.Helper(); r.t.Log(args...) }
func (r *retryT) Logf(format string, args ...any) { r.t.Helper(); r.t.Logf(format, args...) }
func (r *retryT) Failed() bool { return r.failed }
func (r *retryT) Error(args ...any) {
r.t.Helper()
r.failed = true
r.t.Log(args...)
}
func (r *retryT) Errorf(format string, args ...any) {
r.t.Helper()
r.failed = true
r.t.Logf(format, args...)
}
func (r *retryT) Fatal(args ...any) {
r.t.Helper()
r.failed = true
r.t.Log(args...)
panic(retryBail{})
}
func (r *retryT) Fatalf(format string, args ...any) {
r.t.Helper()
r.failed = true
r.t.Logf(format, args...)
panic(retryBail{})
}
func (r *retryT) Skip(args ...any) {
r.t.Helper()
r.skipped = true
r.t.Log(args...)
panic(retryBail{})
}
func (r *retryT) Skipf(format string, args ...any) {
r.t.Helper()
r.skipped = true
r.t.Logf(format, args...)
panic(retryBail{})
}
func (r *retryT) SkipNow() {
r.skipped = true
panic(retryBail{})
}
// runWithRetry runs body up to [retryAttempts] times. Each attempt uses
// a fresh retryT whose attempt counter scales the per-event timeouts in
// the test helpers. Returns on the first attempt that does not fail
// (Skip and success both terminate the loop). On final failure the real
// T is marked failed; intermediate failures are visible in test output
// as Log messages (from Error/Errorf/Fatal/Fatalf streaming through)
// followed by a "retry: ..." log noting the next attempt.
//
// On the fast-path (body passes first try), this is one call with
// negligible overhead over a direct invocation.
func runWithRetry(t *testing.T, body func(testingT)) {
t.Helper()
for attempt := 1; attempt <= retryAttempts; attempt++ {
r := newRetryT(t, attempt)
func() {
defer func() {
if rec := recover(); rec != nil {
if _, ok := rec.(retryBail); !ok {
// Not our panic; resurface.
panic(rec)
}
}
}()
body(r)
}()
if r.skipped {
t.SkipNow()
return
}
if !r.failed {
if attempt > 1 {
t.Logf("retry: succeeded on attempt %d/%d", attempt, retryAttempts)
}
return
}
if attempt < retryAttempts {
t.Logf("retry: attempt %d/%d failed, retrying with %d× timeout scale",
attempt, retryAttempts, retryTimeoutScale(attempt+1))
}
}
t.Errorf("retry: gave up after %d attempts", retryAttempts)
}