diff --git a/go.mod b/go.mod index fa7ace002897..a408887c292f 100644 --- a/go.mod +++ b/go.mod @@ -50,6 +50,7 @@ require ( gopkg.in/square/go-jose.v2 v2.6.0 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 + gotest.tools v2.2.0+incompatible k8s.io/api v0.26.2 k8s.io/apimachinery v0.26.2 k8s.io/apiserver v0.26.2 diff --git a/pkg/api/v1alpha1/cloudstackmachineconfig.go b/pkg/api/v1alpha1/cloudstackmachineconfig.go index 14d5aa2df826..95ff596d1488 100644 --- a/pkg/api/v1alpha1/cloudstackmachineconfig.go +++ b/pkg/api/v1alpha1/cloudstackmachineconfig.go @@ -2,11 +2,12 @@ package v1alpha1 import ( "fmt" - "os" - "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" + + "github.com/aws/eks-anywhere/pkg/utils/file" + yamlutil "github.com/aws/eks-anywhere/pkg/utils/yaml" ) // DefaultCloudStackUser is the default CloudStackMachingConfig username. @@ -66,23 +67,32 @@ func (c *CloudStackMachineConfigGenerate) Name() string { func GetCloudStackMachineConfigs(fileName string) (map[string]*CloudStackMachineConfig, error) { configs := make(map[string]*CloudStackMachineConfig) - content, err := os.ReadFile(fileName) + + r, err := file.ReadFile(fileName) + if err != nil { + return nil, err + } + + resources, err := yamlutil.SplitDocuments(r) if err != nil { - return nil, fmt.Errorf("unable to read file due to: %v", err) + return nil, err } - for _, c := range strings.Split(string(content), YamlSeparator) { + + for _, d := range resources { var config CloudStackMachineConfig - if err = yaml.UnmarshalStrict([]byte(c), &config); err == nil { + if err = yaml.UnmarshalStrict(d, &config); err == nil { if config.Kind == CloudStackMachineConfigKind { configs[config.Name] = &config continue } } - _ = yaml.Unmarshal([]byte(c), &config) // this is to check if there is a bad spec in the file + + _ = yaml.Unmarshal(d, &config) // this is to check if there is a bad spec in the file if config.Kind == CloudStackMachineConfigKind { return nil, fmt.Errorf("unable to unmarshall content from file due to: %v", err) } } + if len(configs) == 0 { return nil, fmt.Errorf("unable to find kind %v in file", CloudStackMachineConfigKind) } diff --git a/pkg/api/v1alpha1/cloudstackmachineconfig_test.go b/pkg/api/v1alpha1/cloudstackmachineconfig_test.go index b127adfd1f9c..dc4bcb1c28f3 100644 --- a/pkg/api/v1alpha1/cloudstackmachineconfig_test.go +++ b/pkg/api/v1alpha1/cloudstackmachineconfig_test.go @@ -1,6 +1,7 @@ package v1alpha1 import ( + "fmt" "reflect" "testing" @@ -58,6 +59,12 @@ func TestGetCloudStackMachineConfigs(t *testing.T) { wantCloudStackMachineConfigs: nil, wantErr: true, }, + { + testName: "non-splitable manifest", + fileName: "testdata/invalid_manifest.yaml", + wantCloudStackMachineConfigs: nil, + wantErr: true, + }, { testName: "valid 1.19", fileName: "testdata/cluster_1_19_cloudstack.yaml", @@ -253,9 +260,13 @@ func TestGetCloudStackMachineConfigs(t *testing.T) { wantErr: true, }, } - for _, tt := range tests { + for i, tt := range tests { + if i != 2 { + continue + } t.Run(tt.testName, func(t *testing.T) { got, err := GetCloudStackMachineConfigs(tt.fileName) + fmt.Println(err) if (err != nil) != tt.wantErr { t.Fatalf("GetCloudStackMachineConfigs() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/pkg/api/v1alpha1/cluster.go b/pkg/api/v1alpha1/cluster.go index ab9df23dff67..a7b05caa375b 100644 --- a/pkg/api/v1alpha1/cluster.go +++ b/pkg/api/v1alpha1/cluster.go @@ -1,9 +1,12 @@ package v1alpha1 import ( + "bufio" + "bytes" "context" "errors" "fmt" + "io" "net" "net/url" "os" @@ -15,6 +18,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/validation" utilerrors "k8s.io/apimachinery/pkg/util/errors" "k8s.io/apimachinery/pkg/util/validation/field" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" "sigs.k8s.io/yaml" "github.com/aws/eks-anywhere/pkg/constants" @@ -264,14 +268,24 @@ type kindObject struct { // ParseClusterConfigFromContent unmarshalls an API object implementing the KindAccessor interface // from a multiobject yaml content. It doesn't set defaults nor validates the object. func ParseClusterConfigFromContent(content []byte, clusterConfig KindAccessor) error { - for _, c := range strings.Split(string(content), YamlSeparator) { + r := yamlutil.NewYAMLReader(bufio.NewReader(bytes.NewReader(content))) + for { + d, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + return err + } + k := &kindObject{} - if err := yaml.Unmarshal([]byte(c), k); err != nil { + + if err := yaml.Unmarshal(d, k); err != nil { return err } if k.Kind == clusterConfig.ExpectedKind() { - return yaml.UnmarshalStrict([]byte(c), clusterConfig) + return yaml.UnmarshalStrict(d, clusterConfig) } } diff --git a/pkg/api/v1alpha1/cluster_test.go b/pkg/api/v1alpha1/cluster_test.go index 987905bac3de..1e2018de84a3 100644 --- a/pkg/api/v1alpha1/cluster_test.go +++ b/pkg/api/v1alpha1/cluster_test.go @@ -3,11 +3,15 @@ package v1alpha1 import ( "errors" "fmt" + //nolint: staticcheck + "io/ioutil" "reflect" "strings" "testing" . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -1402,6 +1406,43 @@ func TestParseClusterConfig(t *testing.T) { } } +func Test_ParseClusterConfigFromContent(t *testing.T) { + tests := []struct { + name string + fileName string + clusterConfig KindAccessor + expectedError error + }{ + { + name: "Good cluster config parse", + fileName: "testdata/cluster_vsphere.yaml", + clusterConfig: &Cluster{}, + expectedError: nil, + }, + { + name: "non-splitable manifest", + fileName: "testdata/invalid_manifest.yaml", + clusterConfig: &Cluster{}, + expectedError: errors.New("invalid Yaml document separator: \\nkey: value\\ninvalid_separator\\n"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + content, err := ioutil.ReadFile(test.fileName) + require.NoError(t, err) + + err = ParseClusterConfigFromContent(content, test.clusterConfig) + + if test.expectedError != nil { + assert.Equal(t, test.expectedError.Error(), err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + func TestCluster_PauseReconcile(t *testing.T) { tests := []struct { name string diff --git a/pkg/api/v1alpha1/nutanixmachineconfig.go b/pkg/api/v1alpha1/nutanixmachineconfig.go index d07e426bbe17..dce3f4f7ccce 100644 --- a/pkg/api/v1alpha1/nutanixmachineconfig.go +++ b/pkg/api/v1alpha1/nutanixmachineconfig.go @@ -2,12 +2,13 @@ package v1alpha1 import ( "fmt" - "os" - "strings" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" + + "github.com/aws/eks-anywhere/pkg/utils/file" + yamlutil "github.com/aws/eks-anywhere/pkg/utils/yaml" ) // NutanixIdentifierType is an enumeration of different resource identifier types. @@ -122,28 +123,39 @@ func (c *NutanixMachineConfigGenerate) Name() string { func GetNutanixMachineConfigs(fileName string) (map[string]*NutanixMachineConfig, error) { configs := make(map[string]*NutanixMachineConfig) - content, err := os.ReadFile(fileName) + + r, err := file.ReadFile(fileName) + if err != nil { + return nil, err + } + + resources, err := yamlutil.SplitDocuments(r) if err != nil { - return nil, fmt.Errorf("unable to read file due to: %v", err) + return nil, err } - for _, c := range strings.Split(string(content), YamlSeparator) { + + for _, d := range resources { config := NutanixMachineConfig{ TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{}, }, } - if err = yaml.UnmarshalStrict([]byte(c), &config); err == nil { + + if err = yaml.UnmarshalStrict(d, &config); err == nil { if config.Kind == NutanixMachineConfigKind { configs[config.Name] = &config continue } } - _ = yaml.Unmarshal([]byte(c), &config) // this is to check if there is a bad spec in the file + + _ = yaml.Unmarshal(d, &config) // this is to check if there is a bad spec in the file if config.Kind == NutanixMachineConfigKind { return nil, fmt.Errorf("unable to unmarshall content from file due to: %v", err) } + } + if len(configs) == 0 { return nil, fmt.Errorf("unable to find kind %v in file", NutanixMachineConfigKind) } diff --git a/pkg/api/v1alpha1/nutanixmachineconfig_test.go b/pkg/api/v1alpha1/nutanixmachineconfig_test.go index 323466286647..bfaf14f1b403 100644 --- a/pkg/api/v1alpha1/nutanixmachineconfig_test.go +++ b/pkg/api/v1alpha1/nutanixmachineconfig_test.go @@ -153,15 +153,31 @@ func TestGetNutanixMachineConfigsValidConfig(t *testing.T) { assert.NotNil(t, y) }, }, + { + name: "invalid-manifest", + fileName: "testdata/invalid_manifest.yaml", + machineConf: nil, + assertions: func(t *testing.T, machineConf *NutanixMachineConfig) { + m := machineConf.Marshallable() + require.NotNil(t, m) + y, err := yaml.Marshal(m) + assert.NoError(t, err) + assert.NotNil(t, y) + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { conf, err := GetNutanixMachineConfigs(test.fileName) - assert.NoError(t, err) - require.NotNil(t, conf) - assert.True(t, reflect.DeepEqual(test.machineConf, conf)) - test.assertions(t, conf[machineConfName]) + if test.machineConf != nil { + require.NoError(t, err) + require.NotNil(t, conf) + assert.True(t, reflect.DeepEqual(test.machineConf, conf)) + test.assertions(t, conf[machineConfName]) + } else { + assert.Error(t, err) + } }) } } diff --git a/pkg/api/v1alpha1/testdata/invalid_manifest.yaml b/pkg/api/v1alpha1/testdata/invalid_manifest.yaml new file mode 100644 index 000000000000..297aaa7042e2 --- /dev/null +++ b/pkg/api/v1alpha1/testdata/invalid_manifest.yaml @@ -0,0 +1 @@ +---\nkey: value\ninvalid_separator\n diff --git a/pkg/api/v1alpha1/testdata/tinkerbell_cluster_with_duplicate_mchine_config_fields.yaml b/pkg/api/v1alpha1/testdata/tinkerbell_cluster_with_duplicate_mchine_config_fields.yaml new file mode 100644 index 000000000000..357d6dc4e596 --- /dev/null +++ b/pkg/api/v1alpha1/testdata/tinkerbell_cluster_with_duplicate_mchine_config_fields.yaml @@ -0,0 +1,45 @@ +apiVersion: anywhere.eks.amazonaws.com/v1alpha1 +kind: Cluster +metadata: + name: single-node +spec: + clusterNetwork: + cniConfig: + cilium: {} + pods: + cidrBlocks: + - 192.168.0.0/16 + services: + cidrBlocks: + - 10.96.0.0/12 + controlPlaneConfiguration: + count: 1 + endpoint: + host: "10.80.8.90" + machineGroupRef: + kind: TinkerbellMachineConfig + name: single-node-cp + datacenterRef: + kind: TinkerbellDatacenterConfig + name: single-node + kubernetesVersion: "1.23" + managementCluster: + name: single-node +--- +apiVersion: anywhere.eks.amazonaws.com/v1alpha1 +kind: TinkerbellDatacenterConfig +metadata: + name: single-node +spec: + tinkerbellIP: "10.80.8.91" +--- +apiVersion: anywhere.eks.amazonaws.com/v1alpha1 +kind: TinkerbellMachineConfig +metadata: + name: single-node-cp + name: another-cp +spec: + hardwareSelector: + type: cp + osFamily: bottlerocket + templateRef: {} \ No newline at end of file diff --git a/pkg/api/v1alpha1/tinkerbellmachineconfig.go b/pkg/api/v1alpha1/tinkerbellmachineconfig.go index 31969631d144..a1da47f0f312 100644 --- a/pkg/api/v1alpha1/tinkerbellmachineconfig.go +++ b/pkg/api/v1alpha1/tinkerbellmachineconfig.go @@ -2,11 +2,13 @@ package v1alpha1 import ( "fmt" - "os" - "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apimachineryyaml "k8s.io/apimachinery/pkg/util/yaml" "sigs.k8s.io/yaml" + + "github.com/aws/eks-anywhere/pkg/utils/file" + yamlutil "github.com/aws/eks-anywhere/pkg/utils/yaml" ) const TinkerbellMachineConfigKind = "TinkerbellMachineConfig" @@ -57,23 +59,33 @@ func (c *TinkerbellMachineConfigGenerate) Name() string { func GetTinkerbellMachineConfigs(fileName string) (map[string]*TinkerbellMachineConfig, error) { configs := make(map[string]*TinkerbellMachineConfig) - content, err := os.ReadFile(fileName) + + r, err := file.ReadFile(fileName) + if err != nil { + return nil, err + } + + resources, err := yamlutil.SplitDocuments(r) if err != nil { - return nil, fmt.Errorf("unable to read file due to: %v", err) + return nil, err } - for _, c := range strings.Split(string(content), YamlSeparator) { + + for _, d := range resources { var config TinkerbellMachineConfig - if err = yaml.UnmarshalStrict([]byte(c), &config); err == nil { + var strictUnmarshallErr error + if strictUnmarshallErr = apimachineryyaml.UnmarshalStrict(d, &config); strictUnmarshallErr == nil { if config.Kind == TinkerbellMachineConfigKind { configs[config.Name] = &config continue } } - _ = yaml.Unmarshal([]byte(c), &config) // this is to check if there is a bad spec in the file - if config.Kind == TinkerbellMachineConfigKind { - return nil, fmt.Errorf("unable to unmarshall content from file due to: %v", err) + + _ = yaml.Unmarshal(d, &config) + if config.Kind == TinkerbellMachineConfigKind && strictUnmarshallErr != nil { + return nil, fmt.Errorf("unable to unmarshall content from file due to: %v", strictUnmarshallErr) } } + if len(configs) == 0 { return nil, fmt.Errorf("unable to find kind %v in file", TinkerbellMachineConfigKind) } diff --git a/pkg/api/v1alpha1/tinkerbellmachineconfig_test.go b/pkg/api/v1alpha1/tinkerbellmachineconfig_test.go new file mode 100644 index 000000000000..a6fb0267af48 --- /dev/null +++ b/pkg/api/v1alpha1/tinkerbellmachineconfig_test.go @@ -0,0 +1,66 @@ +package v1alpha1_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aws/eks-anywhere/pkg/api/v1alpha1" +) + +// MockReaderWithError is a mock implementation of the Reader interface that returns an error. +type MockReaderWithError struct{} + +// Read returns an error. +func (m *MockReaderWithError) Read() ([]byte, error) { + return nil, errors.New("custom error") +} + +func TestGetTinkerbellMachineConfigs(t *testing.T) { + tests := []struct { + name string + fileName string + expectedError error + }{ + { + name: "non-splitable manifest", + fileName: "testdata/invalid_manifest.yaml", + expectedError: errors.New("invalid Yaml document separator: \\nkey: value\\ninvalid_separator\\n"), + }, + { + name: "non existent file", + fileName: "testdata/non_existent_file.yaml", + expectedError: errors.New("unable to read file due to: open testdata/non_existent_file.yaml: no such file or directory"), + }, + { + name: "non existent file", + fileName: "testdata/cluster_vsphere.yaml", + expectedError: errors.New("unable to find kind TinkerbellMachineConfig in file"), + }, + { + name: "duplicate fields in machine config", + fileName: "testdata/tinkerbell_cluster_with_duplicate_mchine_config_fields.yaml", + expectedError: errors.New("unable to unmarshall content from file due to: error converting YAML to JSON: yaml: unmarshal errors:\n line 5: key \"name\" already set in map"), + }, + { + name: "valid tinkerbell manifest", + fileName: "testdata/tinkerbell_cluster_without_worker_nodes.yaml", + expectedError: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + configs, err := v1alpha1.GetTinkerbellMachineConfigs(test.fileName) + + if test.expectedError != nil { + assert.Equal(t, test.expectedError.Error(), err.Error()) + } else { + require.NoError(t, err) + assert.True(t, len(configs) > 0) + } + }) + } +} diff --git a/pkg/api/v1alpha1/vspheremachineconfig.go b/pkg/api/v1alpha1/vspheremachineconfig.go index e2e2ab077539..d9d05d0fb5b1 100644 --- a/pkg/api/v1alpha1/vspheremachineconfig.go +++ b/pkg/api/v1alpha1/vspheremachineconfig.go @@ -2,13 +2,13 @@ package v1alpha1 import ( "fmt" - "os" - "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" "github.com/aws/eks-anywhere/pkg/logger" + "github.com/aws/eks-anywhere/pkg/utils/file" + yamlutil "github.com/aws/eks-anywhere/pkg/utils/yaml" ) const ( @@ -56,23 +56,33 @@ func (c *VSphereMachineConfigGenerate) Name() string { func GetVSphereMachineConfigs(fileName string) (map[string]*VSphereMachineConfig, error) { configs := make(map[string]*VSphereMachineConfig) - content, err := os.ReadFile(fileName) + + r, err := file.ReadFile(fileName) + if err != nil { + return nil, err + } + + resources, err := yamlutil.SplitDocuments(r) if err != nil { - return nil, fmt.Errorf("unable to read file due to: %v", err) + return nil, err } - for _, c := range strings.Split(string(content), YamlSeparator) { + + for _, d := range resources { var config VSphereMachineConfig - if err = yaml.UnmarshalStrict([]byte(c), &config); err == nil { + + if err = yaml.UnmarshalStrict(d, &config); err == nil { if config.Kind == VSphereMachineConfigKind { configs[config.Name] = &config continue } } - _ = yaml.Unmarshal([]byte(c), &config) // this is to check if there is a bad spec in the file + + _ = yaml.Unmarshal(d, &config) // this is to check if there is a bad spec in the file if config.Kind == VSphereMachineConfigKind { return nil, fmt.Errorf("unable to unmarshall content from file due to: %v", err) } } + if len(configs) == 0 { return nil, fmt.Errorf("unable to find kind %v in file", VSphereMachineConfigKind) } diff --git a/pkg/api/v1alpha1/vspheremachineconfig_test.go b/pkg/api/v1alpha1/vspheremachineconfig_test.go index f7ca20227060..72b847f17691 100644 --- a/pkg/api/v1alpha1/vspheremachineconfig_test.go +++ b/pkg/api/v1alpha1/vspheremachineconfig_test.go @@ -21,6 +21,12 @@ func TestGetVSphereMachineConfigs(t *testing.T) { wantVSphereMachineConfigs: nil, wantErr: true, }, + { + testName: "non-splitable manifest", + fileName: "testdata/invalid_manifest.yaml", + wantVSphereMachineConfigs: nil, + wantErr: true, + }, { testName: "not parseable file", fileName: "testdata/not_parseable_cluster.yaml", diff --git a/pkg/utils/file/reader.go b/pkg/utils/file/reader.go new file mode 100644 index 000000000000..a0d0a91de3bf --- /dev/null +++ b/pkg/utils/file/reader.go @@ -0,0 +1,18 @@ +package file + +import ( + "bytes" + "fmt" + "io" + "os" +) + +// ReadFile function reads the contents of a file and provides them as an `io.Reader`. +func ReadFile(fileName string) (io.Reader, error) { + content, err := os.ReadFile(fileName) + if err != nil { + return nil, fmt.Errorf("unable to read file due to: %v", err) + } + + return bytes.NewReader(content), nil +} diff --git a/pkg/utils/yaml/yaml.go b/pkg/utils/yaml/yaml.go index c7fc9ef76580..e15d35473b77 100644 --- a/pkg/utils/yaml/yaml.go +++ b/pkg/utils/yaml/yaml.go @@ -1,9 +1,12 @@ package yaml import ( + "bufio" "bytes" "fmt" + "io" + apiyaml "k8s.io/apimachinery/pkg/util/yaml" "sigs.k8s.io/yaml" ) @@ -25,3 +28,23 @@ func Serialize[T any](objs ...T) ([][]byte, error) { } return r, nil } + +// SplitDocuments function splits content into individual document parts represented as byte slices. +func SplitDocuments(r io.Reader) ([][]byte, error) { + resources := make([][]byte, 0) + + yr := apiyaml.NewYAMLReader(bufio.NewReader(r)) + for { + d, err := yr.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + resources = append(resources, d) + } + + return resources, nil +} diff --git a/pkg/utils/yaml/yaml_test.go b/pkg/utils/yaml/yaml_test.go new file mode 100644 index 000000000000..0c1560337c18 --- /dev/null +++ b/pkg/utils/yaml/yaml_test.go @@ -0,0 +1,100 @@ +package yaml_test + +import ( + "bufio" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + yamlutil "github.com/aws/eks-anywhere/pkg/utils/yaml" +) + +func TestSplitDocuments(t *testing.T) { + tests := []struct { + name string + input string + expectedDocs [][]byte + expectedErr error + }{ + { + name: "Empty input", + input: "", + expectedDocs: [][]byte{}, + expectedErr: nil, + }, + { + name: "Single document", + input: `apiVersion: v1 +kind: Pod +metadata: + name: pod-1 +`, + expectedDocs: [][]byte{ + []byte(`apiVersion: v1 +kind: Pod +metadata: + name: pod-1 +`), + }, + expectedErr: nil, + }, + { + name: "Multiple documents", + input: `apiVersion: v1 +kind: Pod +metadata: + name: pod-1 +--- +apiVersion: v1 +kind: Service +metadata: + name: service-1 +`, + expectedDocs: [][]byte{ + []byte(`apiVersion: v1 +kind: Pod +metadata: + name: pod-1 +`), + []byte(`apiVersion: v1 +kind: Service +metadata: + name: service-1 +`), + }, + expectedErr: nil, + }, + { + name: "Error reading input 2", + input: `---\nkey: value\ninvalid_separator\n`, + expectedDocs: nil, + expectedErr: errors.New("invalid Yaml document separator: \\nkey: value\\ninvalid_separator\\n"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := strings.NewReader(test.input) + + docs, err := yamlutil.SplitDocuments(bufio.NewReader(r)) + if test.expectedErr != nil { + assert.Equal(t, test.expectedErr.Error(), err.Error()) + assert.Equal(t, len(test.expectedDocs), len(docs)) + } else { + require.NoError(t, err) + if len(docs) != len(test.expectedDocs) { + t.Errorf("Expected %d documents, but got %d", len(test.expectedDocs), len(docs)) + } + + for i, doc := range docs { + if string(doc) != string(test.expectedDocs[i]) { + t.Errorf("Document %d mismatch.\nExpected:\n%s\nGot:\n%s", i+1, string(test.expectedDocs[i]), string(doc)) + } + } + } + }) + } +}