util/YamlTests.php (384 lines of code) (raw):
<?php
/**
* Elasticsearch PHP Client
*
* @link https://github.com/elastic/elasticsearch-php
* @copyright Copyright (c) Elasticsearch B.V (https://www.elastic.co)
* @license https://opensource.org/licenses/MIT MIT License
*
* Licensed to Elasticsearch B.V under one or more agreements.
* Elasticsearch B.V licenses this file to you under the MIT License.
* See the LICENSE file in the project root for more information.
*/
declare(strict_types = 1);
namespace Elastic\Elasticsearch\Util;
use Exception;
use ParseError;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use stdClass;
use Throwable;
use function yaml_parse;
class YamlTests
{
const TEMPLATE_UNIT_TEST_OSS = __DIR__ . '/template/test/unit-test-oss';
const TEMPLATE_UNIT_TEST_XPACK = __DIR__ . '/template/test/unit-test-xpack';
const TEMPLATE_UNIT_TEST_SKIPPED = __DIR__ . '/template/test/unit-test-skipped';
const TEMPLATE_FUNCTION_TEST = __DIR__ . '/template/test/function-test';
const TEMPLATE_FUNCTION_SKIPPED = __DIR__ . '/template/test/function-skipped';
const ELASTICSEARCH_GIT_URL = 'https://github.com/elastic/elasticsearch/tree/%s/rest-api-spec/src/main/resources/rest-api-spec/test/%s';
const YAML_FILES_TO_OMIT = [
'platinum/eql/10_basic.yml',
// use of _internal APIs
'free/cluster.desired_nodes/10_basic.yml',
'free/cluster.desired_nodes/20_dry_run.yml',
'free/health/',
'free/cluster.desired_balance/10_basic.yml',
'free/cluster.prevalidate_node_removal/10_basic.yml',
'free/cluster.desired_nodes/11_old_format.yml'
];
const SKIPPED_TEST_OSS = [
'Aggregations\_HistogramTest::HistogramProfiler' => "[histogram] field doesn't support values of type: VALUE_BOOLEAN",
'Aggregations\_Percentiles_BucketTest::*' => 'Array index with float',
'Aggregations\_Time_SeriesTest::BasicTest' => 'Unknown aggregation type [time_series]',
'Cat\Nodeattrs\_10_BasicTest::TestCatNodesAttrsOutput' => 'Regexp error, it seems not compatible with PHP',
'Cat\Shards\_10_BasicTest::TestCatShardsOutput' => 'Regexp error, it seems not compatible with PHP',
'Cat\Templates\_10_BasicTest::FilteredTemplates' => 'regex mismatch',
'Cat\Templates\_10_BasicTest::SelectColumns' => 'regex mismatch',
'FieldCaps\_50_Fieldtype_FilterTest::*' => 'Bool mismatch',
'Indices\Create\_20_Synthetic_SourceTest::*' => 'Malformed request',
'Indices\GetAlias\_10_BasicTest::GetAliasAgainstClosedIndices' => 'Failed asserting that true is false',
'Indices\GetIndexTemplate\_10_BasicTest::*' => 'Bool mismatch',
'Indices\PutTemplate\_10_BasicTest::PutTemplateCreate' => 'index_template [test] already exists',
'Indices\Refresh\_10_BasicTest::IndicesRefreshTestEmptyArray' => 'empty array?',
'Indices\ResolveCluster\_10_Basic_Resolve_ClusterTest::TestResolveClusterOptionalParamsAreAccepted' => 'Bool mismatch',
'Indices\SimulateIndexTemplate\_10_BasicTest::SimulateIndexTemplateWithIndexNotMatchingAnyTemplate' => 'Bool mismatch',
'Indices\ValidateQuery\_10_SynonymsTest::ValidateQueryWithSynonyms' => 'Failed asserting that two strings are equal',
'IngestGeoip\_20_Geoip_ProcessorTest::*' => 'Undefined array key "geoip"',
'IngestGeoip\_30_Geoip_StatsTest::TestGeoipStats' => 'Undefined array key "_arbitrary_key_"',
'IngestGeoip\_50_Ip_Lookup_ProcessorTest::TestIp_locationProcessorWithDefaults' => 'Undefined array key "ip_location"',
'Search\Suggest\_20_PhraseTest::BreaksTiesBySortingTerms' => 'body not supported',
'Search\_330_Fetch_FieldsTest::TestWithSubobjectsAuto' => 'unknown subobjects value: auto',
'Search\Vectors\_90_Sparse_VectorTest::SparseVectorIn800X8110' => 'Undefined array key error',
'Snapshot\Create\_10_BasicTest::CreateASnapshot' => 'Invalid snapshot name [test_snapshot]',
'Snapshot\Create\_10_BasicTest::CreateASnapshotAndCleanUpRepository' => 'Invalid snapshot name [test_snapshot]',
'Tsdb\_20_MappingTest::UnsupportedMetricTypePosition' => 'Fixed in Elasticsearch 8.9',
];
const SKIPPED_TEST_XPACK = [
'ApiKey\_10_BasicTest::TestGetApiKey' => 'Mismatch values',
'ApiKey\_20_QueryTest::TestQueryApiKey' => 'Mismatch values',
'DataStream\_80_Resolve_Index_Data_StreamsTest::*' => 'Skipped all tests',
'Dlm\_10_UsageTest::TestDataStreamLifecycleUsageStats' => 'Mismatch values',
'Entsearch\Analytics\_40_Behavioral_Analytics_Event_PostTest::*' => '401 Unauthorized',
'Entsearch\Connector\Secret\_10_Connector_Secret_PostTest::*' => 'Undefined method',
'Entsearch\Connector\Secret\_20_Connector_Secret_PutTest::*' => 'Undefined method',
'Entsearch\Connector\Secret\_30_Connector_Secret_GetTest::*' => 'Undefined method',
'Entsearch\Connector\Secret\_40_Connector_Secret_DeleteTest::*' => 'Undefined method',
'Entsearch\Connector\SyncJob\_20_Connector_Sync_Job_DeleteTest::*' => 'Undefined constant',
'Entsearch\Connector\SyncJob\_40_Connector_Sync_Job_CancelTest::*' => 'Undefined constant',
'Entsearch\Connector\SyncJob\_50_Connector_Sync_Job_GetTest::*' => 'Undefined constant',
'Entsearch\Connector\SyncJob\_80_Connector_Sync_Job_ListTest::*' => 'Undefined constant',
'Entsearch\Connector\_20_Connector_ListTest::*' => '401 Unauthorized',
'Entsearch\Connector\_60_Connector_Update_FilteringTest::*' => 'Cannot access offset of type string on string',
'Entsearch\Rules\_40_Rule_Query_SearchTest::*' => '401 Unauthorized',
'Entsearch\Search\_55_Search_Application_SearchTest::*' => '401 Unauthorized',
'Entsearch\Search\_56_Search_Application_Search_With_ApikeyTest::*' => '401 Unauthorized',
'Esql\_30_TypesTest::Unsigned_long' => 'Format number issue',
'Health\_10_UsageTest::UsageStatsOnTheHealthAPI' => 'Undefined array key \"green\"',
'License\_20_Put_LicenseTest::*' => 'License issue',
'License\_30_Enterprise_LicenseTest::*' => 'Invalid license',
'Ml\_Jobs_CrudTest::TestPutJobWithModel_memory_limitAsStringAndLazyOpen' => 'Memory limit',
'Ml\_Data_Frame_Analytics_CrudTest::TestPutClassificationGivenNum_top_classesIsLessThanZero' => 'No error catched',
'Ml\_Set_Upgrade_ModeTest::*' => 'Skipped all tests',
'Ml\_Filter_CrudTest::*' => 'Skipped all tests',
'Ml\_Inference_CrudTest::*' => 'Skipped all tests',
'Ml\_Inference_Stats_CrudTest::*' => 'Skipped all tests',
'Ml\_Ml_InfoTest::TestMlInfo' => 'response[\'limits\'][\'max_model_memory_limit\'] is not empty',
'Ml\_Delete_Expired_DataTest::TestDeleteExpiredDataWithJobId' => 'Substring mismatch',
'Ml\_Explain_Data_Frame_AnalyticsTest::TestNonemptyDataFrameGivenBody' => 'Expected a different value',
'Ml\_Get_Trained_Model_StatsTest::*' => 'Skipped all tests',
'Ml\_Get_Trained_Model_StatsTest::TestGetStatsGivenTrainedModels' => 'cannot assign model_alias',
'Ml\_Inference_RescoreTest::*' => 'unknown field [learn_to_rank]',
'Ml\_Learning_To_Rank_RescorerTest::*' => 'Bad request',
'Rollup\_Put_JobTest::TestPutJobWithTemplates' => 'version not converted from variable',
'RuntimeFields\_100_Geo_PointTest::GetMapping' => 'Substring mismatch',
'RuntimeFields\_10_KeywordTest::GetMapping' => 'Substring mismatch',
'RuntimeFields\_10_KeywordTest::FetchFields' => 'Array mismatch',
'RuntimeFields\_10_KeywordTest::Docvalue_fields' => 'Array mismatch',
'RuntimeFields\_10_KeywordTest::ExplainTermQueryWrappedInScriptScore' => 'Substring mismatch',
'RuntimeFields\_200_Runtime_Fields_StatsTest::UsageStatsWithRuntimeFields' => 'Count mismatch',
'RuntimeFields\_20_LongTest::GetMapping' => 'String mismatch',
'RuntimeFields\_30_DoubleTest::GetMapping' => 'Array mismatch',
'RuntimeFields\_40_DateTest::GetMapping' => 'String mismatch',
'RuntimeFields\_50_IpTest::GetMapping' => 'String mismatch',
'RuntimeFields\_60_BooleanTest::GetMapping' => 'String mismatch',
'SearchBusinessRules\_10_Pinned_QueryTest::TestPinnedQueryWithDocsAndNoIndexFailInPreviousVersions' => 'No exception thrown',
'SearchableSnapshots\_10_UsageTest::*' => 'Mismatch values',
'SearchableSnapshots\_20_Synthetic_SourceTest::*' => 'no_shard_available_action_exception',
'ServiceAccounts\_10_BasicTest::TestServiceAccountTokens' => 'Count mismatch',
'Snapshot\_10_BasicTest::CreateASourceOnlySnapshotAndThenRestoreIt' => 'Snapshot name already exists',
'Snapshot\_10_BasicTest::FailedToSnapshotIndicesWithSyntheticSource' => 'Failed asserting that 1 matches expected 0',
'Snapshot\_20_Operator_Privileges_DisabledTest::OperatorOnlySettingsCanBeSetAndRestoredByNonoperatorUserWhenOperatorPrivilegesIsDisabled' => 'Count mismatch',
'Spatial\_130_Geo_Shape_RuntimeTest::GetMapping' => 'Escape string issue',
'Sql\_SqlTest::PagingThroughResults' => 'Mismatch values',
'Ssl\_10_BasicTest::TestGetSSLCertificates' => 'Mismatch values',
'Token\_10_BasicTest::TestInvalidateUsersTokens' => 'Mismatch values',
'Token\_10_BasicTest::TestInvalidateRealmsTokens' => 'Mismatch values',
'Transform\_Transforms_CrudTest::TestDeleteTransformWhenItDoesNotExist' => 'Invalid version format: TRANSFORM HTTP/1.1',
'UnsignedLong\*' => 'Skipped all tests',
'Users\_40_QueryTest::TestQueryUser' => 'Mismatch values',
'Vectors\_30_Sparse_Vector_BasicTest::DeprecatedFunctionSignature' => 'Failed asserting contains string',
];
const PHP_RESERVED_WORDS = [
'abstract', 'and', 'array', 'as', 'break', 'callable', 'case', 'catch',
'class', 'clone', 'const', 'continue', 'declare', 'default', 'die', 'do',
'echo', 'else', 'elseif', 'empty', 'enddeclare', 'endfor', 'endforeach',
'endif', 'endswitch', 'endwhile', 'eval', 'exit', 'extends', 'final',
'for', 'foreach', 'function', 'global', 'goto', 'if', 'implements',
'include', 'include_once', 'instanceof', 'insteadof', 'interface',
'isset', 'list', 'namespace', 'new', 'or', 'print', 'private',
'protected', 'public', 'require', 'require_once', 'return', 'static',
'switch', 'throw', 'trait', 'try', 'unset', 'use', 'var', 'while', 'xor'
];
private $tests = [];
private $testOutput;
private $testDir;
public static $esVersion;
public static $minorEsVersion;
public static $testSuite;
public function __construct(string $testDir, string $testOutput, string $esVersion, string $testSuite)
{
if (!is_dir($testDir)) {
throw new Exception(sprintf(
"The directory %s specified does not exist",
$testDir
));
}
if (file_exists($testOutput)) {
$this->removeDirectory($testOutput);
}
self::$testSuite = ucfirst($testSuite);
$this->testOutput = sprintf("%s/%s", $testOutput, self::$testSuite);
$this->testDir = $testDir;
$this->tests = $this->getAllTests($testDir);
self::$esVersion = $esVersion;
list($major, $minor, $patch) = explode('.',self::$esVersion);
self::$minorEsVersion = sprintf("%s.%s", $major, $minor);
}
private function getAllTests(string $dir): array
{
$it = new RecursiveDirectoryIterator($dir);
$parsed = [];
// Iterate over the Yaml test files
foreach (new RecursiveIteratorIterator($it) as $file) {
if ($file->getExtension() !== 'yml') {
continue;
}
$omit = false;
foreach (self::YAML_FILES_TO_OMIT as $fileOmit) {
if (false !== strpos($file->getPathname(), $fileOmit)) {
$omit = true;
break;
}
}
if ($omit) {
continue;
}
$content = file_get_contents($file->getPathname());
$content = str_replace(' y:', " 'y':", $content); // replace y: with 'y': due the y/true conversion in YAML 1.1
$content = str_replace(' n:', " 'n':", $content); // replace n: with 'n': due the n/false conversion in YAML 1.1
try {
$test = yaml_parse($content, -1, $ndocs, [
YAML_MAP_TAG => function($value, $tag, $flags) {
return empty($value) ? new stdClass : $value;
}
]);
} catch (Throwable $e) {
throw new Exception(sprintf(
"YAML parse error file %s: %s",
$file->getPathname(),
$e->getMessage()
));
}
if (false === $test) {
throw new Exception(sprintf(
"YAML parse error file %s",
$file->getPathname()
));
}
$parsed[$file->getPathname()] = $test;
}
return $parsed;
}
public function build(): array
{
$numTest = 0;
$numFile = 0;
foreach ($this->tests as $testFile => $value) {
$namespace = $this->extractTestNamespace($testFile);
$testName = $this->extractTestName($testFile);
$yamlFileName = substr($testFile, strlen($this->testDir) + 1);
# Delete and create the output directory
$testDirName = sprintf("%s/%s", $this->testOutput, str_replace ('\\', '/', $namespace));
if (!is_dir($testDirName)) {
mkdir ($testDirName, 0777, true);
}
$functions = '';
$setup = '';
$teardown = '';
$alreadyAssignedNames = [];
$allSkipped = false;
foreach ($value as $test) {
if (!is_array($test)) {
continue;
}
foreach ($test as $name => $actions) {
switch ($name) {
case 'setup':
$setup = (string) new ActionTest($actions);
break;
case 'teardown':
$teardown = (string) new ActionTest($actions);
break;
default:
$functionName = $this->filterFunctionName(ucwords($name), $alreadyAssignedNames);
$alreadyAssignedNames[] = $functionName;
$skippedTest = sprintf("%s\\%s::%s", $namespace, $testName, $functionName);
$skippedAllTest = sprintf("%s\\%s::*", $namespace, $testName);
$skippedAllFiles = sprintf("%s\\*", $namespace);
$skip = strtolower(self::$testSuite) === 'free'
? self::SKIPPED_TEST_OSS
: self::SKIPPED_TEST_XPACK;
if (isset($skip[$skippedAllFiles]) || isset($skip[$skippedAllTest])) {
$allSkipped = true;
$functions .= self::render(
self::TEMPLATE_FUNCTION_SKIPPED,
[
':name' => $functionName,
':skipped_msg' => addslashes($skip[$skippedAllTest])
]
);
} elseif (isset($skip[$skippedTest])) {
$functions .= self::render(
self::TEMPLATE_FUNCTION_SKIPPED,
[
':name' => $functionName,
':skipped_msg' => addslashes($skip[$skippedTest])
]
);
} else {
$functions .= self::render(
self::TEMPLATE_FUNCTION_TEST,
[
':name' => $functionName,
':test' => (string) new ActionTest($actions)
]
);
}
$numTest++;
}
}
}
if ($allSkipped) {
$test = self::render(
self::TEMPLATE_UNIT_TEST_SKIPPED,
[
':namespace' => sprintf("Elastic\Elasticsearch\Tests\Yaml\%s\%s", self::$testSuite, $namespace),
':test-name' => $testName,
':tests' => $functions,
':yamlfile' => sprintf(self::ELASTICSEARCH_GIT_URL, self::$minorEsVersion, $yamlFileName),
':group' => strtolower(self::$testSuite)
]
);
} else {
$test = self::render(
strtolower(self::$testSuite) === 'free'
? self::TEMPLATE_UNIT_TEST_OSS
: self::TEMPLATE_UNIT_TEST_XPACK,
[
':namespace' => sprintf("Elastic\Elasticsearch\Tests\Yaml\%s\%s", self::$testSuite, $namespace),
':test-name' => $testName,
':tests' => $functions,
':setup' => $setup,
':teardown' => $teardown,
':yamlfile' => sprintf(self::ELASTICSEARCH_GIT_URL, self::$minorEsVersion, $yamlFileName),
':group' => strtolower(self::$testSuite)
]
);
}
// Fix ${var} string interpolation deprecated for PHP 8.2
// @see https://php.watch/versions/8.2/$%7Bvar%7D-string-interpolation-deprecated
$test = $this->fixStringInterpolationInCurlyBracket($test);
file_put_contents($testDirName . '/' . $testName . '.php', $test);
try {
eval(substr($test, 5)); // remove <?php header
} catch (ParseError $e) {
throw new Exception(sprintf(
"The PHP code generate in %s not valid: %s",
$testDirName . '/' . $testName . '.php',
$e->getMessage()
));
}
$numFile++;
}
return [
'tests' => $numTest,
'files' => $numFile
];
}
/**
* Convert ${var} in {$var} for PHP 8.2 deprecation notice
*
* @see https://php.watch/versions/8.2/$%7Bvar%7D-string-interpolation-deprecated
*/
private function fixStringInterpolationInCurlyBracket(string $code): string
{
return preg_replace('/\${([^}]+)}/', '{\$$1}', $code);
}
private function extractTestNamespace(string $path)
{
$file = substr($path, strlen($this->testDir) + 1);
$last = strrpos($file, '/', -1);
$namespace = substr($file, 0, $last);
$namespace = ucwords($namespace, '._/-');
$namespace = str_replace(['.', '_', '/', '-'], ['\\', '', '\\', ''], ucwords($namespace, '.'));
// Check if a PHP reserved word is present in the namespace
$parts = explode ('\\', $namespace);
foreach ($parts as $part) {
if (in_array(strtolower($part), self::PHP_RESERVED_WORDS)) {
$namespace = str_replace ($part, $part . '_', $namespace);
}
}
return $namespace;
}
private function extractTestName(string $path): string
{
$file = substr($path, strlen($this->testDir) + 1);
$last = strrpos($file, '/', -1);
$testName = substr($file, $last + 1, -4);
$testName = ucwords($testName, '_-');
$testName = str_replace('-', '', $testName);
return '_' . $testName . 'Test';
}
public static function render(string $fileName, array $params = []): string
{
if (!is_file($fileName)) {
throw new Exception(sprintf(
"The file %s is not valid",
$fileName
));
}
$output = file_get_contents($fileName);
foreach ($params as $name => $value) {
if (is_array($value)) {
$value = var_export($value, true);
} elseif ($value instanceof \stdClass) {
$value = 'new \stdClass';
} elseif (is_numeric($value)) {
$value = var_export($value, true);
}
$output = str_replace($name, $value, $output);
}
return $output;
}
private function removeDirectory($directory)
{
foreach(glob("{$directory}/*") as $file)
{
if(is_dir($file)) {
$this->removeDirectory($file);
} else {
unlink($file);
}
}
if (is_dir($directory)) {
rmdir($directory);
}
}
private function filterFunctionName(string $name, array $alreadyAssigned = []): string
{
$result = preg_replace("/[^a-zA-Z0-9_]/", "", $name);
while (in_array($result, $alreadyAssigned)) {
$result .= '_';
}
return $result;
}
}