1<?php
2
3declare(strict_types = 1);
4
5namespace Elasticsearch\Tests;
6
7use Doctrine\Common\Inflector\Inflector;
8use Elasticsearch;
9use Elasticsearch\Common\Exceptions\BadRequest400Exception;
10use Elasticsearch\Common\Exceptions\Conflict409Exception;
11use Elasticsearch\Common\Exceptions\Forbidden403Exception;
12use Elasticsearch\Common\Exceptions\Missing404Exception;
13use Elasticsearch\Common\Exceptions\RequestTimeout408Exception;
14use Elasticsearch\Common\Exceptions\ServerErrorResponseException;
15use Elasticsearch\Common\Exceptions\RoutingMissingException;
16use Elasticsearch\Common\Exceptions\Unauthorized401Exception;
17use GuzzleHttp\Ring\Future\FutureArrayInterface;
18use RecursiveDirectoryIterator;
19use RecursiveIteratorIterator;
20use Symfony\Component\Finder\Finder;
21use Symfony\Component\Finder\SplFileInfo;
22use Symfony\Component\Yaml\Exception\ParseException;
23use Symfony\Component\Yaml\Yaml;
24
25/**
26 * Class YamlRunnerTest
27 *
28 * @category   Tests
29 * @package    Elasticsearch
30 * @subpackage Tests
31 * @author     Zachary Tong <zachary.tong@elasticsearch.com>
32 * @license    http://www.apache.org/licenses/LICENSE-2.0 Apache2
33 * @link       http://elasticsearch.org
34 */
35class YamlRunnerTest extends \PHPUnit\Framework\TestCase
36{
37    /**
38     * @var \Symfony\Component\Yaml\Yaml Yaml parser for reading integrations tests
39     */
40    private $yaml;
41
42    /**
43     * @var \Elasticsearch\Client client used by elasticsearch
44     */
45    private $client;
46
47    /**
48     * @var string Es version
49     */
50    private static $esVersion;
51
52    /**
53     * @var string[] A list of supported features
54     */
55    private static $supportedFeatures = [
56        'stash_in_path', 'warnings', 'headers'
57    ];
58
59    /**
60     * @var array A mapping for endpoint when there is a reserved keywords for the method / namespace name
61     */
62    private static $endpointMapping = [
63        'tasks' => [
64            'list' => ['tasksList', 'tasks'],
65        ],
66    ];
67
68    private static $skippedTests = [
69        'nodes.stats/30_discovery.yml#Discovery stats' => 'Failing on ES 6.1+: nodes.$master.discovery is an empty array, expected to have cluster_state_queue field in it',
70        'indices.stats/20_translog.yml#Translog retention' => 'Failing on ES 6.3+: Failed asserting that 495 is equal to <string:$creation_size> or is less than \'$creation_size\'',
71        'indices.shrink/30_copy_settings.yml#Copy settings during shrink index' => 'Failing on ES 6.4+: Failed to match in test "Copy settings during shrink index". Expected [\'4\'] does not match [false] ',
72    ];
73
74    private static $skippedTestsIfPhpLessThan = [
75        // Failing on ES 6.7+ only with PHP 7.0: Cannot access empty property
76        'indices.put_mapping/11_basic_with_types.yml#Create index with invalid mappings' => '7.1.0',
77        'indices.put_mapping/10_basic.yml#Create index with invalid mappings' => '7.1.0',
78        'indices.create/11_basic_with_types.yml#Create index with invalid mappings' => '7.1.0',
79        'indices.create/11_basic_with_types.yml#Create index with no type mappings' => '7.1.0',
80        'indices.create/10_basic.yml#Create index with invalid mappings' => '7.1.0',
81    ];
82    /**
83     * @var array A list of skipped test with their reasons
84     */
85    private static $skippedFiles = [
86
87        'cat.nodeattrs/10_basic.yml' => 'Using java regex fails in PHP',
88        'cat.nodeattrs/10_basic.yaml' => 'Using java regex fails in PHP',
89
90        'cat.repositories/10_basic.yml' => 'Using java regex fails in PHP',
91        'cat.repositories/10_basic.yaml' => 'Using java regex fails in PHP',
92
93        'indices.shrink/10_basic.yml' => 'Shrink tests seem to require multiple nodes',
94        'indices.shrink/10_basic.yaml' => 'Shrink tests seem to require multiple nodes',
95
96        'indices.rollover/10_basic.yml' => 'Rollover test seems buggy atm',
97        'indices.rollover/10_basic.yaml' => 'Rollover test seems buggy atm',
98
99        'get_source/70_source_filtering.yml' => 'Expected [\'v1\'] does not match [false]',
100        'get_source/71_source_filtering_with_types.yml' => 'Expected [\'v1\'] does not match [false]',
101    ];
102
103    /**
104     * @var array A list of files to skip completely, due to fatal parsing errors
105     */
106    private static $fatalFiles = [
107        'indices.create/10_basic.yml' => 'Temporary: Yaml parser doesnt support "inline" empty keys',
108        'indices.create/10_basic.yaml' => 'Temporary: Yaml parser doesnt support "inline" empty keys',
109
110        'indices.put_mapping/10_basic.yml' => 'Temporary: Yaml parser doesnt support "inline" empty keys',
111        'indices.put_mapping/10_basic.yaml' => 'Temporary: Yaml parser doesnt support "inline" empty keys',
112
113        'search/110_field_collapsing.yml' => 'Temporary: parse error, malformed inline yaml',
114        'search/110_field_collapsing.yaml' => 'Temporary: parse error, malformed inline yaml',
115        'range/10_basic.yml' => 'Temporary: parse error, malformed inline yaml',
116
117        'cat.nodes/10_basic.yml' => 'Temporary: parse error, something about $body: |',
118        'cat.nodes/10_basic.yaml' => 'Temporary: parse error, something about $body: |',
119        'search.aggregation/180_percentiles_tdigest_metric.yml' => 'array of objects, unclear how to fix',
120        'search.aggregation/190_percentiles_hdr_metric.yml' => 'array of objects, unclear how to fix',
121        'search/190_index_prefix_search.yml' => 'bad yaml array syntax',
122        'search.aggregation/230_composite.yml' => 'bad yaml array syntax',
123        'search/30_limits.yml' => 'bad regex'
124    ];
125
126    /**
127     * Return the elasticsearch host
128     *
129     * @return string
130     */
131    public static function getHost(): string
132    {
133        if (getenv('ES_TEST_HOST') !== false) {
134            return getenv('ES_TEST_HOST');
135        }
136
137        echo 'Environment variable for elasticsearch test cluster (ES_TEST_HOST) not defined. Exiting yaml test';
138        exit;
139    }
140
141    public static function setUpBeforeClass()
142    {
143        $host = static::getHost();
144        echo "Test Host: $host\n";
145
146        $ch = curl_init($host);
147        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
148        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "GET");
149        curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
150
151        $response = curl_exec($ch);
152        curl_close($ch);
153        if (false === $response) {
154            throw new \Exception('I cannot connect to ES');
155        }
156        $response = json_decode($response, true);
157        static::$esVersion = $response['version']['number'];
158        echo "ES Version: ".static::$esVersion."\n";
159    }
160
161    public function setUp()
162    {
163        $this->client = Elasticsearch\ClientBuilder::create()
164            ->setHosts([self::getHost()])
165            ->build();
166    }
167
168    public function tearDown()
169    {
170        $this->clean();
171    }
172
173    /**
174     * @dataProvider yamlProvider
175     * @group        sync
176     */
177    public function testIntegration($testProcedure, bool $skip, $setupProcedure, $teardownProcedure, string $fileName)
178    {
179        if ($skip) {
180            static::markTestIncomplete($testProcedure);
181        }
182
183        if (array_key_exists($fileName, static::$skippedFiles)) {
184            static::markTestSkipped(static::$skippedFiles[$fileName]);
185        }
186
187        if (null !== $setupProcedure) {
188            $this->processProcedure(current($setupProcedure), 'setup', $fileName);
189            $this->waitForYellow();
190        }
191
192        try {
193            $this->processProcedure(current($testProcedure), key($testProcedure), $fileName);
194        } finally {
195            if (null !== $teardownProcedure) {
196                $this->processProcedure(current($teardownProcedure), 'teardown', $fileName);
197                $this->waitForYellow();
198            }
199        }
200    }
201
202    /**
203     * @dataProvider yamlProvider
204     * @group        async
205     */
206    public function testAsyncIntegration($testProcedure, bool $skip, $setupProcedure, $teardownProcedure, string $fileName)
207    {
208        if ($skip) {
209            static::markTestIncomplete($testProcedure);
210        }
211
212        if (array_key_exists($fileName, static::$skippedFiles)) {
213            static::markTestSkipped(static::$skippedFiles[$fileName]);
214        }
215
216        if (null !== $setupProcedure) {
217            $this->processProcedure(current($setupProcedure), 'setup', $fileName);
218            $this->waitForYellow();
219        }
220
221        try {
222            $this->processProcedure(current($testProcedure), key($testProcedure), $fileName, true);
223        } finally {
224            if (null !== $teardownProcedure) {
225                $this->processProcedure(current($teardownProcedure), 'teardown', $fileName);
226                $this->waitForYellow();
227            }
228        }
229    }
230
231    /**
232     * Process a procedure
233     *
234     * @param array  $procedure
235     * @param string $name
236     * @param string $fileName
237     * @param bool   $async
238     */
239    public function processProcedure(array $procedure, string $name, string $fileName, bool $async = false)
240    {
241        $lastOperationResult = null;
242        $context = [];
243
244        if (array_key_exists("$fileName#$name", static::$skippedTests)) {
245            static::markTestSkipped(static::$skippedTests["$fileName#$name"]);
246        }
247
248        foreach ($procedure as $operation) {
249            $lastOperationResult = $this->processOperation($operation, $lastOperationResult, $context, $name, $async);
250        }
251    }
252
253    /**
254     * Process an operation
255     *
256     * @param object            $operation
257     * @param array|string|null $lastOperationResult
258     * @param array             $context
259     * @param string            $testName
260     * @param bool              $async
261     *
262     * @return mixed
263     */
264    public function processOperation($operation, $lastOperationResult, array &$context, string $testName, bool $async = false)
265    {
266        $operationName = array_keys((array)$operation)[0];
267
268        if ('do' === $operationName) {
269            return $this->operationDo($operation->{$operationName}, $lastOperationResult, $context, $testName, $async);
270        }
271
272        if ('is_false' === $operationName) {
273            return $this->operationIsFalse($operation->{$operationName}, $lastOperationResult, $context, $testName);
274        }
275
276        if ('is_true' === $operationName) {
277            return $this->operationIsTrue($operation->{$operationName}, $lastOperationResult, $context, $testName);
278        }
279
280        if ('match' === $operationName) {
281            return $this->operationMatch($operation->{$operationName}, $lastOperationResult, $context, $testName);
282        }
283
284        if ('gte' === $operationName) {
285            return $this->operationGreaterThanOrEqual($operation->{$operationName}, $lastOperationResult, $context, $testName);
286        }
287
288        if ('gt' === $operationName) {
289            return $this->operationGreaterThan($operation->{$operationName}, $lastOperationResult, $context, $testName);
290        }
291
292        if ('lte' === $operationName) {
293            return $this->operationLessThanOrEqual($operation->{$operationName}, $lastOperationResult, $context, $testName);
294        }
295
296        if ('t' === $operationName) {
297            return $this->operationLessThan($operation->{$operationName}, $lastOperationResult, $context, $testName);
298        }
299
300        if ('length' === $operationName) {
301            return $this->operationLength($operation->{$operationName}, $lastOperationResult, $context, $testName);
302        }
303
304        if ('set' === $operationName) {
305            return $this->operationSet($operation->{$operationName}, $lastOperationResult, $context, $testName);
306        }
307
308        if ('skip' === $operationName) {
309            return $this->operationSkip($operation->{$operationName}, $lastOperationResult, $testName);
310        }
311
312        self::markTestIncomplete(sprintf('Operation %s not supported for test "%s"', $operationName, $testName));
313    }
314
315    /**
316     * Do something on the client
317     *
318     * @param object            $operation
319     * @param array|string|null $lastOperationResult
320     * @param array             $context
321     * @param string            $testName
322     * @param bool              $async
323     *
324     * @throws \Exception
325     *
326     * @return mixed
327     */
328    public function operationDo($operation, $lastOperationResult, &$context, string $testName, bool $async = false)
329    {
330        $expectedError = null;
331        $expectedWarnings = null;
332        $headers = null;
333
334        // Check if a error must be caught
335        if ('catch' === key($operation)) {
336            $expectedError = current($operation);
337            next($operation);
338        }
339
340        // Check if a warning must be caught
341        if ('warnings' === key($operation)) {
342            $expectedWarnings = current($operation);
343            next($operation);
344        }
345
346        // Any specific headers to add?
347        if ('headers' === key($operation)) {
348            $headers = $this->formatHeaders(current($operation));
349            next($operation);
350        }
351
352        $endpointInfo = explode('.', key($operation));
353
354        /**
355 * @var \stdClass $endpointParams
356*/
357        $endpointParams = $this->replaceWithContext(current($operation), $context);
358
359        $caller = $this->client;
360        $namespace = null;
361        $method = null;
362
363        if (count($endpointInfo) === 1) {
364            $method = Inflector::camelize($endpointInfo[0]);
365        }
366
367        if (count($endpointInfo) === 2) {
368            $namespace = $endpointInfo[0];
369            $method = Inflector::camelize($endpointInfo[1]);
370        }
371
372        if (is_object($endpointParams) === true && property_exists($endpointParams, 'ignore')) {
373            $ignore = $endpointParams->ignore;
374            unset($endpointParams->ignore);
375
376            $endpointParams->client['ignore'] = $ignore;
377        }
378
379        if ($async) {
380            $endpointParams->client['future'] = true;
381        }
382
383        if ($headers != null) {
384            $endpointParams->client['headers'] = $headers;
385        }
386
387        if (!is_string($method)) {
388            throw new \Exception('$method must be string');
389        }
390        list($method, $namespace) = $this->mapEndpoint($method, $namespace);
391
392        if (null !== $namespace) {
393            $caller = $caller->$namespace();
394        }
395
396        if (null === $method) {
397            self::markTestIncomplete(sprintf('Invalid do operation for test "%s"', $testName));
398        }
399
400        if (!method_exists($caller, $method)) {
401            self::markTestIncomplete(sprintf('Method "%s" not implement in "%s"', $method, get_class($caller)));
402        }
403
404        // TODO remove this after cat testing situation resolved
405        if ($caller instanceof Elasticsearch\Namespaces\CatNamespace) {
406            if (!isset($endpointParams->format)) {
407                $endpointParams->format = 'text';
408            }
409        }
410
411        // Exist* methods have to be manually 'unwrapped' into true/false for async
412        if (strpos($method, "exist") !== false && $async === true) {
413            return $this->executeAsyncExistRequest($caller, $method, $endpointParams, $expectedError, $expectedWarnings, $testName);
414        }
415
416        return $this->executeRequest($caller, $method, (array) $endpointParams, $expectedError, $expectedWarnings, $testName);
417    }
418
419    /**
420     * Obtain the response from the server
421     *
422     * @param object      $caller
423     * @param string      $method
424     * @param array       $endpointParams
425     * @param string|null $expectedError
426     * @param null        $expectedWarnings
427     * @param string      $testName
428     *
429     * @throws \Exception
430     *
431     * @return array|mixed
432     */
433    public function executeRequest($caller, string $method, array $endpointParams, $expectedError, $expectedWarnings, string $testName)
434    {
435        try {
436            $response = $caller->$method($endpointParams);
437
438            while ($response instanceof FutureArrayInterface) {
439                $response = $response->wait();
440            }
441
442            $this->checkForWarnings($expectedWarnings);
443
444            return $response;
445        } catch (\Exception $exception) {
446            if (null !== $expectedError) {
447                return $this->assertException($exception, $expectedError, $testName);
448            }
449
450            $msg = $exception->getMessage()
451                . "\nException in " . get_class($caller) . " with [$method].\n Context:\n"
452                . var_export($endpointParams, true)
453                . "\nTest name: $testName\n";
454            throw new \Exception($msg, 0, $exception);
455        }
456    }
457
458    /**
459     * Obtain the response when it is an Exists* method.  These are converted into
460     * true/false responses
461     *
462     * @param object      $caller
463     * @param string      $method
464     * @param object      $endpointParams
465     * @param string|null $expectedError
466     * @param null        $expectedWarnings
467     * @param string      $testName
468     *
469     * @throws \Exception
470     *
471     * @return bool|mixed[]|null
472     */
473    public function executeAsyncExistRequest($caller, $method, $endpointParams, $expectedError, $expectedWarnings, $testName)
474    {
475        try {
476            $response = $caller->$method($endpointParams);
477
478            while ($response instanceof FutureArrayInterface) {
479                $response = $response->wait();
480            }
481
482            $this->checkForWarnings($expectedWarnings);
483
484            if ($response['status'] === 200) {
485                return true;
486            } else {
487                return false;
488            }
489        } catch (Missing404Exception $exception) {
490            return false;
491        } catch (RoutingMissingException $exception) {
492            return false;
493        } catch (\Exception $exception) {
494            if (null !== $expectedError) {
495                return $this->assertException($exception, $expectedError, $testName);
496            }
497
498            throw $exception;
499        }
500    }
501
502    public function checkForWarnings($expectedWarnings)
503    {
504        $last = $this->client->transport->getLastConnection()->getLastRequestInfo();
505
506
507        // We have some warnings to check
508        if ($expectedWarnings !== null) {
509            if (isset($last['response']['headers']['Warning']) === true) {
510                foreach ($last['response']['headers']['Warning'] as $warning) {
511                    //$position = array_search($warning, $expectedWarnings);
512                    $position = false;
513                    foreach ($expectedWarnings as $index => $value) {
514                        if (stristr($warning, $value) !== false) {
515                            $position = $index;
516                            break;
517                        }
518                    }
519                    if ($position !== false) {
520                        // found the warning
521                        unset($expectedWarnings[$position]);
522                    } else {
523                        // didn't find, throw error
524                        //throw new \Exception("Expected to find warning [$warning] but did not.");
525                    }
526                }
527                if (count($expectedWarnings) > 0) {
528                    print_r($last['response']);
529                    throw new \Exception("Expected to find more warnings: ". print_r($expectedWarnings, true));
530                }
531            }
532        }
533
534        // Check to make sure we're adding headers
535        $this->assertArrayHasKey('Content-Type', $last['request']['headers'], print_r($last['request']['headers'], true));
536        $this->assertSame('application/json', $last['request']['headers']['Content-Type'][0], print_r($last['request']['headers'], true));
537        $this->assertArrayHasKey('Accept', $last['request']['headers'], print_r($last['request']['headers'], true));
538        $this->assertSame('application/json', $last['request']['headers']['Accept'][0], print_r($last['request']['headers'], true));
539    }
540
541    /**
542     * Check if a field in the last operation is false
543     *
544     * @param string            $operation
545     * @param array|string|null $lastOperationResult
546     * @param array             $context
547     * @param string            $testName
548     */
549    public function operationIsFalse(string $operation, $lastOperationResult, &$context, string $testName)
550    {
551        $value = (bool) $this->resolveValue($lastOperationResult, $operation, $context);
552        $msg = "Failed to assert that a value is false in test \"$testName\"\n"
553            ."$operation was [".print_r($value, true)."]"
554            .var_export($lastOperationResult, true);
555        $this->assertFalse($value, $msg);
556
557        return $lastOperationResult;
558    }
559
560    /**
561     * Check if a field in the last operation is true
562     *
563     * @param string            $operation
564     * @param array|string|null $lastOperationResult
565     * @param string            $testName
566     */
567    public function operationIsTrue(string $operation, $lastOperationResult, &$context, string $testName)
568    {
569        $value = $this->resolveValue($lastOperationResult, $operation, $context);
570
571        $msg = "Failed to assert that a value is true in test \"$testName\"\n"
572            ."$operation was [".print_r($value, true)."]"
573            .var_export($lastOperationResult, true);
574        $this->assertNotEquals(0, $value, $msg);
575
576        $this->assertNotFalse($value, $msg);
577        $this->assertNotNull($value, $msg);
578        $this->assertNotEquals('', $msg);
579        return $lastOperationResult;
580    }
581
582    /**
583     * Check if a field in the last operation match an expected value
584     *
585     * @param object            $operation
586     * @param array|string|null $lastOperationResult
587     * @param string            $testName
588     */
589    public function operationMatch($operation, $lastOperationResult, &$context, string $testName)
590    {
591        $key = key($operation);
592
593        if ($key === '$body') {
594            $match = $lastOperationResult;
595        } else {
596            if (empty($key)) {
597                $match = $lastOperationResult['_source'] ?? $lastOperationResult;
598            } else {
599                $match = $this->resolveValue($lastOperationResult, $key, $context);
600            }
601        }
602
603        // Special cases for responses
604        // @todo We need to investigate more about this behaviour
605        switch ($testName) {
606            case 'docvalue_fields with explicit format':
607                if (is_array($match)) {
608                    foreach ($match as $k => $v) {
609                        $match[$k] = is_string($v) ? trim($v) : $v;
610                    }
611                }
612                break;
613        }
614
615        $expected = $this->replaceWithContext(current($operation), $context);
616        $msg = "Failed to match in test \"$testName\". Expected ["
617            .var_export($expected, true)."] does not match [".var_export($match, true)."]\n".var_export($lastOperationResult, true);
618
619        if ($expected instanceof \stdClass) {
620            // Avoid stdClass / array mismatch
621            $expected = json_decode(json_encode($expected), true);
622            $match = json_decode(json_encode($match), true);
623            $this->assertEquals($expected, $match, $msg);
624        } elseif (is_string($expected) && preg_match('#^/.+?/$#s', $expected)) {
625            $this->assertRegExp($this->formatRegex($expected), $match, $msg);
626        } else {
627            $this->assertEquals($expected, $match, $msg);
628        }
629
630        return $lastOperationResult;
631    }
632
633    /**
634     * Check if a field in the last operation is greater than or equal a value
635     *
636     * @param object            $operation
637     * @param array|string|null $lastOperationResult
638     * @param string            $testName
639     */
640    public function operationGreaterThanOrEqual($operation, $lastOperationResult, &$context, string $testName)
641    {
642        $value = $this->resolveValue($lastOperationResult, key($operation), $context);
643        $expected = current($operation);
644
645        $this->assertGreaterThanOrEqual($expected, $value, 'Failed to gte in test ' . $testName);
646
647        return $lastOperationResult;
648    }
649
650    /**
651     * Check if a field in the last operation is greater than a value
652     *
653     * @param object            $operation
654     * @param array|string|null $lastOperationResult
655     * @param string            $testName
656     */
657    public function operationGreaterThan($operation, $lastOperationResult, &$context, string $testName)
658    {
659        $value = $this->resolveValue($lastOperationResult, key($operation), $context);
660        $expected = current($operation);
661
662        $this->assertGreaterThan($expected, $value, 'Failed to gt in test ' . $testName);
663
664        return $lastOperationResult;
665    }
666
667    /**
668     * Check if a field in the last operation is less than or equal a value
669     *
670     * @param object            $operation
671     * @param array|string|null $lastOperationResult
672     * @param string            $testName
673     */
674    public function operationLessThanOrEqual($operation, $lastOperationResult, &$context, string $testName)
675    {
676        $value = $this->resolveValue($lastOperationResult, key($operation), $context);
677        $expected = current($operation);
678
679        $this->assertLessThanOrEqual($expected, $value, 'Failed to lte in test ' . $testName);
680
681        return $lastOperationResult;
682    }
683
684    /**
685     * Check if a field in the last operation is less than a value
686     *
687     * @param object            $operation
688     * @param array|string|null $lastOperationResult
689     * @param string            $testName
690     */
691    public function operationLessThan($operation, $lastOperationResult, &$context, string $testName)
692    {
693        $value = $this->resolveValue($lastOperationResult, key($operation), $context);
694        $expected = current($operation);
695
696        $this->assertLessThan($expected, $value, 'Failed to lt in test ' . $testName);
697
698        return $lastOperationResult;
699    }
700
701    /**
702     * Check if a field in the last operation has length of a value
703     *
704     * @param object            $operation
705     * @param array|string|null $lastOperationResult
706     * @param string            $testName
707     */
708    public function operationLength($operation, $lastOperationResult, &$context, string $testName)
709    {
710        $value = $this->resolveValue($lastOperationResult, key($operation), $context);
711        $expected = current($operation);
712
713        $this->assertCount($expected, $value, 'Failed to gte in test ' . $testName);
714
715        return $lastOperationResult;
716    }
717
718    /**
719     * Set a variable into context from last operation
720     *
721     * @param object            $operation
722     * @param array|string|null $lastOperationResult
723     * @param array             $context
724     * @param string            $testName
725     */
726    public function operationSet($operation, $lastOperationResult, &$context, string $testName)
727    {
728        $key = key($operation);
729        $value = $this->resolveValue($lastOperationResult, $key, $context);
730        $variable = current($operation);
731
732        $context['$' . $variable] = $value;
733
734        return $lastOperationResult;
735    }
736
737    /**
738     * Skip an operation depending on Elasticsearch Version
739     *
740     * @param \stdClass         &object              $operation
741     * @param array|string|null $lastOperationResult
742     * @param string            $testName
743     */
744    public function operationSkip($operation, $lastOperationResult, string $testName)
745    {
746        if (is_object($operation) !== true) {
747            return $lastOperationResult;
748        }
749
750        if (property_exists($operation, 'features')) {
751            if (is_array($operation->features)) {
752                if (count(array_intersect($operation->features, static::$supportedFeatures)) != count($operation->features)) {
753                    static::markTestSkipped(sprintf('Feature(s) %s not supported in test "%s"', json_encode($operation->features), $testName));
754                }
755            } else {
756                if (!in_array($operation->features, static::$supportedFeatures, true)) {
757                    static::markTestSkipped(sprintf('Feature(s) %s not supported in test "%s"', json_encode($operation->features), $testName));
758                }
759            }
760        }
761
762        if (property_exists($operation, 'version')) {
763            $version = $operation->version;
764            $version = str_replace(" ", "", $version);
765            $version = explode("-", $version);
766
767            if (isset($version[0]) && $version[0] == 'all') {
768                static::markTestSkipped(sprintf('Skip test "%s", as all versions should be skipped (%s)', $testName, $operation->reason));
769            }
770
771            if (!isset($version[0]) || $version[0] === "") {
772                $version[0] = ~PHP_INT_MAX;
773            }
774
775            if (!isset($version[1]) || $version[1] === "") {
776                $version[1] = PHP_INT_MAX;
777            }
778
779            if (version_compare(static::$esVersion, (string)$version[0], '>=')  && version_compare(static::$esVersion, (string)$version[1], '<=')) {
780                static::markTestSkipped(sprintf('Skip test "%s", as version %s should be skipped (%s)', $testName, static::$esVersion, $operation->reason));
781            }
782        }
783
784        return $lastOperationResult;
785    }
786
787    /**
788     * Assert an expected error
789     *
790     * @param \Exception $exception
791     * @param string     $expectedError
792     * @param string     $testName
793     *
794     * @return array|null
795     */
796    private function assertException(\Exception $exception, string $expectedError, string $testName)
797    {
798        if (is_string($expectedError) && preg_match('#^/.+?/$#', $expectedError)) {
799            $this->assertRegExp($expectedError, $exception->getMessage(), 'Failed to catch error in test ' . $testName);
800        } elseif ($exception instanceof BadRequest400Exception && $expectedError === 'bad_request') {
801            $this->assertTrue(true);
802        } elseif ($exception instanceof Unauthorized401Exception && $expectedError === 'unauthorized') {
803            $this->assertTrue(true);
804        } elseif ($exception instanceof Missing404Exception && $expectedError === 'missing') {
805            $this->assertTrue(true);
806        } elseif ($exception instanceof Conflict409Exception && $expectedError === 'conflict') {
807            $this->assertTrue(true);
808        } elseif ($exception instanceof Forbidden403Exception && $expectedError === 'forbidden') {
809            $this->assertTrue(true);
810        } elseif ($exception instanceof RequestTimeout408Exception && $expectedError === 'request_timeout') {
811            $this->assertTrue(true);
812        } elseif ($exception instanceof BadRequest400Exception && $expectedError === 'request') {
813            $this->assertTrue(true);
814        } elseif ($exception instanceof ServerErrorResponseException && $expectedError === 'request') {
815            $this->assertTrue(true);
816        } elseif ($exception instanceof \RuntimeException && $expectedError === 'param') {
817            $this->assertTrue(true);
818        } else {
819            $this->assertContains($expectedError, $exception->getMessage());
820        }
821
822        if ($exception->getPrevious() !== null) {
823            return json_decode($exception->getPrevious()->getMessage(), true);
824        }
825
826        return json_decode($exception->getMessage(), true);
827    }
828
829    /**
830     * Provider list of document to test
831     *
832     * @return array
833     */
834    public function yamlProvider(): array
835    {
836        $this->yaml = new Yaml();
837        $path = __DIR__ . '/../../../util/elasticsearch/rest-api-spec/src/main/resources/rest-api-spec/test';
838        $files = [];
839
840        $finder = new Finder();
841        $finder->in($path);
842        $finder->files();
843        $finder->name('*.yml');
844
845        // *.yaml files should be included until the library is ES 6.0+ only
846        $finder->name('*.yaml');
847
848        $filter = getenv('TEST_CASE') !== false ? getenv('TEST_CASE') : null;
849
850        /**
851         * @var SplFileInfo $file
852         */
853        foreach ($finder as $file) {
854            $files = array_merge($files, $this->splitDocument($file, $path, $filter));
855        }
856
857        return $files;
858    }
859
860    /**
861     * Return the real namespace / method couple for elasticsearch php
862     *
863     * @param string      $method
864     * @param string|null $namespace
865     *
866     * @return array
867     */
868    private function mapEndpoint(string $method, $namespace = null): array
869    {
870        if (null === $namespace && array_key_exists($method, static::$endpointMapping)) {
871            return static::$endpointMapping[$method];
872        }
873
874        if (null !== $namespace && array_key_exists($namespace, static::$endpointMapping) && array_key_exists($method, static::$endpointMapping[$namespace])) {
875            return static::$endpointMapping[$namespace][$method];
876        }
877
878        return [$method, $namespace];
879    }
880
881    /**
882     * Replace contextual variable into a bunch of data
883     *
884     * @param object|array|string|integer $data
885     * @param array                       $context
886     *
887     * @return mixed
888     */
889    private function replaceWithContext($data, array $context)
890    {
891        if (empty($context)) {
892            return $data;
893        }
894
895        if (is_string($data)) {
896            if (array_key_exists($data, $context)) {
897                return $context[$data];
898            }
899        }
900
901        if (!is_array($data) && !($data instanceof \stdClass)) {
902            return $data;
903        }
904
905        foreach ($data as $key => &$value) {
906            $value = $this->replaceWithContext($value, $context);
907        }
908
909        return $data;
910    }
911
912    /**
913     * Resolve a value into an array given a specific field
914     *
915     * @param  mixed  $result
916     * @param  string $field
917     * @param  array  $context
918     * @return mixed
919     */
920    private function resolveValue($result, $field, array &$context)
921    {
922        if (empty($field)) {
923            return $result;
924        }
925
926        foreach ($context as $key => $value) {
927            $field = preg_replace('/('.preg_quote($key, '/').')/', $value, $field);
928        }
929
930        $operationSplit = explode('.', $field);
931        $value = $result;
932
933        do {
934            $key = array_shift($operationSplit);
935
936            if (substr($key, -1) === '\\') {
937                $key = substr($key, 0, -1) . '.' . array_shift($operationSplit);
938            }
939
940            if (!is_array($value)) {
941                return $value;
942            }
943
944            if (!array_key_exists($key, $value)) {
945                return false;
946            }
947
948            $value = $value[$key];
949        } while (count($operationSplit) > 0);
950
951        return $value;
952    }
953
954    /**
955     * Format a regex for PHP
956     *
957     * @param string $regex
958     *
959     * @return string
960     */
961    private function formatRegex(string $regex): string
962    {
963        $regex = trim($regex);
964        $regex = substr($regex, 1, -1);
965        $regex = str_replace('/', '\\/', $regex);
966        $regex = '/' . $regex . '/mx';
967
968        return $regex;
969    }
970
971    /**
972     * Split file content into multiple document
973     *
974     * @param SplFileInfo $file
975     * @param string      $path;
976     *
977     * @return array
978     */
979    private function splitDocument(SplFileInfo $file, string $path, string $filter = null): array
980    {
981
982        $fileContent = $file->getContents();
983        // cleanup some bad comments
984        $fileContent = str_replace('"#', '" #', $fileContent);
985
986        $documents = explode("---\n", $fileContent);
987        $documents = array_filter(
988            $documents,
989            function ($item) {
990                return trim($item) !== '';
991            }
992        );
993
994        $documentsParsed = [];
995        $setup = null;
996        $setupSkip = false;
997        $teardown = null;
998        $fileName = str_replace($path . '/', '', $file);
999
1000        if (array_key_exists($fileName, static::$fatalFiles)) {
1001            echo "Skipping: $fileName.  ".static::$fatalFiles[$fileName]."\n";
1002            return [];
1003        }
1004
1005        if (null !== $filter && !preg_match('/'.preg_quote($filter, '/').'/', $fileName)) {
1006            return [];
1007        }
1008        $skip = false;
1009        $documentParsed = null;
1010        foreach ($documents as $documentString) {
1011            // Extract test name
1012            if (preg_match('/"([^"]+)"/', $documentString, $matches)) {
1013                $testName = $matches[1];
1014                // Skip YAML parsing if test is signed to be skipped and if PHP is < version specified
1015                // To prevent YAML parse error, e.g. empty property
1016                if (array_key_exists("$fileName#$testName", static::$skippedTestsIfPhpLessThan)) {
1017                    if (version_compare(PHP_VERSION, static::$skippedTestsIfPhpLessThan["$fileName#$testName"], '<')) {
1018                        continue;
1019                    }
1020                }
1021            }
1022            // TODO few bad instances of teardown, should be fixed in upstream but this is a quick fix locally
1023            $documentString = str_replace(" teardown:", "teardown:", $documentString);
1024            try {
1025                if (!$setupSkip) {
1026                    $documentParsed = $this->yaml->parse($documentString, Yaml::PARSE_OBJECT_FOR_MAP);
1027                    $skip = false;
1028                }
1029            } catch (ParseException $exception) {
1030                $documentParsed = sprintf(
1031                    "[ParseException]Cannot run this test as it cannot be parsed (%s) in file %s",
1032                    $exception->getMessage(),
1033                    $fileName
1034                );
1035
1036                if (preg_match("#\nsetup:#mx", $documentString)) {
1037                    $setupSkip = true;
1038                }
1039
1040                $skip = true;
1041            } catch (\Exception $exception) {
1042                $documentParsed = sprintf(
1043                    "[Exception] Cannot run this test as it generated an exception (%s) in file %s",
1044                    $exception->getMessage(),
1045                    $fileName
1046                );
1047
1048                if (preg_match("#\nsetup:#mx", $documentString)) {
1049                    $setupSkip = true;
1050                }
1051
1052                $skip = true;
1053            }
1054
1055            if (!$skip && key($documentParsed) === 'setup') {
1056                $setup = $documentParsed;
1057                $setupSkip = $skip;
1058            } elseif (!$teardown && key($documentParsed) === 'teardown') {
1059                $teardown = $documentParsed;
1060            } else {
1061                $documentsParsed[] = [$documentParsed, $skip || $setupSkip, $setup, $teardown, $fileName];
1062            }
1063        }
1064
1065        return $documentsParsed;
1066    }
1067
1068    /**
1069     * Clean the cluster
1070     */
1071    private function clean()
1072    {
1073        $host = static::getHost();
1074        $ch = curl_init($host."/*");
1075        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1076        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
1077        curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
1078
1079        $response = curl_exec($ch);
1080        curl_close($ch);
1081
1082        $ch = curl_init($host."/_template/*");
1083        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1084        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
1085        curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
1086
1087        $response = curl_exec($ch);
1088        curl_close($ch);
1089
1090        $ch = curl_init($host."/_snapshot/*/*");
1091        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1092        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
1093        curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
1094
1095        $response = curl_exec($ch);
1096        curl_close($ch);
1097
1098        $ch = curl_init($host."/_snapshot/*");
1099        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1100        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
1101        curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
1102
1103        $response = curl_exec($ch);
1104        curl_close($ch);
1105
1106        $this->rmDirRecursively('/tmp/test_repo_create_1_loc');
1107        $this->rmDirRecursively('/tmp/test_repo_restore_1_loc');
1108        $this->rmDirRecursively('/tmp/test_cat_repo_1_loc');
1109        $this->rmDirRecursively('/tmp/test_cat_repo_2_loc');
1110        $this->rmDirRecursively('/tmp/test_cat_snapshots_1_loc');
1111
1112        $this->waitForYellow();
1113    }
1114
1115    private function rmDirRecursively(string $dir)
1116    {
1117        if (!is_dir($dir)) {
1118            return;
1119        }
1120        $files = new RecursiveIteratorIterator(
1121            new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
1122            RecursiveIteratorIterator::CHILD_FIRST
1123        );
1124
1125        foreach ($files as $fileinfo) {
1126            $todo = ($fileinfo->isDir() ? 'rmdir' : 'unlink');
1127            $todo($fileinfo->getRealPath());
1128        }
1129
1130        rmdir($dir);
1131    }
1132
1133    /**
1134     * Wait for cluster to be in a "YELLOW" state
1135     */
1136    private function waitForYellow()
1137    {
1138        $host = static::getHost();
1139        $ch = curl_init("$host/_cluster/health?wait_for_status=yellow&timeout=50s");
1140        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1141        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "GET");
1142        curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
1143
1144        $response = curl_exec($ch);
1145        if (false !== $response) {
1146            $response = json_decode($response, true);
1147        }
1148
1149        $counter = 0;
1150        while (false !== $response && $response['status'] === 'red') {
1151            sleep(0.5);
1152            $response = json_decode(curl_exec($ch), true);
1153            ++$counter;
1154
1155            if ($counter > 10) {
1156                echo "Aborting test due to failure in clearing cluster.\n";
1157                echo print_r($response, true);
1158                exit;
1159            }
1160        }
1161        curl_close($ch);
1162    }
1163
1164    private function formatHeaders($headers): array
1165    {
1166        $result = (array) $headers;
1167        foreach ($result as $key => $value) {
1168            if (!is_array($value)) {
1169                $result[$key] = explode(',', $value);
1170            }
1171        }
1172        return $result;
1173    }
1174}
1175