489 lines
18 KiB
Go
489 lines
18 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLoadRunsRecordedResolutionWithMapWriteback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type artifact struct {
|
|
Output string `yaml:"output"`
|
|
Timeout time.Duration `yaml:"timeout"`
|
|
}
|
|
type config struct {
|
|
PollInterval time.Duration `yaml:"poll_interval"`
|
|
Timeout time.Duration `yaml:"timeout"`
|
|
Artifacts map[string]artifact `yaml:"artifacts"`
|
|
}
|
|
definition := Definition[config]{
|
|
Defaults: func() config {
|
|
return config{PollInterval: time.Minute, Timeout: 5 * time.Second}
|
|
},
|
|
Resolve: func(configuration *config, resolution *Resolution) error {
|
|
Default(resolution, Keys("timeout"), &configuration.Timeout,
|
|
configuration.PollInterval/10, "derived from poll interval", Keys("poll_interval"))
|
|
for name, current := range configuration.Artifacts {
|
|
path := Keys("artifacts", name)
|
|
Default(resolution, path.Key("timeout"), ¤t.Timeout,
|
|
configuration.Timeout, "inherited configuration timeout", Keys("timeout"))
|
|
Normalize(resolution, path.Key("output"), ¤t.Output,
|
|
strings.TrimSpace(current.Output), "trimmed surrounding whitespace")
|
|
configuration.Artifacts[name] = current
|
|
}
|
|
resolution.Warning(Keys("timeout"), "using derived timeout", Keys("poll_interval"))
|
|
return nil
|
|
},
|
|
}
|
|
input := "poll_interval: 1m\nartifacts:\n second: {output: ' second '}\n first: {output: ' first '}\n"
|
|
result, err := testLoad(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Config.Timeout != 6*time.Second {
|
|
t.Fatalf("timeout = %s", result.Config.Timeout)
|
|
}
|
|
for name, want := range map[string]string{"first": "first", "second": "second"} {
|
|
current := result.Config.Artifacts[name]
|
|
if current.Timeout != 6*time.Second || current.Output != want {
|
|
t.Errorf("artifact %q = %#v", name, current)
|
|
}
|
|
}
|
|
if diagnostics := result.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != CodeResolveWarning || diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
|
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
|
}
|
|
|
|
metadata := result.Metadata()
|
|
timeout, ok := metadata.Lookup(Keys("timeout"))
|
|
if !ok || timeout.Explicit || timeout.Origin.Layer != LayerResolution || timeout.Origin.Operation != ChangeDerivedDefault {
|
|
t.Fatalf("timeout metadata = %#v, %v", timeout, ok)
|
|
}
|
|
output, ok := metadata.Lookup(Keys("artifacts", "first", "output"))
|
|
if !ok || !output.Explicit || output.Origin.Operation != ChangeNormalization {
|
|
t.Fatalf("output metadata = %#v, %v", output, ok)
|
|
}
|
|
history := metadata.History(Keys("timeout"))
|
|
last := history[len(history)-1]
|
|
if last.Origin.Operation != ChangeDerivedDefault || !reflect.DeepEqual(last.Reasons, []string{"derived from poll interval"}) || len(last.Dependencies) != 1 || last.Dependencies[0].String() != "/poll_interval" {
|
|
t.Fatalf("timeout history = %#v", history)
|
|
}
|
|
wantOrder := []string{
|
|
"/artifacts/first/output", "/artifacts/first/timeout",
|
|
"/artifacts/second/output", "/artifacts/second/timeout", "/timeout",
|
|
}
|
|
gotOrder := make([]string, len(result.resolution))
|
|
for index, snapshot := range result.resolution {
|
|
gotOrder[index] = snapshot.change.Path.String()
|
|
}
|
|
if !reflect.DeepEqual(gotOrder, wantOrder) {
|
|
t.Fatalf("resolution order = %#v", gotOrder)
|
|
}
|
|
}
|
|
|
|
func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type config struct {
|
|
Optional *int `yaml:"optional"`
|
|
Count int `yaml:"count"`
|
|
Absent int `yaml:"absent"`
|
|
}
|
|
five := 5
|
|
result, err := testLoad(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{
|
|
Defaults: func() config { return config{Optional: &five, Count: 5, Absent: 5} },
|
|
Resolve: func(configuration *config, resolution *Resolution) error {
|
|
if !resolution.IsExplicit(Keys("optional")) || !resolution.IsExplicit(Keys("count")) || resolution.IsExplicit(Keys("absent")) {
|
|
return errors.New("unexpected presence")
|
|
}
|
|
Default(resolution, Keys("optional"), &configuration.Optional, &five, "would derive optional")
|
|
Default(resolution, Keys("count"), &configuration.Count, 9, "would derive count")
|
|
Default(resolution, Keys("absent"), &configuration.Absent, 9, "derived absent value")
|
|
return nil
|
|
},
|
|
}})
|
|
if err != nil {
|
|
var loadError *LoadError
|
|
_ = errors.As(err, &loadError)
|
|
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
|
|
}
|
|
if result.Config.Optional != nil || result.Config.Count != 0 || result.Config.Absent != 9 {
|
|
t.Fatalf("config = %#v", result.Config)
|
|
}
|
|
if len(result.Metadata().History(Keys("optional"))) != 2 || len(result.Metadata().History(Keys("count"))) != 2 || len(result.Metadata().History(Keys("absent"))) != 2 {
|
|
t.Fatal("resolution history disagrees with explicitness")
|
|
}
|
|
}
|
|
|
|
func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
parsedOther, err := ParsePath("/other")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
type config struct {
|
|
Value int `yaml:"value"`
|
|
Other int `yaml:"other"`
|
|
}
|
|
result, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{
|
|
Defaults: func() config { return config{Value: 5} },
|
|
Resolve: func(configuration *config, resolution *Resolution) error {
|
|
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
|
|
Default(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", parsedOther)
|
|
Normalize(resolution, Keys("value"), &configuration.Value, 7, "canonicalized value", Keys("other"))
|
|
Normalize(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", parsedOther)
|
|
return nil
|
|
},
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
history := result.Metadata().History(Keys("value"))
|
|
if len(history) != 2 {
|
|
t.Fatalf("history = %#v", history)
|
|
}
|
|
change := history[1]
|
|
if change.Origin.Operation != ChangeNormalization || !reflect.DeepEqual(change.Reasons, []string{"equal derived intent", "updated derived intent", "canonicalized value"}) || len(change.Dependencies) != 1 {
|
|
t.Fatalf("coalesced change = %#v", change)
|
|
}
|
|
}
|
|
|
|
func TestResolutionContainerRecordCoversSubtreeRemovalAndSnapshotsAreDetached(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type item struct {
|
|
Value string `yaml:"value"`
|
|
}
|
|
type config struct {
|
|
Items map[string]item `yaml:"items"`
|
|
}
|
|
result, err := testLoad(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{
|
|
Resolve: func(configuration *config, resolution *Resolution) error {
|
|
delete(configuration.Items, "remove")
|
|
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries")
|
|
return nil
|
|
},
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, exists := result.Config.Items["remove"]; exists {
|
|
t.Fatal("removed entry remains")
|
|
}
|
|
if _, ok := result.Metadata().Lookup(Keys("items", "remove")); ok {
|
|
t.Fatal("removed entry remains effective metadata")
|
|
}
|
|
if len(result.resolution) != 1 || result.resolution[0].before == nil || result.resolution[0].after == nil {
|
|
t.Fatalf("snapshots = %#v", result.resolution)
|
|
}
|
|
result.Config.Items["keep"] = item{Value: "changed"}
|
|
if mappingValue(t, result.resolution[0].after, "keep").Mapping[0].Value.Text != "yes" {
|
|
t.Fatal("caller mutation changed private resolution snapshot")
|
|
}
|
|
}
|
|
|
|
func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type config struct {
|
|
Values []int `yaml:"values"`
|
|
}
|
|
result, err := testLoad(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{
|
|
Resolve: func(configuration *config, resolution *Resolution) error {
|
|
Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values")
|
|
return nil
|
|
},
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, ok := result.Metadata().Lookup(Keys("values").Index(0))
|
|
if !ok || !info.Explicit || info.Origin.Layer != LayerResolution || info.Origin.Operation != ChangeNormalization {
|
|
t.Fatalf("element metadata = %#v, %v", info, ok)
|
|
}
|
|
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
|
|
t.Fatal("removed sequence element remains effective")
|
|
}
|
|
}
|
|
|
|
func TestOrderedResolutionRecordsSortEscapedPointers(t *testing.T) {
|
|
parsedIndex, err := ParsePath("/items/0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
records := map[string]*resolutionRecord{
|
|
"tilde": {path: Keys("a~b")},
|
|
"slash": {path: Keys("a/b")},
|
|
"nested": {path: Keys("a", "b")},
|
|
"index": {path: Keys("items").Index(0)},
|
|
"parsed": {path: parsedIndex},
|
|
"last": {path: Keys("z")},
|
|
}
|
|
ordered := orderedResolutionRecords(records)
|
|
want := []string{"/a/b", "/a~0b", "/a~1b", "/items/0", "/items/0", "/z"}
|
|
if len(ordered) != len(want) {
|
|
t.Fatalf("ordered records = %d, want %d", len(ordered), len(want))
|
|
}
|
|
for i, record := range ordered {
|
|
if got := record.path.String(); got != want[i] {
|
|
t.Fatalf("record[%d] path = %q, want %q", i, got, want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type config struct {
|
|
Value int `yaml:"value"`
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
resolve func(*config, *Resolution) error
|
|
code Code
|
|
}{
|
|
{name: "unrecorded", resolve: func(configuration *config, _ *Resolution) error {
|
|
configuration.Value = 2
|
|
return nil
|
|
}, code: CodeResolveUnrecordedChange},
|
|
{name: "unrelated target", resolve: func(_ *config, resolution *Resolution) error {
|
|
unrelated := 1
|
|
Normalize(resolution, Keys("value"), &unrelated, 2, "changed value")
|
|
return nil
|
|
}, code: CodeResolveInvalidTarget},
|
|
{name: "invalid record kind", resolve: func(_ *config, resolution *Resolution) error {
|
|
resolution.Record(Keys("value"), ChangeKind("other"), "invalid kind")
|
|
return nil
|
|
}, code: CodeResolveInvalidKind},
|
|
{name: "invalid path", resolve: func(_ *config, resolution *Resolution) error {
|
|
resolution.Record(Keys("unknown"), ChangeNormalization, "unknown field")
|
|
return nil
|
|
}, code: CodeResolveInvalidPath},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
result, err := testLoad(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
|
|
assertLoadDiagnostic(t, result, err, test.code)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type config struct {
|
|
Number float64 `yaml:"number"`
|
|
Value int `yaml:"value"`
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
resolve func(*config, *Resolution) error
|
|
code Code
|
|
}{
|
|
{name: "invalid helper value", resolve: func(configuration *config, resolution *Resolution) error {
|
|
Normalize(resolution, Keys("number"), &configuration.Number, math.NaN(), "invalid number")
|
|
return nil
|
|
}, code: CodeResolveInvalidValue},
|
|
{name: "reported error", resolve: func(_ *config, resolution *Resolution) error {
|
|
resolution.Error(Keys("value"), "value cannot be resolved")
|
|
return errors.New("CANARY_RESOLUTION_ERROR")
|
|
}, code: CodeResolveError},
|
|
{name: "returned error", resolve: func(_ *config, _ *Resolution) error {
|
|
return errors.New("CANARY_RESOLUTION_ERROR")
|
|
}, code: CodeResolveHookFailed},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
result, err := testLoad(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
|
|
loadError := assertLoadDiagnostic(t, result, err, test.code)
|
|
for _, diagnostic := range loadError.Diagnostics() {
|
|
if strings.Contains(diagnostic.Message, "CANARY") {
|
|
t.Fatalf("diagnostic exposed returned error: %#v", diagnostic)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type config struct {
|
|
Declared string `yaml:"declared"`
|
|
Default string `yaml:"default"`
|
|
Optional *string `yaml:"optional"`
|
|
}
|
|
filename := writeImportFile(t, t.TempDir(), "config.yml", "declared: file\noptional: null\n")
|
|
var savedField FieldInfo
|
|
var savedSource SourceInfo
|
|
result, err := testLoad[config](File(filename), Options[config]{Definition: Definition[config]{
|
|
Defaults: func() config { return config{Default: "retained"} },
|
|
Resolve: func(_ *config, resolution *Resolution) error {
|
|
for _, path := range []Path{Keys("declared"), Keys("default"), Keys("optional")} {
|
|
info, found := resolution.InputInfo(path)
|
|
if !found || info.Explicit != resolution.IsExplicit(path) {
|
|
return errors.New("entry metadata does not agree with explicitness")
|
|
}
|
|
}
|
|
defaultInfo, _ := resolution.InputInfo(Keys("default"))
|
|
optionalInfo, _ := resolution.InputInfo(Keys("optional"))
|
|
if defaultInfo.Explicit || defaultInfo.Origin.Layer != LayerDefault || !optionalInfo.Explicit || !optionalInfo.Null {
|
|
return errors.New("entry metadata lost default or null state")
|
|
}
|
|
if _, found := resolution.InputInfo(Keys("missing")); found {
|
|
return errors.New("missing entry path was found")
|
|
}
|
|
declared, _ := resolution.InputInfo(Keys("declared"))
|
|
if len(declared.Origin.Locations) != 1 {
|
|
return errors.New("declared source location is missing")
|
|
}
|
|
source, found := resolution.Source(declared.Origin.Locations[0].SourceID)
|
|
if !found || source.Path != canonicalTestPath(t, filename) || source.ID != "source-1" {
|
|
return errors.New("declared source record is missing")
|
|
}
|
|
if _, found := resolution.Source(""); found {
|
|
return errors.New("empty source identity was found")
|
|
}
|
|
if _, found := resolution.Source("unknown"); found {
|
|
return errors.New("unknown source identity was found")
|
|
}
|
|
savedField, savedSource = declared, source
|
|
return nil
|
|
},
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Config.Declared != "file" || result.Config.Default != "retained" || result.Config.Optional != nil {
|
|
t.Fatalf("configuration = %#v", result.Config)
|
|
}
|
|
savedField.Origin.Locations[0].SourceID = "mutated"
|
|
savedSource.Label = "mutated"
|
|
field, _ := result.Metadata().Lookup(Keys("declared"))
|
|
if field.Origin.Locations[0].SourceID != "source-1" || result.Sources()[0].Label == "mutated" {
|
|
t.Fatal("saved resolution records alias a completed result")
|
|
}
|
|
|
|
noSource, err := testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{
|
|
Defaults: func() config { return config{Default: "retained"} },
|
|
Resolve: func(_ *config, resolution *Resolution) error {
|
|
info, found := resolution.InputInfo(Keys("default"))
|
|
if !found || info.Explicit || resolution.IsExplicit(Keys("default")) {
|
|
return errors.New("defaults-only entry metadata is wrong")
|
|
}
|
|
if _, found := resolution.Source("source-1"); found {
|
|
return errors.New("defaults-only load invented a source")
|
|
}
|
|
return nil
|
|
},
|
|
}})
|
|
if err != nil || noSource.Config.Default != "retained" {
|
|
t.Fatalf("defaults-only load = %#v, %v", noSource, err)
|
|
}
|
|
}
|
|
|
|
func TestResolutionContextPanicsAfterCallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type config struct {
|
|
Value int `yaml:"value"`
|
|
}
|
|
var retained *Resolution
|
|
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
|
retained = resolution
|
|
return nil
|
|
}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
if recovered := recover(); recovered != closedCallbackContextMessage {
|
|
t.Fatalf("panic = %#v", recovered)
|
|
}
|
|
}()
|
|
retained.IsExplicit(Keys("value"))
|
|
}
|
|
|
|
func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type config struct {
|
|
Value int `yaml:"value"`
|
|
}
|
|
requireClosed := func(t *testing.T, call func()) {
|
|
t.Helper()
|
|
defer func() {
|
|
if recovered := recover(); recovered != closedCallbackContextMessage {
|
|
t.Fatalf("panic = %#v", recovered)
|
|
}
|
|
}()
|
|
call()
|
|
}
|
|
requireClosed(t, func() { (*Resolution)(nil).InputInfo(Keys("value")) })
|
|
requireClosed(t, func() { (&Resolution{}).Source("source-1") })
|
|
|
|
var successful *Resolution
|
|
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
|
successful = resolution
|
|
return nil
|
|
}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requireClosed(t, func() { successful.InputInfo(Keys("value")) })
|
|
requireClosed(t, func() { successful.Source("source-1") })
|
|
|
|
var failed *Resolution
|
|
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
|
failed = resolution
|
|
return errors.New("resolver failed")
|
|
}}}); err == nil {
|
|
t.Fatal("failed resolver succeeded")
|
|
}
|
|
requireClosed(t, func() { failed.InputInfo(Keys("value")) })
|
|
requireClosed(t, func() { failed.Source("source-1") })
|
|
}
|
|
|
|
func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type config struct {
|
|
Version string `yaml:"version"`
|
|
}
|
|
var retained *Validation
|
|
if _, err := testLoad(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{
|
|
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
|
|
retained = validation
|
|
return nil
|
|
}},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
if recovered := recover(); recovered != closedCallbackContextMessage {
|
|
t.Fatalf("panic = %#v", recovered)
|
|
}
|
|
}()
|
|
retained.Warning(Keys("version"), "too late")
|
|
}
|
|
|
|
func assertLoadDiagnostic[T any](t *testing.T, result *testResult[T], err error, code Code) *LoadError {
|
|
t.Helper()
|
|
if result != nil || err == nil {
|
|
t.Fatalf("result, err = %#v, %v", result, err)
|
|
}
|
|
var loadError *LoadError
|
|
if !errors.As(err, &loadError) {
|
|
t.Fatalf("error type = %T", err)
|
|
}
|
|
diagnostics := loadError.Diagnostics()
|
|
if len(diagnostics) == 0 || diagnostics[0].Code != code {
|
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
|
}
|
|
return loadError
|
|
}
|