e2etest/newe2e_scenario_variation_manager.go (345 lines of code) (raw):
package e2etest
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"runtime"
"strings"
"testing"
)
// ScenarioVariationManager manages one specific variation of a scenario.
type ScenarioVariationManager struct {
// t is intentionally nil during dryruns.
t *testing.T
// runNow disables parallelism in testing, instead running all tests immediately, intended for run-first suites.
runNow bool
// isInvalid is synonymous with Failed. It serves two purposes:
// 1. Invalidating dry-runs that would under no deterministic circumstances succeed.
// 2. Failing wet-runs that encountered an error or unexpected results.
// If you manually set invalid in a dry run, it will get caught when trying to spawn new variations
// or when completing the current variation. No impact will occur.
isInvalid bool
// Dryrun is indicative that this is not a real run, Asserter will be filled with a ScenarioDryrunAsserter,
// and this initial run is for mapping out calls to GetVariation(ID, options) (usually ResolveVariation).
// The test will continue to spawn other variations until all variations have been mapped.
//Dryrun bool
// callcounts should under no circumstances be modified by hand.
// If you are in need of a repeated singular variation result,
// please call GetVariation using a custom static ID.
callcounts map[string]uint
// Parent refers to the running scenario.
Parent *ScenarioManager
// VariationData is a mapping of IDs to values, in order.
VariationData *VariationDataContainer // todo call order, prepared options
VariationUUID uuid.UUID
// wetrun data
RunContext context.Context
CreatedResources *PathTrie[createdResource]
CleanupFuncs []func(a Asserter)
}
func (svm *ScenarioVariationManager) GetTestName() string {
if svm.t != nil {
return svm.t.Name()
} else {
return svm.Parent.testingT.Name() + "/" + svm.VariationName()
}
}
func (svm *ScenarioVariationManager) Context() context.Context {
if svm.RunContext == nil {
return context.Background()
}
return svm.RunContext
}
func (svm *ScenarioVariationManager) SetContext(ctx context.Context) {
svm.RunContext = ctx
}
func (svm *ScenarioVariationManager) UUID() uuid.UUID {
if svm.VariationUUID == uuid.Nil { // ensure we aren't handing back something empty
svm.VariationUUID = uuid.New()
}
return svm.VariationUUID
}
type createdResource struct {
acct AccountResourceManager
res ResourceManager
}
func (svm *ScenarioVariationManager) initResourceTracker() {
if svm.CreatedResources == nil {
svm.CreatedResources = NewTrie[createdResource]('/')
}
}
func (svm *ScenarioVariationManager) TrackCreatedResource(manager ResourceManager) {
svm.initResourceTracker()
canon := manager.Canon()
svm.CreatedResources.Insert(canon, &createdResource{res: manager})
}
func (svm *ScenarioVariationManager) TrackCreatedAccount(account AccountResourceManager) {
svm.initResourceTracker()
svm.CreatedResources.Insert(account.AccountName(), &createdResource{acct: account})
}
func (svm *ScenarioVariationManager) DeleteCreatedResources() {
svm.initResourceTracker()
type deletable interface {
Delete(a Asserter)
}
svm.CreatedResources.Traverse(func(data *createdResource) TraversalOperation {
if data.acct != nil {
DeleteAccount(svm, data.acct)
} else if data.res != nil {
del, isDeletable := data.res.(deletable)
if !isDeletable {
return TraversalOperationContinue
}
del.Delete(svm)
}
return TraversalOperationRemove
})
svm.CreatedResources = nil
}
// Assertions
func (svm *ScenarioVariationManager) NoError(comment string, err error, failNow ...bool) {
if svm.Dryrun() {
return
}
svm.t.Helper()
failFast := FirstOrZero(failNow)
//svm.AssertNow(comment, IsNil{}, err)
if err != nil {
svm.t.Logf("Error was not nil (%s): %v", comment, err)
svm.isInvalid = true // Flip the failed flag
if failFast {
svm.t.FailNow()
} else {
svm.t.Fail()
}
}
}
func (svm *ScenarioVariationManager) Assert(comment string, assertion Assertion, items ...any) {
if svm.Dryrun() {
return
}
svm.t.Helper()
if !assertion.Assert(items...) {
if fa, ok := assertion.(FormattedAssertion); ok {
svm.t.Logf("Assertion %s failed: %s (%s)", fa.Name(), fa.Format(items...), comment)
} else {
svm.t.Logf("Assertion %s failed with items %v (%s)", assertion.Name(), items, comment)
}
svm.isInvalid = true // We've now failed, so we flip the shared bad flag
svm.t.Fail()
}
}
func (svm *ScenarioVariationManager) AssertNow(comment string, assertion Assertion, items ...any) {
if svm.Dryrun() {
return
}
svm.t.Helper()
if !assertion.Assert(items...) {
if fa, ok := assertion.(FormattedAssertion); ok {
svm.t.Logf("Assertion %s failed: %s (%s)", fa.Name(), fa.Format(items...), comment)
} else {
svm.t.Logf("Assertion %s failed with items %v (%s)", assertion.Name(), items, comment)
}
svm.isInvalid = true // We've now failed, so we flip the shared bad flag
svm.t.FailNow()
}
}
func (svm *ScenarioVariationManager) Error(reason string) {
if svm.Dryrun() {
return
}
svm.t.Helper()
svm.isInvalid = true
svm.t.Log("Error: " + reason)
svm.t.FailNow()
}
func (svm *ScenarioVariationManager) Skip(reason string) {
if svm.Dryrun() {
return
}
svm.t.Helper()
svm.t.Log("Skipped: " + reason)
// No special flag is needed. We can surmise that if a test did a internalTestExit panic
// and it is not invalid, that the only other logical reason is that we intentionally skipped it.
svm.t.SkipNow()
//panic(internalTestExit) // skip exits immediately
}
func (svm *ScenarioVariationManager) Log(format string, a ...any) {
if svm.Dryrun() {
return
}
svm.t.Helper()
svm.t.Logf(format, a...)
}
func (svm *ScenarioVariationManager) Failed() bool {
return svm.isInvalid // This is actually technically safe during dryruns.
}
func (svm *ScenarioVariationManager) HelperMarker() HelperMarker {
if svm.t != nil {
return svm.t
}
return NilHelperMarker{}
}
// =========== Variation Handling ==========
var variationExcludedCallers = map[string]bool{
"GetVariation": true,
"ResolveVariation": true,
"GetVariationCallerID": true,
"NamedResolveVariation": true,
}
func (svm *ScenarioVariationManager) VariationName() string {
return svm.VariationData.GetTestName()
}
// GetVariation acts as a simple dictionary for IDs to variations.
// If no variation with the related ID is found
func (svm *ScenarioVariationManager) GetVariation(ID string, options []any) any {
var currentVariation any
var variationExists bool
if svm.VariationData != nil {
currentVariation, variationExists = svm.VariationData.Lookup(ID)
}
if variationExists {
return currentVariation
} else {
currentVariation = options[0] // Default.
options = options[1:] // Trim for remaining variations
if !svm.isInvalid { // Don't spawn other variations if invalid
svm.Parent.NewVariation(svm, ID, options)
}
svm.VariationData = svm.VariationData.Insert(ID, currentVariation)
return currentVariation
}
}
// GetVariationCallerID builds a raw caller ID based upon the current call stack.
// It returns an incremented caller ID (e.g. with the ";calls=n" suffix).
func (svm *ScenarioVariationManager) GetVariationCallerID() (callerID string) {
type scopedFunction struct {
Package string
Scope []string
Name string
}
type caller struct {
// Func isn't ultimately needed for the return value, but it is needed to pick up functions we should avoid in the call stack.
// todo: maybe find a better way to exclude these functions?
Func scopedFunction
File string
Line int
}
// Get from test to variation in the call stack
skippedCalls := 0
callerIDs := make([]string, 0)
for {
callerPC, callerFile, callerLine, ok := runtime.Caller(len(callerIDs) + skippedCalls)
// Ensure we're calling from the right place
svm.AssertNow(fmt.Sprintf("%s must be included in the call stack prior to GetVariation", svm.Parent.scenario), Equal{}, true, ok)
fn := runtime.FuncForPC(callerPC)
rawName := fn.Name()
// Trim package name
lastSlash := strings.LastIndex(rawName, "/")
var pkgName string
if lastSlash != -1 {
//prefixedFuncName := rawName[lastSlash+1:]
//// There should always be a dot here. Sanity check if we don't.
pkgName = rawName[:lastSlash+1]
rawName = rawName[lastSlash+1:] // trim prefix from raw name
}
pkgDotIdx := strings.Index(rawName, ".")
svm.AssertNow(fmt.Sprintf("functions must have a package prefix"), Not{Equal{}}, pkgDotIdx, -1)
pkgName += rawName[:pkgDotIdx]
rawName = rawName[pkgDotIdx+1:]
scopeSegments := strings.Split(strings.TrimRight(rawName, "[...]"), ".")
funcName := scopeSegments[len(scopeSegments)-1]
currentCaller := caller{
Func: scopedFunction{
Package: pkgName,
Scope: scopeSegments[:len(scopeSegments)-1],
Name: funcName,
},
File: callerFile,
Line: callerLine,
}
if ok := variationExcludedCallers[currentCaller.Func.Name]; !ok {
callerIDs = append(callerIDs, fmt.Sprintf("%s:%d", currentCaller.File, currentCaller.Line))
} else {
skippedCalls++
}
if strings.EqualFold(currentCaller.Func.Name, svm.Parent.scenario) {
break
}
}
callerID = strings.Join(callerIDs, ";")
svm.callcounts[callerID]++
callCount := svm.callcounts[callerID]
callerID += fmt.Sprintf(";calls=%d", callCount)
return
}
// InsertVariationSeparator is mostly used to clean up variation names (e.g. rather than BlobBlobCopy, Blob->Blob_Copy)
func (svm *ScenarioVariationManager) InsertVariationSeparator(sep string) {
// 1 variation won't spawn new runs
svm.GetVariation(svm.GetVariationCallerID(), []any{sep})
}
func (svm *ScenarioVariationManager) Dryrun() bool {
return svm.t == nil
}
func (svm *ScenarioVariationManager) Invalid() bool {
return svm.isInvalid
}
func (svm *ScenarioVariationManager) InvalidateScenario() {
svm.isInvalid = true
}
func (svm *ScenarioVariationManager) Cleanup(cleanupFunc CleanupFunc) {
if svm.Dryrun() {
svm.Error("Sanity check: svm.Cleanup should not be called during a dry run. No real actions should be taken during a dry run.")
return
}
svm.CleanupFuncs = append(svm.CleanupFuncs, cleanupFunc)
}
// ResolveVariation wraps ScenarioVariationManager.GetVariation, returning the variation as the user's requested type, and using the call stack as the ID
// ResolveVariation doesn't have a type receiver, because the type itself must have a generic type in order for one of its methods to be generic
func ResolveVariation[T any](svm *ScenarioVariationManager, options []T) T {
return GetTypeOrZero[T](svm.GetVariation(svm.GetVariationCallerID(), ListOfAny(options)))
}
// ResolveVariationByID is the same as ResolveVariation, but it's based upon the supplied ID rather than the call stack.
func ResolveVariationByID[T any](svm *ScenarioVariationManager, ID string, options []any) T {
return GetTypeOrZero[T](svm.GetVariation(ID, ListOfAny(options)))
}
// NamedResolveVariation is similar to ResolveVariation, but instead resolves over the keys in options, and hands back T.
func NamedResolveVariation[T any](svm *ScenarioVariationManager, options map[string]T) T {
variation := GetTypeOrZero[string](svm.GetVariation(svm.GetVariationCallerID(), AnyKeys(options)))
return options[variation]
}
var CleanupStepEarlyExit = errors.New("cleanupEarlyExit")
type ScenarioVariationManagerCleanupAsserter struct {
svm *ScenarioVariationManager
}
func (s *ScenarioVariationManagerCleanupAsserter) GetTestName() string {
return s.svm.GetTestName()
}
func (s *ScenarioVariationManagerCleanupAsserter) WrapCleanup(cf CleanupFunc) {
defer func() {
if err := recover(); err != nil {
if err == CleanupStepEarlyExit {
return
}
s.Log("Cleanup step panicked: %v", err)
}
}()
cf(s)
}
func (s *ScenarioVariationManagerCleanupAsserter) NoError(comment string, err error, failNow ...bool) {
s.svm.t.Helper()
failFast := FirstOrZero(failNow)
//svm.AssertNow(comment, IsNil{}, err)
if err != nil {
s.Log("Error was not nil (%s): %v", comment, err)
s.svm.isInvalid = true // Flip the failed flag
s.svm.t.Fail()
if failFast {
panic(CleanupStepEarlyExit)
}
}
}
func (s *ScenarioVariationManagerCleanupAsserter) Assert(comment string, assertion Assertion, items ...any) {
s.svm.t.Helper()
if !assertion.Assert(items...) {
if fa, ok := assertion.(FormattedAssertion); ok {
s.Log("Assertion %s failed: %s (%s)", fa.Name(), fa.Format(items...), comment)
} else {
s.Log("Assertion %s failed with items %v (%s)", assertion.Name(), items, comment)
}
s.svm.isInvalid = true // We've now failed, so we flip the shared bad flag
s.svm.t.Fail()
}
}
func (s *ScenarioVariationManagerCleanupAsserter) AssertNow(comment string, assertion Assertion, items ...any) {
s.svm.t.Helper()
if !assertion.Assert(items...) {
if fa, ok := assertion.(FormattedAssertion); ok {
s.Log("Assertion %s failed: %s (%s)", fa.Name(), fa.Format(items...), comment)
} else {
s.Log("Assertion %s failed with items %v (%s)", assertion.Name(), items, comment)
}
s.svm.isInvalid = true // We've now failed, so we flip the shared bad flag
s.svm.t.Fail()
panic(CleanupStepEarlyExit)
}
}
func (s *ScenarioVariationManagerCleanupAsserter) Error(reason string) {
s.svm.t.Helper()
s.Log("Failed cleanup step: %v", reason)
panic(CleanupStepEarlyExit)
}
func (s *ScenarioVariationManagerCleanupAsserter) Skip(reason string) {
s.svm.t.Helper()
s.Log("Cleanup step skipped: %v", reason)
panic(CleanupStepEarlyExit)
}
func (s *ScenarioVariationManagerCleanupAsserter) Log(format string, a ...any) {
s.svm.t.Helper()
s.svm.Log(format, a...)
}
func (s *ScenarioVariationManagerCleanupAsserter) Failed() bool {
return s.svm.Failed()
}
func (s *ScenarioVariationManagerCleanupAsserter) HelperMarker() HelperMarker {
return s.svm.HelperMarker()
}