From eadce846b166d9ba3f5568bbca77a00c16b2d427 Mon Sep 17 00:00:00 2001 From: Enrico Zimuel Date: Fri, 22 Mar 2024 17:33:43 +0100 Subject: [PATCH] New YAML tests --- .gitignore | 1 + util/BuildAction.php | 552 +++++++++++++++++++++++++++++ util/PhpUnitTests.php | 343 ++++++++++++++++++ util/Utility.php | 33 ++ util/build_yaml_tests.php | 79 +++++ util/template/test/skip-test | 4 + util/template/test/unit-test-class | 48 +++ 7 files changed, 1060 insertions(+) create mode 100644 util/BuildAction.php create mode 100644 util/PhpUnitTests.php create mode 100644 util/Utility.php create mode 100644 util/build_yaml_tests.php create mode 100644 util/template/test/skip-test create mode 100644 util/template/test/unit-test-class diff --git a/.gitignore b/.gitignore index 61b321f53..455d21660 100755 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ util/cache/ util/*.zip util/output/ util/rest-spec +util/tests # Doctum docs generator /doctum.phar diff --git a/util/BuildAction.php b/util/BuildAction.php new file mode 100644 index 000000000..3592a754f --- /dev/null +++ b/util/BuildAction.php @@ -0,0 +1,552 @@ +phpUnitVersion = (int) explode('.', PHPUnitVersion::id())[0]; + + foreach ($steps as $step) { + foreach ($step as $name => $actions) { + if ($name === 'do' && !is_array($actions)) { + var_dump($step); + } + if (method_exists($this, $name) && !$this->skippedTest) { + $this->output .= $this->$name($actions); + } + } + } + } + + private function do(array $actions): string + { + $vars = [ + ':endpoint' => '', + ':params' => '', + ':catch' => '', + ':response-check' => '', + ':code' => '', + ':headers' => '', + ':reset-client' => '' + ]; + foreach ($actions as $key => $value) { + if (is_int($key)) { + var_dump($key, $value); + die(); + } + if (method_exists($this, (string) $key)) { + $this->$key($value, $vars); + } else { + // headers + if (!empty($this->headers)) { + $vars[':headers'] = $this->formatHeaders($this->headers); + $vars[':reset-client'] = '$this->client = Utility::getClient();'; + $this->resetHeaders(); + } + // Check if {} (new stdClass) is the parameter of an endpoint + if ($value instanceof stdClass && empty(get_object_vars($value))) { + $params = ''; + } else { + $params = $this->adjustClientParams($value); + $params = var_export($params, true); + $params = $this->convertDollarValueInVariable($params); // replace '$var' or '${var}' in $var + $params = $this->convertStdClass($params); // convert "stdClass::__set_state(array())" in "(object)[]" + } + $vars[':endpoint'] = $this->convertDotToArrow($key); + //$vars[':params'] = str_replace("\n","\n" . self::TAB14, $params); + $vars[':params'] = $params; + } + } + // ignore client parameter + if (isset($this->clientParams['ignore'])) { + $vars[':code'] = $this->clientParams['ignore']; + if (is_array($vars[':code'])) { + return PhpUnitTests::render(self::TEMPLATE_ENDPOINT_TRY_CATCH_ARRAY, $vars); + } + return PhpUnitTests::render(self::TEMPLATE_ENDPOINT_TRY_CATCH, $vars); + } + return PhpUnitTests::render(self::TEMPLATE_ENDPOINT, $vars); + } + + /** + * Adjust the client parameters (e.g. ignore) + */ + private function adjustClientParams($params) + { + if (!is_array($params)) { + return $params; + } + $this->clientParams = []; + foreach ($params as $key => $value) { + switch($key) { + case 'ignore': + $this->clientParams['ignore'] = $value; + unset($params[$key]); + break; + } + } + return $params; + } + + /** + * ---------- FEATURE FUNCTIONS (BEGIN) ---------- + */ + + private function transform_and_set(array $action): string + { + $key = key($action); + $transform = $action[$key]; + if (false !== strpos($transform, '#base64EncodeCredentials')) { + preg_match_all('/\#base64EncodeCredentials\((\w+),(\w+)\)/', $transform, $matches); + $param1 = $matches[1][0]; + $param2 = $matches[2][0]; + $this->variables[] = $key; + return PhpUnitTests::render(self::TEMPLATE_TRANSFORM_AND_SET, [ + ':var' => $key, + ':param' => sprintf("\$response['%s'] . ':' . \$response['%s']", $param1, $param2) + ]); + } + return ''; + } + + private function set(array $action): string + { + $output = ''; + foreach ($action as $key => $var) { + $this->variables[] = $var; + $output .= PhpUnitTests::render(self::TEMPLATE_SET_VARIABLE, [ + ':var' => $var, + ':value' => $this->convertResponseField($key) + ]); + } + return $output; + } + + private function warnings(array $action, array &$vars) + { + $vars[':response-check'] .= PhpUnitTests::render(self::TEMPLATE_WARNINGS, [ + ':expected' => $action + ]); + } + + private function allowed_warnings(array $action, array &$vars) + { + $vars[':response-check'] .= PhpUnitTests::render(self::TEMPLATE_ALLOWED_WARNINGS, [ + ':expected' => $action + ]); + } + + private function catch(string $action, array &$vars) + { + switch ($action) { + case 'bad_request': + case 'unauthorized': + case 'forbidden': + case 'missing': + case 'request_timeout': + case 'conflict': + $expectedException = ClientResponseException::class; + break; + case 'request': + $expectedException = ElasticsearchException::class; + break; + case 'unavailable': + $expectedException = ElasticsearchException::class; + $scriptException = PhpUnitTests::render(self::TEMPLATE_CATCH_UNAVAILABLE); + break; + case 'param': + $expectedException = ElasticsearchException::class; + $scriptException = 'var_dump($response);'; + break; + default: + $expectedException = ElasticsearchException::class; + $scriptException = PhpUnitTests::render( + ($this->phpUnitVersion > 8) ? (self::TEMPLATE_PHPUNIT9_CATCH_REGEX) : (self::TEMPLATE_CATCH_REGEX), + [ ':regex' => sprintf("'%s'", addslashes($action)) ] + ); + } + $vars[':catch'] = PhpUnitTests::render(self::TEMPLATE_CATCH, [ + ':exception' => $expectedException + ]); + $vars[':response-check'] .= $scriptException ?? ''; + } + + private function headers(array $actions, array $params) + { + $this->headers = $actions; + } + + private function resetHeaders() + { + $this->headers = []; + } + + private function node_selector(array $actions) + { + // this is an empty function since we are using only 1 node + // @see https://github.com/elastic/elasticsearch/tree/master/rest-api-spec/src/main/resources/rest-api-spec/test#node_selector + } + + private function match(array $actions) + { + $key = key($actions); + if (null === $actions[$key]) { + return PhpUnitTests::render(self::TEMPLATE_IS_NULL, [ + ':value' => $this->convertResponseField($key) + ]); + } + + if (is_string($actions[$key]) && substr($actions[$key], 0, 1) !== '$') { + $expected = sprintf("'%s'", addslashes($actions[$key])); + } elseif (is_string($actions[$key]) && substr($actions[$key], 0, 2) === '${') { + $expected = sprintf("\$%s", substr($actions[$key],2, strlen($actions[$key])-3)); + } elseif (is_bool($actions[$key])) { + $expected = $actions[$key] ? 'true' : 'false'; + } elseif (is_array($actions[$key])) { + $expected = $this->removeObjectFromArray($actions[$key]); + } else { + $expected = $actions[$key]; + } + $vars = [ + ':expected' => $expected, + ':value' => $this->convertResponseField($key) + ]; + if (is_string($expected) && $this->isRegularExpression($expected)) { + $vars[':expected'] = $this->convertJavaRegexToPhp($vars[':expected']); + // Add /sx preg modifier to ignore whitespace + $vars[':expected'] .= "sx"; + if ($vars[':value'] === '$response') { + $vars[':value'] = '$response->asString()'; + } + return PhpUnitTests::render( + ($this->phpUnitVersion > 8) ? (self::TEMPLATE_PHPUNIT9_MATCH_REGEX) : (self::TEMPLATE_MATCH_REGEX), + $vars + ); + } elseif (is_array($expected)) { + if ($vars[':value'] === '$response') { + $vars[':value'] = '$response->asArray()'; + } + } elseif (is_bool($expected)) { + if ($vars[':value'] === '$response') { + $vars[':value'] = '$response->asBool()'; + } + } + if ($expected instanceof stdClass && empty(get_object_vars($expected))) { + $vars[':expected'] = '[]'; + if ($vars[':value'] === '$response') { + $vars[':value'] = '$response->asArray()'; + } + } + return PhpUnitTests::render(self::TEMPLATE_MATCH_EQUAL, $vars); + } + + private function is_true(string $value) + { + $vars = [ + ':value' => $this->convertResponseField($value) + ]; + if ($vars[':value'] === '$response') { + return PhpUnitTests::render(self::TEMPLATE_IS_TRUE_RESPONSE, $vars); + } + return PhpUnitTests::render(self::TEMPLATE_IS_TRUE, $vars); + } + + private function is_false(string $value) + { + $vars = [ + ':value' => $this->convertResponseField($value) + ]; + if ($vars[':value'] === '$response') { + return PhpUnitTests::render(self::TEMPLATE_IS_FALSE_RESPONSE, $vars); + } + return PhpUnitTests::render(self::TEMPLATE_IS_FALSE, $vars); + } + + private function length(array $actions) + { + $key = key($actions); + + return PhpUnitTests::render(self::TEMPLATE_LENGTH, [ + ':expected' => (int) $actions[$key], + ':value' => $this->convertResponseField($key) + ]); + } + + private function skip(array $actions) + { + if (isset($actions['version']) && isset($actions['reason'])) { + // Extract version compare constrain + $version = explode ('-', $actions['version']); + $version = array_map('trim', $version); + if (empty($version[0])) { + $version[0] = '0'; + } + if (empty($version[1])) { + $version[1] = sprintf("%s", PHP_INT_MAX); + } + if (strtolower($version[0]) === 'all' || + (version_compare(PhpUnitTests::$esVersion, $version[0], '>=') && version_compare(PhpUnitTests::$esVersion, $version[1], '<=')) + ) { + $this->skippedTest = true; + return PhpUnitTests::render(self::TEMPLATE_SKIP_VERSION, [ + ':testname' => "__CLASS__ . '::' . __FUNCTION__", + ':esversion' => sprintf("'%s'", PhpUnitTests::$esVersion), + ':reason' => sprintf("'%s'", addslashes($actions['reason'])) + ]); + } + } + if (isset($actions['features'])) { + $features = (array) $actions['features']; + foreach ($features as $feature) { + if (!in_array($feature, self::SUPPORTED_FEATURES)) { + $this->skippedTest = true; + return PhpUnitTests::render(self::TEMPLATE_SKIP_FEATURE, [ + ':testname' => "__CLASS__ . '::' . __FUNCTION__", + ':feature' => sprintf("'%s'", $feature) + ]); + } + switch ($feature) { + case 'xpack': + if (PhpUnitTests::$testSuite !== 'platinum') { + $this->skippedTest = true; + return PhpUnitTests::render(self::TEMPLATE_SKIP_XPACK, [ + ':testname' => "__CLASS__ . '::' . __FUNCTION__" + ]); + } + break; + case 'no_xpack': + if (PhpUnitTests::$testSuite !== 'free') { + $this->skippedTest = true; + return PhpUnitTests::render(self::TEMPLATE_SKIP_OSS, [ + ':testname' => "__CLASS__ . '::' . __FUNCTION__" + ]); + } + break; + } + } + + } + } + + private function setup(array $actions) + { + return $this->do($actions); + } + + private function teardown(array $actions) + { + return $this->do($actions); + } + + private function gt(array $actions) + { + $key = key($actions); + return PhpUnitTests::render(self::TEMPLATE_GT, [ + ':expected' => $actions[$key], + ':value' => $this->convertResponseField($key) + ]); + } + + private function gte(array $actions) + { + $key = key($actions); + return PhpUnitTests::render(self::TEMPLATE_GTE, [ + ':expected' => $actions[$key], + ':value' => $this->convertResponseField($key) + ]); + } + + private function lt(array $actions) + { + $key = key($actions); + return PhpUnitTests::render(self::TEMPLATE_LT, [ + ':expected' => $actions[$key], + ':value' => $this->convertResponseField($key) + ]); + } + + private function lte(array $actions) + { + $key = key($actions); + return PhpUnitTests::render(self::TEMPLATE_LTE, [ + ':expected' => $actions[$key], + ':value' => $this->convertResponseField($key) + ]); + } + + /** + * ---------- FEATURE FUNCTIONS (END) ---------- + */ + + public function __toString(): string + { + return $this->output; + } + + private function removeObjectFromArray(array $array): array + { + array_walk_recursive($array, function(&$value, $key) { + if (is_object($value)) { + $value = (array) $value; + } + }); + return $array; + } + + private function convertDotToArrow(string $dot) + { + $result = str_replace ('.', '()->', $dot); + $tot = strlen($result); + for ($i = 0; $i < $tot; $i++) { + if ($result[$i] === '_' && ($i+1) < $tot) { + $result[$i+1] = strtoupper($result[$i+1]); + } + } + return str_replace('_', '', $result); + } + + private function convertResponseField(string $field): string + { + $output = '$response'; + if ($field === '$body' || $field === '') { + return $output; + } + // if the field starts with a .$variable remove the first dot + if (substr($field, 0, 2) === '.$') { + $field = substr($field, 1); + } + # Remove \. from $field + $field = str_replace ('\.', chr(200), $field); + $parts = explode('.', $field); + foreach ($parts as $part) { + # Replace \. in $part + $part = str_replace (chr(200), '.', $part); + if (is_int($part)) { + $output .= sprintf("[%d]", $part); + } else { + $output .= sprintf("[\"%s\"]", $part); + } + } + return $output; + } + + private function convertDollarValueInVariable(string $value): string + { + foreach ($this->variables as $var) { + + $value = str_replace("\${{$var}}", "\$$var", $value); + $value = str_replace("'\$$var'", "\$$var", $value); + if (preg_match("/'[^']*\\\${$var}[^']*',/", $value)) { + $value = str_replace("\$$var", "' . \$$var . '", $value); + } + } + return $value; + } + + private function isRegularExpression(string $regex): bool + { + return preg_match("/^\'\s?\/\^?/", $regex) > 0; + } + + private function convertJavaRegexToPhp(string $regex): string + { + # remove the single quote from the beginning and end of a string + $regex = trim($regex, '\''); + preg_match_all('/(\/\^?)(.+)(\$?\/)/sx', $regex, $matches); + if (isset($matches[2][0])) { + $matches[2][0] = str_replace('/', '\/', $matches[2][0]); + return sprintf("%s%s%s", $matches[1][0], $matches[2][0], $matches[3][0]); + } + + return $regex; + } + + /** + * Convert "stdClass::__set_state" into "(object) []" + * @see https://www.php.net/manual/en/function.var-export.php#refsect1-function.var-export-changelog + */ + private function convertStdClass(string $value): string + { + return preg_replace("/stdClass::__set_state\(array\(\s+\)\)/", '(object) []', $value); + } + + private function formatHeaders(array $headers): string + { + $result = ''; + foreach ($headers as $key => $value) { + $result .= sprintf("\$this->client->getTransport()->setHeader('%s',\"%s\");\n", $key, $value); + } + return $result; + } +} \ No newline at end of file diff --git a/util/PhpUnitTests.php b/util/PhpUnitTests.php new file mode 100644 index 000000000..cbebaa44d --- /dev/null +++ b/util/PhpUnitTests.php @@ -0,0 +1,343 @@ +removeDirectory($testOutput); + } + self::$testSuite = str_replace('-', '', ucwords($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 BuildAction($actions); + break; + case 'teardown': + $teardown = (string) new BuildAction($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 = self::SKIPPED_TESTS; + if (isset($skip[$skippedAllFiles]) || isset($skip[$skippedAllTest])) { + $allSkipped = true; + $functions .= self::render( + self::TEMPLATE_FUNCTION_SKIPPED, + [ + ':name' => $functionName, + ':skipped_msg' => $skip[$skippedAllTest] + ] + ); + } elseif (isset($skip[$skippedTest])) { + $functions .= self::render( + self::TEMPLATE_FUNCTION_SKIPPED, + [ + ':name' => $functionName, + ':skipped_msg' => $skip[$skippedTest] + ] + ); + } else { + $functions .= self::render( + self::TEMPLATE_FUNCTION_TEST, + [ + ':name' => $functionName, + ':test' => (string) new BuildAction($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( + self::TEMPLATE_UNIT_TEST_CLASS, + [ + ':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 getMessage() + )); + } + $numFile++; + } + return [ + 'tests' => $numTest, + 'files' => $numFile, + 'path' => $this->testOutput + ]; + } + + /** + * 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); + + if (false !== $last) { + $namespace = substr($file, 0, $last); + } else { + $namespace = $file; + } + $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; + } +} \ No newline at end of file diff --git a/util/Utility.php b/util/Utility.php new file mode 100644 index 000000000..86a144afc --- /dev/null +++ b/util/Utility.php @@ -0,0 +1,33 @@ +open($tmpFilePath); +// printf("Extracting into %s\n", $testDir); +// $zip->extractTo($testDir); +// $zip->close(); + +printf ("YAML tests installed successfully!\n\n"); + +printf("********************************\n"); +printf("** Building the PHPUnit tests **\n"); +printf("********************************\n"); + +printf ("** Bulding YAML tests for %s suite\n", strtoupper($stack)); +printf ("** Using Elasticsearch %s version\n", $version); + +$yamlTestFolder = sprintf("%s/tests/%s/tests", __DIR__, strtolower($stack)); + +$test = new PhpUnitTests($yamlTestFolder, $outputTest, $version, $stack); +$result = $test->build(); + +printf ("Generated %d PHPUnit files and %d tests.\n", $result['files'], $result['tests']); +printf ("Files saved in %s\n", realpath($result['path'])); +printf ("\n"); + diff --git a/util/template/test/skip-test b/util/template/test/skip-test new file mode 100644 index 000000000..e75ae0027 --- /dev/null +++ b/util/template/test/skip-test @@ -0,0 +1,4 @@ + $this->markTestSkipped(sprintf( + "Skip test %s", + :testname + )); diff --git a/util/template/test/unit-test-class b/util/template/test/unit-test-class new file mode 100644 index 000000000..c0e87d689 --- /dev/null +++ b/util/template/test/unit-test-class @@ -0,0 +1,48 @@ +