diff --git a/.gitignore b/.gitignore index 0dcf066..9c1cabb 100644 --- a/.gitignore +++ b/.gitignore @@ -24,9 +24,6 @@ prod/php/vendor_*/ .php_cs.cache # PHPUnit -# phpunit.xml.dist is checked into version control and phpunit.xml is ignored by version control. -# This allows a developer to use a custom configuration without running the risk of accidentally checking it into version control. -phpunit.xml .phpunit.result.cache #phpt results folder diff --git a/composer.json b/composer.json index 23acfa2..346807c 100644 --- a/composer.json +++ b/composer.json @@ -26,13 +26,20 @@ "slim/slim": "*" }, "require-dev": { + "ext-ctype": "*", + "ext-curl": "*", + "ext-zlib": "*", + "guzzlehttp/guzzle": "^7.9.2", + "nikic/php-parser": "^5.4.0", + "php-ds/php-ds": "^1.5.0", "php-parallel-lint/php-console-highlighter": "^1.0", "php-parallel-lint/php-parallel-lint": "1.4.0", - "phpstan/phpstan": "2.0.2", - "phpstan/phpstan-phpunit": "^2.0.1", - "phpunit/phpunit": "^10.5", + "phpstan/phpstan": "2.1.3", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.45", + "react/http": "^1.6", "slevomat/coding-standard": "8.15.0", - "squizlabs/php_codesniffer": "3.11.1" + "squizlabs/php_codesniffer": "3.11.3" }, "autoload-dev": { "psr-4": { @@ -61,31 +68,65 @@ "./tools/build/configure_php_templates.sh" ], "parallel-lint": [ + "@putenv ELASTIC_OTEL_ENABLED=false", + "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all", + "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false", "parallel-lint ./prod/php/ ./tests/ --exclude ./tests/polyfills/" ], "php_codesniffer_check": [ + "@putenv ELASTIC_OTEL_ENABLED=false", + "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all", + "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false", "phpcs -s ./prod/php/", "phpcs -s ./tests/" ], "php_codesniffer_fix": [ + "@putenv ELASTIC_OTEL_ENABLED=false", + "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all", + "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false", "phpcbf ./prod/php/", "phpcbf ./tests/" ], "fix_code_format_for": [ + "@putenv ELASTIC_OTEL_ENABLED=false", + "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all", + "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false", "phpcbf" ], "phpstan-junit-report-for-ci": [ + "@putenv ELASTIC_OTEL_ENABLED=false", + "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all", + "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false", "phpstan analyse --error-format=junit ./prod/php/ --level max --memory-limit=1G | tee build/elastic-otel-phpstan-junit.xml", "phpstan analyse --error-format=junit ./tests/ --level max --memory-limit=1G --error-format=junit | tee build/tests-phpstan-junit.xml" ], "phpstan": [ - "phpstan analyse ./prod/php/", - "phpstan analyse ./tests/" + "@putenv ELASTIC_OTEL_ENABLED=false", + "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all", + "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false", + "phpstan analyse ./prod/php/ --memory-limit=1G", + "phpstan analyse ./tests/ --memory-limit=1G" ], "static_check": [ "composer run-script -- parallel-lint", "composer run-script -- php_codesniffer_check", "composer run-script -- phpstan" + ], + "run_unit_tests": [ + "@putenv ELASTIC_OTEL_ENABLED=false", + "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all", + "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false", + "phpunit" + ], + "static_check_and_run_unit_tests": [ + "composer run-script -- static_check", + "composer run-script -- run_unit_tests" + ], + "run_component_tests_configured_custom_config": [ + "@putenv ELASTIC_OTEL_ENABLED=false", + "@putenv OTEL_PHP_DISABLED_INSTRUMENTATIONS=all", + "@putenv OTEL_PHP_AUTOLOAD_ENABLED=false", + "phpunit" ] } } diff --git a/phpcs.xml.dist b/phpcs.xml similarity index 84% rename from phpcs.xml.dist rename to phpcs.xml index 1e7b114..dfed5e6 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml @@ -28,4 +28,9 @@ */prod/php/ElasticOTel/Log/LogFeature.php */prod/php/ElasticOTel/PhpPartVersion.php + + */tests/polyfills/AllowDynamicProperties.php + */tests/polyfills/Override.php + + */tests/substitutes/*/original/* diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 218b437..7a01ec4 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,5 +1,5 @@ includes: - - vendor/phpstan/phpstan-phpunit/extension.neon + - ./vendor/phpstan/phpstan-phpunit/extension.neon parameters: bootstrapFiles: @@ -8,3 +8,6 @@ parameters: level: max reportUnmatchedIgnoredErrors: true + + excludePaths: + - ./tests/substitutes/*/original/* diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..1dc3c11 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,82 @@ + + + + + ./tests/ElasticOTelTests/UnitTests + + + + + + + + + + + + + + ./prod/php + + + + + + + + diff --git a/phpunit_component_tests.xml b/phpunit_component_tests.xml new file mode 100644 index 0000000..bee6c69 --- /dev/null +++ b/phpunit_component_tests.xml @@ -0,0 +1,82 @@ + + + + + ./tests/ElasticOTelTests/ComponentTests + + + + + + + + + + + + + + ./prod/php + + + + + + + + diff --git a/prod/native/extension/code/ModuleInit.cpp b/prod/native/extension/code/ModuleInit.cpp index 75da437..4746e6a 100644 --- a/prod/native/extension/code/ModuleInit.cpp +++ b/prod/native/extension/code/ModuleInit.cpp @@ -83,6 +83,11 @@ void elasticApmModuleInit(int moduleType, int moduleNumber) { return; } + if (EAPM_CFG(bootstrap_php_part_file).empty()) { + ELOGF_WARNING(globals->logger_, MODULE, "bootstrap_php_part_file configuration option is not set - extension will be disabled"); + return; + } + ELOGF_DEBUG(globals->logger_, MODULE, "MINIT Replacing hooks"); elasticapm::php::Hooking::getInstance().fetchOriginalHooks(); elasticapm::php::Hooking::getInstance().replaceHooks(globals->config_->get().inferred_spans_enabled, globals->config_->get().dependency_autoloader_guard_enabled); @@ -105,6 +110,10 @@ void elasticApmModuleShutdown( int moduleType, int moduleNumber ) { return; } + if (EAPM_CFG(bootstrap_php_part_file).empty()) { + return; + } + elasticapm::php::Hooking::getInstance().restoreOriginalHooks(); // curl_global_cleanup(); diff --git a/prod/native/extension/phpt/tests/includes/tests_util.inc b/prod/native/extension/phpt/tests/includes/tests_util.inc index fd7f9af..1bf40c3 100644 --- a/prod/native/extension/phpt/tests/includes/tests_util.inc +++ b/prod/native/extension/phpt/tests/includes/tests_util.inc @@ -1,15 +1,15 @@ bootstrap_php_part_file.c_str()); bridge_->compileAndExecuteFile((*config_)->bootstrap_php_part_file); + ELOGF_DEBUG(log_, REQUEST, "Executing entry point in bootstrap_php_part_file: '%s' ...", (*config_)->bootstrap_php_part_file.c_str()); bridge_->callPHPSideEntryPoint(log_->getMaxLogLevel(), requestStartTime); } catch (std::exception const &e) { ELOGF_CRITICAL(log_, REQUEST, "Unable to bootstrap PHP-side instrumentation '%s'", e.what()); return false; } + ELOGF_DEBUG(log_, REQUEST, "Executed entry point in bootstrap_php_part_file: %s", (*config_)->bootstrap_php_part_file.data()); return true; } diff --git a/prod/php/ElasticOTel/Autoloader.php b/prod/php/ElasticOTel/Autoloader.php index 8a72dbf..4d781f3 100644 --- a/prod/php/ElasticOTel/Autoloader.php +++ b/prod/php/ElasticOTel/Autoloader.php @@ -19,6 +19,8 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel; @@ -32,11 +34,8 @@ final class Autoloader { private const AUTOLOAD_FQ_CLASS_NAME_PREFIX = 'Elastic\\OTel\\'; - /** @var int */ - private static $autoloadFqClassNamePrefixLength; - - /** @var string */ - private static $srcRootDir; + private static int $autoloadFqClassNamePrefixLength; + private static string $srcRootDir; public static function register(string $rootDir): void { @@ -54,7 +53,7 @@ private static function shouldAutoloadCodeForClass(string $fqClassName): bool public static function autoloadCodeForClass(string $fqClassName): void { - // Example of $fqClassName: Elastic\Apm\Impl\Util\Assert + // Example of $fqClassName: Elastic\OTel\Autoloader BootstrapStageLogger::logTrace("Entered with fqClassName: `$fqClassName'", __FILE__, __LINE__, __CLASS__, __FUNCTION__); diff --git a/prod/php/ElasticOTel/BootstrapStageLogger.php b/prod/php/ElasticOTel/BootstrapStageLogger.php index 3e7738c..b0677dd 100644 --- a/prod/php/ElasticOTel/BootstrapStageLogger.php +++ b/prod/php/ElasticOTel/BootstrapStageLogger.php @@ -19,12 +19,16 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel; use Throwable; +use function elastic_otel_log_feature; + /** * Code in this file is part of implementation internals, and thus it is not covered by the backward compatibility. * @@ -114,7 +118,6 @@ public static function configure(int $maxEnabledLevel, string $phpSrcCodeRootDir . '; maxEnabledLevel: ' . self::levelToString($maxEnabledLevel) . '; phpSrcCodePathPrefixToRemove: ' . self::$phpSrcCodePathPrefixToRemove . '; classNamePrefixToRemove: ' . self::$classNamePrefixToRemove - . '; maxEnabledLevel: ' . self::levelToString($maxEnabledLevel) . '; pid: ' . self::nullableToLog(self::$pid), __FILE__, __LINE__, @@ -235,12 +238,7 @@ private static function logWithLevel(int $statementLevel, string $message, strin return; } - /** - * elastic_otel_* functions are provided by the extension - * - * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection - */ - \elastic_otel_log_feature( // @phpstan-ignore function.notFound + elastic_otel_log_feature( 0 /* $isForced */, $statementLevel, Log\LogFeature::BOOTSTRAP, diff --git a/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransport.php b/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransport.php index e28cb43..0662d55 100644 --- a/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransport.php +++ b/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransport.php @@ -19,6 +19,8 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel\HttpTransport; @@ -59,12 +61,8 @@ public function __construct( $this->endpoint = $endpoint; $this->contentType = $contentType; - /** - * \Elastic\OTel\HttpTransport\* functions are provided by the extension - * - * @noinspection PhpUnnecessaryFullyQualifiedNameInspection, PhpUndefinedFunctionInspection - */ - \Elastic\OTel\HttpTransport\initialize($endpoint, $contentType, $headers, $timeout, $retryDelay, $maxRetries); // @phpstan-ignore function.notFound + // \Elastic\OTel\HttpTransport\initialize is provided by the extension + initialize($endpoint, $contentType, $headers, $timeout, $retryDelay, $maxRetries); } public function contentType(): string @@ -77,12 +75,8 @@ public function contentType(): string */ public function send(string $payload, ?CancellationInterface $cancellation = null): FutureInterface { - /** - * \Elastic\OTel\HttpTransport\* functions are provided by the extension - * - * @noinspection PhpUnnecessaryFullyQualifiedNameInspection, PhpUndefinedFunctionInspection - */ - \Elastic\OTel\HttpTransport\enqueue($this->endpoint, $payload); // @phpstan-ignore function.notFound + // \Elastic\OTel\HttpTransport\enqueue is provided by the extension + enqueue($this->endpoint, $payload); return new CompletedFuture(null); } diff --git a/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransportFactory.php b/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransportFactory.php index 1775c8e..f186057 100644 --- a/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransportFactory.php +++ b/prod/php/ElasticOTel/HttpTransport/ElasticHttpTransportFactory.php @@ -40,7 +40,6 @@ public function create( ?string $cert = null, ?string $key = null ): ElasticHttpTransport { - spl_autoload_call("Elastic\OTel\PhpPartVersion"); $headers['User-Agent'] = "elastic-otlp-http-php/" . PhpPartVersion::VALUE; return new ElasticHttpTransport($endpoint, $contentType, $headers, $compression, $timeout, $retryDelay, $maxRetries, $cacert, $cert, $key); } diff --git a/prod/php/ElasticOTel/InferredSpans/InferredSpans.php b/prod/php/ElasticOTel/InferredSpans/InferredSpans.php index 620acf6..27257c6 100644 --- a/prod/php/ElasticOTel/InferredSpans/InferredSpans.php +++ b/prod/php/ElasticOTel/InferredSpans/InferredSpans.php @@ -19,27 +19,34 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel\InferredSpans; use OpenTelemetry\API\Globals; use OpenTelemetry\API\Behavior\LogsMessagesTrait; +use OpenTelemetry\API\Trace\SpanInterface; use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\API\Trace\TracerInterface; use OpenTelemetry\Context\Context; +use OpenTelemetry\Context\ContextStorageScopeInterface; +use OpenTelemetry\SDK\Trace\Span; use OpenTelemetry\SemConv\TraceAttributes; use OpenTelemetry\API\Common\Time\Clock; use OpenTelemetry\SemConv\Version; use OpenTelemetry\Context\ContextInterface; +use Throwable; use WeakReference; /** - * @phpstan-type ExtendedStackTraceFrame array{function: string, line?: int, file?: string, class?: class-string, type?: '->'|'::', span: \WeakReference<\OpenTelemetry\API\Trace\SpanInterface>, - * context: \WeakReference<\OpenTelemetry\Context\ContextInterface>, scope: \WeakReference<\OpenTelemetry\Context\ContextStorageScopeInterface>, stackTraceId: int } + * @phpstan-type StackTraceFrameCallType '->'|'::' + * @phpstan-type ExtendedStackTraceFrame array{function: string, line?: int, file?: string, class?: class-string, type?: StackTraceFrameCallType, span: WeakReference, + * context: WeakReference, scope: WeakReference, stackTraceId: int} * @phpstan-type ExtendedStackTrace array - * @phpstan-type DebugBackTraceFrame array{function: string, line?: int, file?: string, class?: class-string, type?: '->'|'::', args?: array, object?: object} - * @phpstan-type DebugBackTrace array, DebugBackTraceFrame> + * @phpstan-type DebugBackTraceFrame array{function: string, line?: int, file?: string, class?: class-string, type?: StackTraceFrameCallType, args?: array, object?: object} + * @phpstan-type DebugBackTrace array */ class InferredSpans { @@ -75,7 +82,7 @@ public function __construct(private readonly bool $spanReductionEnabled, private $this->shutdown = false; } - // $durationMs - duration between interrupt request and interrupt occurence + // $durationMs - duration between interrupt request and interrupt occurrence public function captureStackTrace(int $durationMs, bool $topFrameIsInternalFunction): void { self::logDebug("captureStackTrace topFrameInternal: $topFrameIsInternalFunction, duration: $durationMs ms shutdown: " . $this->shutdown); @@ -92,7 +99,7 @@ public function captureStackTrace(int $durationMs, bool $topFrameIsInternalFunct $apmFramesFilteredOutCount = $this->filterOutAPMFrames($stackTrace); $this->compareStackTraces($stackTrace, $durationMs, $topFrameIsInternalFunction, $apmFramesFilteredOutCount); - } catch (\Throwable $throwable) { + } catch (Throwable $throwable) { self::logError($throwable->__toString()); } } @@ -124,7 +131,7 @@ private function compareStackTraces(array $stackTrace, int $durationMs, bool $to for ($index = 0; $index < $oldFramesCount; $index++) { $endEpochNanos = null; - // if last frame was internal function, so duraton contains it's time, previous ones ended between sampling interval - they're shorter + // if last frame was internal function, so duration contains it's time, previous ones ended between sampling interval - they're shorter if ($topFrameIsInternalFunction) { $endEpochNanos = $this->getStartTime($durationMs); } @@ -167,7 +174,8 @@ private function compareStackTraces(array $stackTrace, int $durationMs, bool $to } if ($index == 0 && $topFrameIsInternalFunction) { - $this->endFrameSpan($newFrame, false, null); // we don't need to save newest internal frame, it ended + /** @noinspection PhpRedundantOptionalArgumentInspection */ + $this->endFrameSpan($newFrame, false, null); // we don't need to save the newest internal frame, it ended } else { array_unshift($this->lastStackTrace, $newFrame); // push-copy frame in front of last stack trace for next interruption processing } @@ -188,7 +196,6 @@ private function filterOutAPMFrames(array &$stackTrace): ?int // Filter out Elastic and Otel stack frames $cutIndex = null; for ($index = count($stackTrace) - 1; $index >= 0; $index--) { - /** @var DebugBackTraceFrame $frame */ $frame = $stackTrace[$index]; if ( array_key_exists('class', $frame) && @@ -209,11 +216,13 @@ private function filterOutAPMFrames(array &$stackTrace): ?int /** @param DebugBackTrace $stackTrace */ private function getHowManyStackFramesAreIdenticalFromStackBottom(array $stackTrace): int { - // Helper function to check if two frames are identical - /** @param DebugBackTraceFrame $frame1 - * @param DebugBackTraceFrame $frame2 + /** + * Helper function to check if two frames are identical + * + * @phpstan-param DebugBackTraceFrame $frame1 + * @phpstan-param DebugBackTraceFrame $frame2 */ - $isSameFrame = function (array $frame1, array $frame2) { + $isSameFrame = function (array $frame1, array $frame2): bool { $keysToCompare = ['class', 'function', 'file', 'line', 'type']; foreach ($keysToCompare as $key) { if (($frame1[$key] ?? null) !== ($frame2[$key] ?? null)) { @@ -232,7 +241,7 @@ private function getHowManyStackFramesAreIdenticalFromStackBottom(array $stackTr $stFrame = &$stackTrace[$stackTraceCount - $index]; $lastStFrame = &$this->lastStackTrace[$lastStackTraceCount - $index]; - if ($isSameFrame($stFrame, $lastStFrame) == false) { + if (!$isSameFrame($stFrame, $lastStFrame)) { return $index - 1; } } @@ -256,7 +265,7 @@ private function shouldReduceFrame(int $index, int $oldFramesCount, int &$previo } $span = $this->lastStackTrace[$i][self::METADATA_SPAN]->get(); - if (! $span instanceof \OpenTelemetry\SDK\Trace\Span) { + if (!$span instanceof Span) { break; } @@ -265,7 +274,7 @@ private function shouldReduceFrame(int $index, int $oldFramesCount, int &$previo if ($lastSpanParent) { $span = $this->lastStackTrace[$index][self::METADATA_SPAN]->get(); - if (! $span instanceof \OpenTelemetry\SDK\Trace\Span) { + if (!$span instanceof Span) { return false; } @@ -274,9 +283,6 @@ private function shouldReduceFrame(int $index, int $oldFramesCount, int &$previo ['new', $lastSpanParent, 'old', $span->getParentContext()] ); - /** @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection - * @phpstan-ignore function.notFound - */ $forceParentChangeFailed = !force_set_object_property_value($span, "parentSpanContext", $lastSpanParent); } } @@ -289,7 +295,7 @@ private function shouldReduceFrame(int $index, int $oldFramesCount, int &$previo } /** - * @param ExtendedStackTraceFrame $frame + * @phpstan-param ExtendedStackTraceFrame $frame */ private function shouldDropTooShortSpan(array $frame, ?int $endEpochNanos = null): bool { @@ -298,18 +304,11 @@ private function shouldDropTooShortSpan(array $frame, ?int $endEpochNanos = null } $span = $frame[self::METADATA_SPAN]->get(); - if (! $span instanceof \OpenTelemetry\SDK\Trace\Span) { + if (!$span instanceof Span) { return false; } - /** @var int */ - $duration = 0; - if ($endEpochNanos) { - $duration = $endEpochNanos - $span->getStartEpochNanos(); - } else { - $duration = $span->getDuration(); - } - + $duration = $endEpochNanos ? ($endEpochNanos - $span->getStartEpochNanos()) : $span->getDuration(); if ($duration < $this->minSpanDuration * self::MILLIS_TO_NANOS) { self::logDebug('Span ' . $span->getName() . ' duration ' . intval($duration / self::MILLIS_TO_NANOS) . 'ms is too short to fit within the minimum span duration limit: ' . $this->minSpanDuration . 'ms'); @@ -321,7 +320,7 @@ private function shouldDropTooShortSpan(array $frame, ?int $endEpochNanos = null /** * @param ExtendedStackTrace $stackTrace */ - private function getStackTrace($stackTrace): string + private function getStackTrace(array $stackTrace): string { $str = "#0 {main}\n"; $id = 1; @@ -339,26 +338,26 @@ private function getStackTrace($stackTrace): string } /** - * @param DebugBackTraceFrame $frame - * @return ExtendedStackTraceFrame - */ + * @phpstan-param DebugBackTraceFrame $frame + * + * @phpstan-return ExtendedStackTraceFrame + */ private function startFrameSpan(array $frame, int $durationMs, ?ContextInterface $parentContext, int $stackTraceId): array { - $parent = $parentContext ? $parentContext : Context::getCurrent(); + $parent = $parentContext ?? Context::getCurrent(); $builder = $this->tracer->spanBuilder(!empty($frame['function']) ? $frame['function'] : '[unknown]') ->setParent($parent) ->setStartTimestamp($this->getStartTime($durationMs)) ->setSpanKind(SpanKind::KIND_INTERNAL) - ->setAttribute(TraceAttributes::CODE_FUNCTION, $frame['function']) + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, $frame['function']) ->setAttribute(TraceAttributes::CODE_FILEPATH, $frame['file'] ?? null) - ->setAttribute(TraceAttributes::CODE_LINENO, $frame['line'] ?? null) + ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $frame['line'] ?? null) ->setAttribute('is_inferred', true); $span = $builder->startSpan(); //OpenTelemetry\API\Trace\SpanInterface $context = $span->storeInContext($parent); //OpenTelemetry\Context\ContextInterface $scope = Context::storage()->attach($context); //OpenTelemetry\Context\ContextStorageScopeInterface - /* @var ExtendedStackTraceFrame $newFrame */ $newFrame = $frame; $newFrame[self::METADATA_SPAN] = WeakReference::create($span); $newFrame[self::METADATA_CONTEXT] = WeakReference::create($context); @@ -370,12 +369,11 @@ private function startFrameSpan(array $frame, int $durationMs, ?ContextInterface } /** - * @param ExtendedStackTraceFrame $frame - */ + * @phpstan-param ExtendedStackTraceFrame $frame + */ private function endFrameSpan(array $frame, bool $dropSpan, ?int $endEpochNanos = null): void { - /** @phpstan-ignore-next-line */ - if (!array_key_exists(self::METADATA_SPAN, $frame)) { + if (!array_key_exists(self::METADATA_SPAN, $frame)) { // @phpstan-ignore function.alreadyNarrowedType self::logError("endFrameSpan missing metadata.", [$frame]); return; } @@ -385,7 +383,7 @@ private function endFrameSpan(array $frame, bool $dropSpan, ?int $endEpochNanos } $span = $frame[self::METADATA_SPAN]->get(); - if (! $span instanceof \OpenTelemetry\SDK\Trace\Span) { + if (!$span instanceof Span) { self::logDebug("Span in frame is not instanceof Trace\Span", [$span, $frame]); return; } diff --git a/prod/php/ElasticOTel/InstrumentationBridge.php b/prod/php/ElasticOTel/InstrumentationBridge.php index 25d0a6c..d4c408c 100644 --- a/prod/php/ElasticOTel/InstrumentationBridge.php +++ b/prod/php/ElasticOTel/InstrumentationBridge.php @@ -24,14 +24,24 @@ namespace Elastic\OTel; use Closure; +use Elastic\OTel\Log\LogLevel; use Throwable; use Elastic\OTel\Util\SingletonInstanceTrait; +use function elastic_otel_get_config_option_by_name; +use function elastic_otel_hook; +use function elastic_otel_log_feature; + /** * Code in this file is part of implementation internals, and thus it is not covered by the backward compatibility. * * @internal * + * @phpstan-type PreHook Closure(?object $thisObj, array $params, string $class, string $function, ?string $filename, ?int $lineno): (void|array) + * return value is modified parameters + * + * @phpstan-type PostHook Closure(?object $thisObj, array $params, mixed $returnValue, ?Throwable $throwable): mixed + * return value is modified return value */ final class InstrumentationBridge { @@ -49,15 +59,19 @@ final class InstrumentationBridge public function bootstrap(): void { - self::elasticOTelHook(null, 'spl_autoload_register', null, Closure::fromCallable([$this, 'retryDelayedHooks'])); + self::elasticOTelHook(null, 'spl_autoload_register', null, $this->retryDelayedHooks(...)); require ProdPhpDir::$fullPath . DIRECTORY_SEPARATOR . 'OpenTelemetry' . DIRECTORY_SEPARATOR . 'Instrumentation' . DIRECTORY_SEPARATOR . 'hook.php'; - $this->enableDebugHooks = (bool)\elastic_otel_get_config_option_by_name('debug_php_hooks_enabled'); // @phpstan-ignore function.notFound + $this->enableDebugHooks = (bool)elastic_otel_get_config_option_by_name('debug_php_hooks_enabled'); BootstrapStageLogger::logDebug('Finished successfully', __FILE__, __LINE__, __CLASS__, __FUNCTION__); } + /** + * @phpstan-param PreHook $pre + * @phpstan-param PostHook $post + */ public function hook(?string $class, string $function, ?Closure $pre = null, ?Closure $post = null): bool { BootstrapStageLogger::logTrace('Entered. class: ' . $class . ' function: ' . $function, __FILE__, __LINE__, __CLASS__, __FUNCTION__); @@ -76,6 +90,10 @@ public function hook(?string $class, string $function, ?Closure $pre = null, ?Cl return $success; } + /** + * @phpstan-param PreHook $pre + * @phpstan-param PostHook $post + */ private function addToDelayedHooks(string $class, string $function, ?Closure $pre = null, ?Closure $post = null): void { BootstrapStageLogger::logTrace('Adding to delayed hooks. class: ' . $class . ', function: ' . $function, __FILE__, __LINE__, __CLASS__, __FUNCTION__); @@ -83,17 +101,17 @@ private function addToDelayedHooks(string $class, string $function, ?Closure $pr $this->delayedHooks[] = [$class, $function, $pre, $post]; } + /** + * @phpstan-param PreHook $pre + * @phpstan-param PostHook $post + */ private static function elasticOTelHook(?string $class, string $function, ?Closure $pre = null, ?Closure $post = null): void { $dbgClassAsString = BootstrapStageLogger::nullableToLog($class); BootstrapStageLogger::logTrace('Entered. class: ' . $dbgClassAsString . ', function: ' . $function, __FILE__, __LINE__, __CLASS__, __FUNCTION__); - /** - * \elastic_otel_* functions are provided by the extension - * - * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection - */ - $retVal = \elastic_otel_hook($class, $function, $pre, $post); // @phpstan-ignore function.notFound + // elastic_otel_hook function is provided by the extension + $retVal = elastic_otel_hook($class, $function, $pre, $post); if ($retVal) { BootstrapStageLogger::logTrace('Successfully hooked. class: ' . $dbgClassAsString . ', function: ' . $function, __FILE__, __LINE__, __CLASS__, __FUNCTION__); return; @@ -102,6 +120,10 @@ private static function elasticOTelHook(?string $class, string $function, ?Closu BootstrapStageLogger::logDebug('elastic_otel_hook returned false: ' . $dbgClassAsString . ', function: ' . $function, __FILE__, __LINE__, __CLASS__, __FUNCTION__); } + /** + * @phpstan-param PreHook $pre + * @phpstan-param PostHook $post + */ private static function elasticOTelHookNoThrow(?string $class, string $function, ?Closure $pre = null, ?Closure $post = null): bool { try { @@ -151,38 +173,33 @@ private static function placeDebugHooks(?string $class, string $function): void } $func .= $function . '\''; - self::elasticOTelHookNoThrow($class, $function, function () use ($func) { - /** - * elastic_otel_* functions are provided by the extension - * - * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection - */ - \elastic_otel_log_feature( // @phpstan-ignore function.notFound - 0, - Log\Level::DEBUG, - Log\LogFeature::INSTRUMENTATION, - 'PRE HOOK', - '', - null, - $func, - ('pre-hook data: ' . var_export(func_get_args(), true)) - ); - }, function () use ($func) { - /** - * elastic_otel_* functions are provided by the extension - * - * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection - */ - \elastic_otel_log_feature( // @phpstan-ignore function.notFound - 0, - Log\Level::DEBUG, - Log\LogFeature::INSTRUMENTATION, - 'POST HOOK', - '', - null, - $func, - ('post-hook data: ' . var_export(func_get_args(), true)) - ); - }); + self::elasticOTelHookNoThrow( + $class, + $function, + function () use ($func) { + elastic_otel_log_feature( + 0, + LogLevel::debug->value, + Log\LogFeature::INSTRUMENTATION, + 'PRE HOOK', + '', + null, + $func, + ('pre-hook data: ' . var_export(func_get_args(), true)) + ); + }, + function () use ($func) { + elastic_otel_log_feature( + 0, + LogLevel::debug->value, + Log\LogFeature::INSTRUMENTATION, + 'POST HOOK', + '', + null, + $func, + ('post-hook data: ' . var_export(func_get_args(), true)) + ); + } + ); } } diff --git a/prod/php/ElasticOTel/Log/ElasticLogWriter.php b/prod/php/ElasticOTel/Log/ElasticLogWriter.php index be2b7bb..8926dfd 100644 --- a/prod/php/ElasticOTel/Log/ElasticLogWriter.php +++ b/prod/php/ElasticOTel/Log/ElasticLogWriter.php @@ -19,6 +19,8 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel\Log; @@ -41,25 +43,20 @@ public function __construct() */ public function write(mixed $level, string $message, array $context): void { - $edotLevel = is_string($level) ? Level::getFromPsrLevel($level) : Level::OFF; + $edotLevel = is_string($level) ? (LogLevel::fromPsrLevel($level) ?? LogLevel::off) : LogLevel::off; $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4)[3]; $func = ($caller['class'] ?? '') . ($caller['type'] ?? '') . $caller['function']; $logContext = $this->attachLogContext ? (' context: ' . var_export($context, true)) : ''; - /** - * elastic_otel_* functions are provided by the extension - * - * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection - */ - \elastic_otel_log_feature( // @phpstan-ignore function.notFound - 0 /* isForced */, - $edotLevel, + elastic_otel_log_feature( + 0 /* <- isForced */, + $edotLevel->value, LogFeature::OTEL, - 'OpenTelemetry', + 'OpenTelemetry' /* <- category */, $caller['file'] ?? '', - $caller['line'] ?? '', + $caller['line'] ?? null, $func, $message . $logContext ); diff --git a/prod/php/ElasticOTel/Log/Level.php b/prod/php/ElasticOTel/Log/Level.php deleted file mode 100644 index c435473..0000000 --- a/prod/php/ElasticOTel/Log/Level.php +++ /dev/null @@ -1,106 +0,0 @@ - */ - private static array $nameIntPairs - = [ - ['OFF', Level::OFF], - ['CRITICAL', Level::CRITICAL], - ['ERROR', Level::ERROR], - ['WARNING', Level::WARNING], - ['INFO', Level::INFO], - ['DEBUG', Level::DEBUG], - ['TRACE', Level::TRACE], - ]; - - /** @var ?array */ - private static ?array $intToName = null; - - /** - * @return array - * - * @noinspection PhpUnused - */ - public static function nameIntPairs(): array - { - return self::$nameIntPairs; - } - - /** - * @noinspection PhpUnused - */ - public static function intToName(int $intValueToMap): string - { - if (self::$intToName === null) { - self::$intToName = []; - foreach (self::$nameIntPairs as $nameIntPair) { - self::$intToName[$nameIntPair[1]] = $nameIntPair[0]; - } - } - return self::$intToName[$intValueToMap]; - } - - /** - * @noinspection PhpUnused - */ - public static function getHighest(): int - { - return self::TRACE; - } - - /** - * @noinspection PhpFullyQualifiedNameUsageInspection - */ - public static function getFromPsrLevel(string $level): int - { - return match ($level) { - \Psr\Log\LogLevel::EMERGENCY, \Psr\Log\LogLevel::ALERT, \Psr\Log\LogLevel::CRITICAL => Level::CRITICAL, - \Psr\Log\LogLevel::ERROR => Level::ERROR, - \Psr\Log\LogLevel::WARNING => Level::WARNING, - \Psr\Log\LogLevel::NOTICE, \Psr\Log\LogLevel::INFO => Level::INFO, - \Psr\Log\LogLevel::DEBUG => Level::DEBUG, - default => Level::OFF, - }; - } -} diff --git a/prod/php/ElasticOTel/Log/LogFeature.php.template b/prod/php/ElasticOTel/Log/LogFeature.php.template index d849028..8fe8518 100644 --- a/prod/php/ElasticOTel/Log/LogFeature.php.template +++ b/prod/php/ElasticOTel/Log/LogFeature.php.template @@ -25,6 +25,7 @@ declare(strict_types=1); namespace Elastic\OTel\Log; -final class LogFeature { +final class LogFeature +{ @_PROJECT_PROPERTIES_LOGGER_FEATURES_ENUM_VALUES@ } diff --git a/prod/php/ElasticOTel/Log/LogLevel.php b/prod/php/ElasticOTel/Log/LogLevel.php new file mode 100644 index 0000000..8e4e4b3 --- /dev/null +++ b/prod/php/ElasticOTel/Log/LogLevel.php @@ -0,0 +1,58 @@ + self::critical, + \Psr\Log\LogLevel::ERROR => self::error, + \Psr\Log\LogLevel::WARNING => self::warning, + \Psr\Log\LogLevel::NOTICE, \Psr\Log\LogLevel::INFO => self::info, + \Psr\Log\LogLevel::DEBUG => self::debug, + default => null, + }; + } +} diff --git a/prod/php/ElasticOTel/PhpPartFacade.php b/prod/php/ElasticOTel/PhpPartFacade.php index cccfd35..a7f80d5 100644 --- a/prod/php/ElasticOTel/PhpPartFacade.php +++ b/prod/php/ElasticOTel/PhpPartFacade.php @@ -19,6 +19,8 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel; @@ -28,6 +30,7 @@ use Elastic\OTel\Log\ElasticLogWriter; use Elastic\OTel\Util\HiddenConstructorTrait; use OpenTelemetry\API\Globals; +use OpenTelemetry\SDK\Registry; use OpenTelemetry\SDK\SdkAutoloader; use OpenTelemetry\API\Trace\Span; use OpenTelemetry\API\Trace\SpanKind; @@ -38,14 +41,14 @@ use RuntimeException; use Throwable; +use function elastic_otel_get_config_option_by_name; + /** * Code in this file is part of implementation internals, and thus it is not covered by the backward compatibility. * * @internal * * Called by the extension - * - * @noinspection PhpUnused, PhpMultipleClassDeclarationsInspection */ final class PhpPartFacade { @@ -54,10 +57,26 @@ final class PhpPartFacade */ use HiddenConstructorTrait; + public static bool $wasBootstrapCalled = false; + private static ?self $singletonInstance = null; private static bool $rootSpanEnded = false; private ?InferredSpans $inferredSpans = null; + public const CONFIG_ENV_VAR_NAME_DEV_INTERNAL_MODE_IS_DEV = 'ELASTIC_OTEL_PHP_DEV_INTERNAL_MODE_IS_DEV'; + + /** + * We need to use TELEMETRY_DISTRO_NAME and TELEMETRY_DISTRO_VERSION attribute names before OTel SDK is loaded by composer + * so we copy those values to local constants + * + * @see \OpenTelemetry\SemConv\TraceAttributes::TELEMETRY_DISTRO_NAME + * @see \OpenTelemetry\SemConv\TraceAttributes::TELEMETRY_DISTRO_VERSION + * + * @noinspection PhpUnnecessaryFullyQualifiedNameInspection + */ + public const OTEL_ATTR_NAME_TELEMETRY_DISTRO_NAME = 'telemetry.distro.name'; + public const OTEL_ATTR_NAME_TELEMETRY_DISTRO_VERSION = 'telemetry.distro.version'; + /** * Called by the extension * @@ -69,6 +88,8 @@ final class PhpPartFacade */ public static function bootstrap(string $elasticOTelNativePartVersion, int $maxEnabledLogLevel, float $requestInitStartTime): bool { + self::$wasBootstrapCalled = true; + require __DIR__ . DIRECTORY_SEPARATOR . 'BootstrapStageLogger.php'; BootstrapStageLogger::configure($maxEnabledLogLevel, __DIR__, __NAMESPACE__); @@ -117,15 +138,11 @@ public static function bootstrap(string $elasticOTelNativePartVersion, int $maxE self::$singletonInstance = new self(); - /** - * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection - * @phpstan-ignore function.notFound - */ - if (\elastic_otel_get_config_option_by_name('inferred_spans_enabled')) { + if (elastic_otel_get_config_option_by_name('inferred_spans_enabled')) { self::$singletonInstance->inferredSpans = new InferredSpans( - (bool)\elastic_otel_get_config_option_by_name('inferred_spans_reduction_enabled'), // @phpstan-ignore-line - (bool)\elastic_otel_get_config_option_by_name('inferred_spans_stacktrace_enabled'), // @phpstan-ignore-line - \elastic_otel_get_config_option_by_name('inferred_spans_min_duration') // @phpstan-ignore-line + (bool)elastic_otel_get_config_option_by_name('inferred_spans_reduction_enabled'), + (bool)elastic_otel_get_config_option_by_name('inferred_spans_stacktrace_enabled'), + elastic_otel_get_config_option_by_name('inferred_spans_min_duration') // @phpstan-ignore argument.type ); } } catch (Throwable $throwable) { @@ -137,15 +154,20 @@ public static function bootstrap(string $elasticOTelNativePartVersion, int $maxE return true; } + /** + * Called by the extension + * + * @noinspection PhpUnused + */ public static function inferredSpans(int $durationMs, bool $internalFunction): bool { if (self::$singletonInstance === null) { - BootstrapStageLogger::logDebug('Missig facade', __FILE__, __LINE__, __CLASS__, __FUNCTION__); + BootstrapStageLogger::logDebug('Missing facade', __FILE__, __LINE__, __CLASS__, __FUNCTION__); return true; } if (self::$singletonInstance->inferredSpans === null) { - BootstrapStageLogger::logDebug('Missig inferred spans instance', __FILE__, __LINE__, __CLASS__, __FUNCTION__); + BootstrapStageLogger::logDebug('Missing inferred spans instance', __FILE__, __LINE__, __CLASS__, __FUNCTION__); return true; } self::$singletonInstance->inferredSpans->captureStackTrace($durationMs, $internalFunction); @@ -171,7 +193,7 @@ private static function buildElasticOTelVersion(string $nativePartVersion): stri private static function isInDevMode(): bool { - $modeIsDevEnvVarVal = getenv('ELASTIC_OTEL_PHP_DEV_INTERNAL_MODE_IS_DEV'); + $modeIsDevEnvVarVal = getenv(self::CONFIG_ENV_VAR_NAME_DEV_INTERNAL_MODE_IS_DEV); if (is_string($modeIsDevEnvVarVal)) { /** * @var string[] $trueStringValues @@ -194,11 +216,17 @@ private static function prepareEnvForOTelAttributes(string $elasticOTelNativePar $envVarValue = (is_string($envVarValueOnEntry) && strlen($envVarValueOnEntry) !== 0) ? ($envVarValueOnEntry . ',') : ''; // https://opentelemetry.io/docs/specs/semconv/resource/#telemetry-distribution-experimental - $envVarValue .= 'telemetry.distro.name=elastic,telemetry.distro.version=' . self::buildElasticOTelVersion($elasticOTelNativePartVersion); + $envVarValue .= + self::OTEL_ATTR_NAME_TELEMETRY_DISTRO_NAME . '=elastic' + . ',' + . self::OTEL_ATTR_NAME_TELEMETRY_DISTRO_VERSION . '=' . self::buildElasticOTelVersion($elasticOTelNativePartVersion); self::setEnvVar($envVarName, $envVarValue); } + /** + * @param non-empty-string $envVarName + */ private static function setEnvVar(string $envVarName, string $envVarValue): void { if (!putenv($envVarName . '=' . $envVarValue)) { @@ -214,7 +242,11 @@ private static function prepareEnvForOTelSdk(string $elasticOTelNativePartVersio private static function registerAutoloader(): void { - $vendorDir = ProdPhpDir::$fullPath . '/vendor' . (self::isInDevMode() ? '' : '_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION); + $vendorDir = ProdPhpDir::$fullPath . DIRECTORY_SEPARATOR . ( + self::isInDevMode() + ? ('..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'vendor') + : ('vendor_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION) + ); $vendorAutoloadPhp = $vendorDir . '/autoload.php'; if (!file_exists($vendorAutoloadPhp)) { throw new RuntimeException("File $vendorAutoloadPhp does not exist"); @@ -227,18 +259,12 @@ private static function registerAutoloader(): void private static function registerAsyncTransportFactory(): void { - /** - * elastic_otel_* functions are provided by the extension - * - * @noinspection PhpFullyQualifiedNameUsageInspection, PhpUndefinedFunctionInspection - */ - if (\elastic_otel_get_config_option_by_name('async_transport') === false) { // @phpstan-ignore function.notFound + if (elastic_otel_get_config_option_by_name('async_transport') === false) { BootstrapStageLogger::logDebug('ELASTIC_OTEL_ASYNC_TRANSPORT set to false', __FILE__, __LINE__, __CLASS__, __FUNCTION__); return; } - /** @noinspection PhpFullyQualifiedNameUsageInspection */ - \OpenTelemetry\SDK\Registry::registerTransportFactory('http', ElasticHttpTransportFactory::class, true); + Registry::registerTransportFactory('http', ElasticHttpTransportFactory::class, true); } private static function registerOtelLogWriter(): void @@ -272,7 +298,13 @@ public static function shutdown(): void self::$singletonInstance = null; } - /** @phpstan-ignore-next-line */ + /** + * Called by the extension + * + * @param array $params + * + * @noinspection PhpUnused, PhpUnusedParameterInspection + */ public static function debugPreHook(mixed $object, array $params, ?string $class, string $function, ?string $filename, ?int $lineno): void { if (self::$rootSpanEnded) { @@ -286,22 +318,28 @@ public static function debugPreHook(mixed $object, array $params, ?string $class ); $parent = Context::getCurrent(); - /** @phpstan-ignore-next-line */ - $span = $tracer->spanBuilder($class ? $class . "::" . $function : $function) - ->setSpanKind(SpanKind::KIND_CLIENT) - ->setParent($parent) - ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) - ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) - ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) - ->setAttribute(TraceAttributes::CODE_LINENO, $lineno) - ->setAttribute('call.arguments', print_r($params, true)) - ->startSpan(); + /** @noinspection PhpDeprecationInspection */ + $span = $tracer->spanBuilder($class ? $class . "::" . $function : $function) // @phpstan-ignore argument.type + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setParent($parent) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINENO, $lineno) + ->setAttribute('call.arguments', print_r($params, true)) + ->startSpan(); $context = $span->storeInContext($parent); Context::storage()->attach($context); } - /** @phpstan-ignore-next-line */ + /** + * Called by the extension + * + * @param array $params + * + * @noinspection PhpUnused, PhpUnusedParameterInspection + */ public static function debugPostHook(mixed $object, array $params, mixed $retval, ?Throwable $exception): void { if (self::$rootSpanEnded) { @@ -318,6 +356,7 @@ public static function debugPostHook(mixed $object, array $params, mixed $retval $span->setAttribute('call.return_value', print_r($retval, true)); if ($exception) { + /** @noinspection PhpDeprecationInspection */ $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]); $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); } diff --git a/prod/php/ElasticOTel/Traces/ElasticRootSpan.php b/prod/php/ElasticOTel/Traces/ElasticRootSpan.php index e94ac27..7e66e79 100644 --- a/prod/php/ElasticOTel/Traces/ElasticRootSpan.php +++ b/prod/php/ElasticOTel/Traces/ElasticRootSpan.php @@ -19,6 +19,8 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel\Traces; diff --git a/prod/php/ElasticOTel/Util/ArrayUtil.php b/prod/php/ElasticOTel/Util/ArrayUtil.php index 8076479..0156c15 100644 --- a/prod/php/ElasticOTel/Util/ArrayUtil.php +++ b/prod/php/ElasticOTel/Util/ArrayUtil.php @@ -19,6 +19,8 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel\Util; @@ -28,17 +30,61 @@ final class ArrayUtil use StaticClassTrait; /** - * @template T - * @param array $array - * @param-out T $valOut + * @template TKey of array-key + * @template TValue + * + * @phpstan-param TKey $key + * @phpstan-param array $array + * + * @param-out TValue $valueOut + * + * @phpstan-assert-if-true TValue $valueOut + */ + public static function getValueIfKeyExists(int|string $key, array $array, /* out */ mixed &$valueOut): bool + { + if (!array_key_exists($key, $array)) { + return false; + } + + $valueOut = $array[$key]; + return true; + } + + /** + * @template TKey of array-key + * @template TArrayValue + * @template TFallbackValue + * + * @phpstan-param TKey $key + * @phpstan-param array $array + * @phpstan-param TFallbackValue $fallbackValue + * + * @return TArrayValue|TFallbackValue + */ + public static function getValueIfKeyExistsElse(string|int $key, array $array, mixed $fallbackValue): mixed + { + return array_key_exists($key, $array) ? $array[$key] : $fallbackValue; + } + + /** + * @template TKey of array-key + * @template TValue + * + * @phpstan-param TKey $key + * @phpstan-param array $array + * + * @param-out TValue $valueOut + * + * @phpstan-assert-if-true TValue $valueOut */ - public static function getValueIfKeyExists(int|string $key, array $array, /* out */ mixed &$valOut): bool + public static function removeValue(int|string $key, array $array, /* out */ mixed &$valueOut): bool { if (!array_key_exists($key, $array)) { return false; } - $valOut = $array[$key]; + $valueOut = $array[$key]; + unset($array[$key]); return true; } } diff --git a/prod/php/ElasticOTel/Util/EnumUtilTrait.php b/prod/php/ElasticOTel/Util/EnumUtilTrait.php new file mode 100644 index 0000000..11f9cbe --- /dev/null +++ b/prod/php/ElasticOTel/Util/EnumUtilTrait.php @@ -0,0 +1,49 @@ + $mapByName */ + static $mapByName = null; + + if ($mapByName === null) { + $mapByName = []; + foreach (self::cases() as $enumCase) { + $mapByName[$enumCase->name] = $enumCase; + } + } + + return ArrayUtil::getValueIfKeyExistsElse($enumName, $mapByName, fallbackValue: null); + } +} diff --git a/prod/php/ElasticOTel/Util/NumericUtil.php b/prod/php/ElasticOTel/Util/NumericUtil.php index b0e04aa..d8e8e44 100644 --- a/prod/php/ElasticOTel/Util/NumericUtil.php +++ b/prod/php/ElasticOTel/Util/NumericUtil.php @@ -19,6 +19,8 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel\Util; @@ -34,62 +36,8 @@ final class NumericUtil * * @return bool */ - public static function isInClosedInterval($intervalLeft, $x, $intervalRight): bool + public static function isInClosedInterval(float|int $intervalLeft, float|int $x, float|int $intervalRight): bool { return ($intervalLeft <= $x) && ($x <= $intervalRight); } - - /** - * @param mixed $intervalLeft - * @param mixed $x - * @param mixed $intervalRight - * - * @return bool - * - * @template T - * @phpstan-param T $intervalLeft - * @phpstan-param T $x - * @phpstan-param T $intervalRight - * - */ - public static function isInOpenInterval($intervalLeft, $x, $intervalRight): bool - { - return ($intervalLeft < $x) && ($x < $intervalRight); - } - - /** - * @param mixed $intervalLeft - * @param mixed $x - * @param mixed $intervalRight - * - * @return bool - * - * @template T - * @phpstan-param T $intervalLeft - * @phpstan-param T $x - * @phpstan-param T $intervalRight - * - */ - public static function isInRightOpenInterval($intervalLeft, $x, $intervalRight): bool - { - return ($intervalLeft <= $x) && ($x < $intervalRight); - } - - /** - * @param mixed $intervalLeft - * @param mixed $x - * @param mixed $intervalRight - * - * @return bool - * - * @template T - * @phpstan-param T $intervalLeft - * @phpstan-param T $x - * @phpstan-param T $intervalRight - * - */ - public static function isInLeftOpenInterval($intervalLeft, $x, $intervalRight): bool - { - return ($intervalLeft < $x) && ($x <= $intervalRight); - } } diff --git a/prod/php/ElasticOTel/Util/SingletonInstanceTrait.php b/prod/php/ElasticOTel/Util/SingletonInstanceTrait.php index 9f2eecc..f20e2a0 100644 --- a/prod/php/ElasticOTel/Util/SingletonInstanceTrait.php +++ b/prod/php/ElasticOTel/Util/SingletonInstanceTrait.php @@ -19,6 +19,8 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel\Util; @@ -40,7 +42,7 @@ trait SingletonInstanceTrait public static function singletonInstance(): self { if (self::$singletonInstance === null) { - self::$singletonInstance = new static(); // @phpstan-ignore-line + self::$singletonInstance = new static(); } return self::$singletonInstance; } diff --git a/prod/php/ElasticOTel/Util/TextUtil.php b/prod/php/ElasticOTel/Util/TextUtil.php index b8edee0..56271a8 100644 --- a/prod/php/ElasticOTel/Util/TextUtil.php +++ b/prod/php/ElasticOTel/Util/TextUtil.php @@ -19,6 +19,8 @@ * under the License. */ +/** @noinspection PhpIllegalPsrClassPathInspection */ + declare(strict_types=1); namespace Elastic\OTel\Util; @@ -27,6 +29,7 @@ final class TextUtil { use StaticClassTrait; + /** @noinspection PhpUnused */ public static function ensureMaxLength(string $text, int $maxLength): string { if (strlen($text) <= $maxLength) { @@ -43,6 +46,7 @@ public static function isEmptyString(string $str): bool return $str === ''; } + /** @noinspection PhpUnused */ public static function isNullOrEmptyString(?string $str): bool { return $str === null || self::isEmptyString($str); @@ -184,7 +188,6 @@ public static function isPrefixOf(string $prefix, string $text, bool $isCaseSens return true; } - /** @noinspection PhpStrictComparisonWithOperandsOfDifferentTypesInspection */ return substr_compare( $text /* <- haystack */, $prefix /* <- needle */, @@ -214,9 +217,4 @@ public static function isSuffixOf(string $suffix, string $text, bool $isCaseSens !$isCaseSensitive /* <- case_insensitivity */ ) == 0; } - - public static function contains(string $haystack, string $needle): bool - { - return strpos($haystack, $needle) !== false; - } } diff --git a/prod/php/ElasticOTel/Util/WildcardListMatcher.php b/prod/php/ElasticOTel/Util/WildcardListMatcher.php index 3ec7403..8c8e77a 100644 --- a/prod/php/ElasticOTel/Util/WildcardListMatcher.php +++ b/prod/php/ElasticOTel/Util/WildcardListMatcher.php @@ -32,7 +32,7 @@ final class WildcardListMatcher { /** @var WildcardMatcher[] */ - private $matchers; + private array $matchers; /** * @param iterable $wildcardExprs @@ -55,15 +55,6 @@ public function match(string $text): ?string return null; } - public static function matchNullable(?WildcardListMatcher $nullableMatcher, string $text): ?string - { - if ($nullableMatcher === null) { - return null; - } - - return $nullableMatcher->match($text); - } - public function __toString(): string { return implode(', ', $this->matchers); diff --git a/prod/php/ElasticOTel/Util/WildcardMatcher.php b/prod/php/ElasticOTel/Util/WildcardMatcher.php index 0c54086..2f54bab 100644 --- a/prod/php/ElasticOTel/Util/WildcardMatcher.php +++ b/prod/php/ElasticOTel/Util/WildcardMatcher.php @@ -33,24 +33,15 @@ final class WildcardMatcher private const CASE_SENSITIVE_PREFIX = '(?-i)'; private const WILDCARD = '*'; - /** @var int */ - private static $wildcardLen; - - /** @var bool */ - private $isCaseSensitive; - - /** @var bool */ - private $startsWithWildcard; - - /** @var bool */ - private $endsWithWildcard; - + private static int $wildcardLen; + private bool $isCaseSensitive; + private bool $startsWithWildcard; + private bool $endsWithWildcard; /** @var string[] */ - private $literalParts; + private array $literalParts; public function __construct(string $expr) { - /** @phpstan-ignore-next-line */ if (!isset(self::$wildcardLen)) { self::$wildcardLen = strlen(self::WILDCARD); } @@ -75,21 +66,13 @@ public function __construct(string $expr) $lastPartWasWildcard = false; $literalPartEndPos = ($nextWildcardPos === false) ? $exprLen : $nextWildcardPos; $literalPartLen = $literalPartEndPos - $exprPos; - $this->literalParts[] = substr($expr, /* offset */ $exprPos, /* length */ $literalPartLen); + $this->literalParts[] = substr($expr, offset: $exprPos, length: $literalPartLen); $exprPos += $literalPartLen; } $this->endsWithWildcard = $lastPartWasWildcard; } - /** - * @param string $haystack - * @param string $needle - * @param int $offset - * @param bool $isCaseSensitive - * - * @return false|int - */ - private static function findSubString(string $haystack, string $needle, int $offset, bool $isCaseSensitive) + private static function findSubString(string $haystack, string $needle, int $offset, bool $isCaseSensitive): false|int { return $isCaseSensitive ? strpos($haystack, $needle, $offset) : stripos($haystack, $needle, $offset); } diff --git a/prod/php/OpenTelemetry/Instrumentation/hook.php b/prod/php/OpenTelemetry/Instrumentation/hook.php index 955402c..21eac0a 100644 --- a/prod/php/OpenTelemetry/Instrumentation/hook.php +++ b/prod/php/OpenTelemetry/Instrumentation/hook.php @@ -29,30 +29,28 @@ use Closure; use Elastic\OTel\InstrumentationBridge; +use Throwable; /** * Code in this file is part of implementation internals, and thus it is not covered by the backward compatibility. * + * Called by OTel instrumentations + * * @internal * - * Called by OTel instrumentations + * @phpstan-param ?string $class The hooked function's class. Null for a global/built-in function. + * @phpstan-param string $function The hooked function's name. + * @phpstan-param ?(Closure(?object $thisObj, array $params, string $class, string $function, ?string $filename, ?int $lineno): (void|array)) $pre + * return value is modified parameters + * @phpstan-param ?(Closure(?object $thisObj, array $params, mixed $returnValue, ?Throwable $throwable): mixed) $post + * return value is modified return value * - * @noinspection PhpUnused, PhpInternalEntityUsedInspection - */ - -/** - * @param string|null $class The hooked function's class. Null for a global/built-in function. - * @param string $function The hooked function's name. - * @param Closure|null $pre function($class, array $params, string $class, string $function, ?string $filename, ?int $lineno): $params - * You may optionally return modified parameters. - * @param Closure|null $post function($class, array $params, $returnValue, ?Throwable $exception): $returnValue - * You may optionally return modified return value. * @return bool Whether the observer was successfully added * * @see https://github.com/open-telemetry/opentelemetry-php-instrumentation */ function hook( - string|null $class, + ?string $class, string $function, ?Closure $pre = null, ?Closure $post = null, diff --git a/tests/ElasticOTelTests/BootstrapTests.php b/tests/ElasticOTelTests/BootstrapTests.php new file mode 100644 index 0000000..c4dcf33 --- /dev/null +++ b/tests/ElasticOTelTests/BootstrapTests.php @@ -0,0 +1,72 @@ +validateForComponentTests(); + } + ); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/CurlAutoInstrumentationTest.php b/tests/ElasticOTelTests/ComponentTests/CurlAutoInstrumentationTest.php new file mode 100644 index 0000000..a5ac3df --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/CurlAutoInstrumentationTest.php @@ -0,0 +1,287 @@ + $suffixes + * + * @return array + */ + private static function genHeaders(iterable $suffixes): array + { + $result = []; + foreach ($suffixes as $suffix) { + $headerName = self::HTTP_REQUEST_HEADER_NAME_PREFIX . $suffix; + $result[$headerName] = 'Value_for_' . $headerName; + } + return $result; + } + + /** + * @param array $headers + * + * @return list + */ + private static function convertHeadersToCurlFormat(array $headers): array + { + $result = []; + foreach ($headers as $headerName => $headerValue) { + $result[] = $headerName . ': ' . $headerValue; + } + return $result; + } + + public static function appCodeServer(): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $dbgCtx->add(['$_SERVER' => IterableUtil::toMap(GlobalUnderscoreServer::getAll())]); + + $dbgCtx->add(['php_sapi_name()' => php_sapi_name()]); + self::assertNotEquals('cli', php_sapi_name()); + + self::assertSame(HttpMethods::GET, GlobalUnderscoreServer::requestMethod()); + + $expectedHeaders = self::genHeaders(RangeUtil::generateFromToIncluding(2, 3)); + foreach ($expectedHeaders as $expectedHeaderName => $expectedHeaderValue) { + $dbgCtx->add(compact('expectedHeaderName', 'expectedHeaderValue')); + self::assertSame($expectedHeaderValue, GlobalUnderscoreServer::getRequestHeaderValue($expectedHeaderName)); + } + + echo self::SERVER_RESPONSE_BODY; + http_response_code(self::SERVER_RESPONSE_HTTP_STATUS); + } + + public static function appCodeClient(MixedMap $appCodeArgs): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + self::assertTrue(extension_loaded('curl')); + + $enableCurlInstrumentationForClient = $appCodeArgs->getBool(self::ENABLE_CURL_INSTRUMENTATION_FOR_CLIENT_KEY); + if ($enableCurlInstrumentationForClient) { + self::assertTrue(class_exists(CurlInstrumentation::class, autoload: false)); + self::assertSame(CurlInstrumentation::NAME, self::CURL_INSTRUMENTATION_NAME); // @phpstan-ignore staticMethod.alreadyNarrowedType + } + + $requestParams = $appCodeArgs->getObject(self::HTTP_APP_CODE_REQUEST_PARAMS_FOR_SERVER_KEY, HttpAppCodeRequestParams::class); + $resourcesClient = $appCodeArgs->getObject(self::RESOURCES_CLIENT_KEY, ResourcesClient::class); + + $curlHandleRaw = curl_init(UrlUtil::buildFullUrl($requestParams->urlParts)); + self::assertInstanceOf(CurlHandle::class, $curlHandleRaw); + $curlHandle = new CurlHandleForTests($curlHandleRaw, $resourcesClient); + + self::assertTrue($curlHandle->setOpt(CURLOPT_CONNECTTIMEOUT, HttpClientUtilForTests::CONNECT_TIMEOUT_SECONDS)); + self::assertTrue($curlHandle->setOpt(CURLOPT_TIMEOUT, HttpClientUtilForTests::TIMEOUT_SECONDS)); + + $dataPerRequestHeaderName = RequestHeadersRawSnapshotSource::optionNameToHeaderName(OptionForTestsName::data_per_request->name); + $dataPerRequestHeaderValue = PhpSerializationUtil::serializeToString($requestParams->dataPerRequest); + + $notFinalHeaders12 = self::genHeaders([1, 2]); + $notFinalHeader2Key = array_key_last($notFinalHeaders12); + $notFinalHeaders12[$notFinalHeader2Key] .= '_NOT_FINAL_VALUE'; + self::assertTrue($curlHandle->setOptArray([CURLOPT_HTTPHEADER => self::convertHeadersToCurlFormat($notFinalHeaders12), CURLOPT_POST => true])); + + $headers = array_merge([$dataPerRequestHeaderName => $dataPerRequestHeaderValue], self::genHeaders([2, 3])); + self::assertTrue($curlHandle->setOptArray([CURLOPT_HTTPHEADER => self::convertHeadersToCurlFormat($headers), CURLOPT_HTTPGET => true, CURLOPT_RETURNTRANSFER => true])); + + $execRetVal = $curlHandle->exec(); + $dbgCtx->add(compact('execRetVal')); + if ($execRetVal === false) { + self::fail(LoggableToString::convert(['error' => $curlHandle->error(), 'errno' => $curlHandle->errno(), 'verbose output' => $curlHandle->lastVerboseOutput()])); + } + $dbgCtx->add(['getInfo()' => $curlHandle->getInfo()]); + + self::assertSame(self::SERVER_RESPONSE_HTTP_STATUS, $curlHandle->getResponseStatusCode()); + self::assertSame(self::SERVER_RESPONSE_BODY, $execRetVal); + } + + /** + * @return iterable + */ + public static function dataProviderForTestRunAndEscalateLogLevelOnFailure(): iterable + { + return self::adaptDataProviderForTestBuilderToSmokeToDescToMixedMap( + (new DataProviderForTestBuilder()) + ->addBoolKeyedDimensionAllValuesCombinable(self::ENABLE_CURL_INSTRUMENTATION_FOR_CLIENT_KEY) + ->addBoolKeyedDimensionAllValuesCombinable(self::ENABLE_CURL_INSTRUMENTATION_FOR_SERVER_KEY) + ); + } + + public function implTestLocalClientServer(MixedMap $testArgs): void + { + $testCaseHandle = $this->getTestCaseHandle(); + + $enableCurlInstrumentationForServer = $testArgs->getBool(self::ENABLE_CURL_INSTRUMENTATION_FOR_SERVER_KEY); + $serverAppCode = $testCaseHandle->ensureAdditionalHttpAppCodeHost( + dbgInstanceName: 'server for cUrl request', + setParamsFunc: function (AppCodeHostParams $appCodeParams) use ($enableCurlInstrumentationForServer): void { + if (!$enableCurlInstrumentationForServer) { + $appCodeParams->setProdOptionIfNotNull(OptionForProdName::disabled_instrumentations, self::CURL_INSTRUMENTATION_NAME); + } + } + ); + $appCodeRequestParamsForServer = $serverAppCode->buildRequestParams(AppCodeTarget::asRouted([__CLASS__, 'appCodeServer'])); + + $enableCurlInstrumentationForClient = $testArgs->getBool(self::ENABLE_CURL_INSTRUMENTATION_FOR_CLIENT_KEY); + $clientAppCode = $testCaseHandle->ensureMainAppCodeHost( + setParamsFunc: function (AppCodeHostParams $appCodeParams) use ($enableCurlInstrumentationForClient): void { + if (!$enableCurlInstrumentationForClient) { + $appCodeParams->setProdOptionIfNotNull(OptionForProdName::disabled_instrumentations, self::CURL_INSTRUMENTATION_NAME); + } + }, + dbgInstanceName: 'client for cUrl request', + ); + $resourcesClient = $testCaseHandle->getResourcesClient(); + + $clientAppCode->execAppCode( + AppCodeTarget::asRouted([__CLASS__, 'appCodeClient']), + function (AppCodeRequestParams $clientAppCodeReqParams) use ($testArgs, $appCodeRequestParamsForServer, $resourcesClient): void { + $clientAppCodeReqParams->setAppCodeArgs( + [ + self::HTTP_APP_CODE_REQUEST_PARAMS_FOR_SERVER_KEY => $appCodeRequestParamsForServer, + self::RESOURCES_CLIENT_KEY => $resourcesClient, + ] + + $testArgs->cloneAsArray() + ); + } + ); + + // + // spans: -> -> + // |------------------------------------------------------| |--------------------------------| + // client app host server app host + + $curlClientSpanAttributesExpectations = new SpanAttributesExpectations( + [ + self::CURL_FUNC_ATTRIBUTE_NAME => 'curl_exec', + TraceAttributes::HTTP_REQUEST_METHOD => HttpMethods::GET, + TraceAttributes::HTTP_RESPONSE_STATUS_CODE => self::SERVER_RESPONSE_HTTP_STATUS, + TraceAttributes::SERVER_ADDRESS => $appCodeRequestParamsForServer->urlParts->host, + TraceAttributes::SERVER_PORT => $appCodeRequestParamsForServer->urlParts->port, + TraceAttributes::URL_FULL => UrlUtil::buildFullUrl($appCodeRequestParamsForServer->urlParts), + TraceAttributes::URL_SCHEME => $appCodeRequestParamsForServer->urlParts->scheme, + ] + ); + $expectationsForCurlClientSpan = new SpanExpectations(HttpMethods::GET, SpanKind::client, $curlClientSpanAttributesExpectations); + + $serverTxSpanAttributesExpectations = new SpanAttributesExpectations( + [ + TraceAttributes::HTTP_REQUEST_METHOD => HttpMethods::GET, + TraceAttributes::HTTP_RESPONSE_STATUS_CODE => self::SERVER_RESPONSE_HTTP_STATUS, + TraceAttributes::SERVER_ADDRESS => $appCodeRequestParamsForServer->urlParts->host, + TraceAttributes::SERVER_PORT => $appCodeRequestParamsForServer->urlParts->port, + TraceAttributes::URL_FULL => UrlUtil::buildFullUrl($appCodeRequestParamsForServer->urlParts), + TraceAttributes::URL_PATH => $appCodeRequestParamsForServer->urlParts->path, + TraceAttributes::URL_SCHEME => $appCodeRequestParamsForServer->urlParts->scheme, + ] + ); + $expectedServerTxSpanName = HttpMethods::GET . ' ' . $appCodeRequestParamsForServer->urlParts->path; + $expectationsForServerTxSpan = new SpanExpectations($expectedServerTxSpanName, SpanKind::server, $serverTxSpanAttributesExpectations); + + $exportedData = $testCaseHandle->waitForEnoughExportedData(WaitForEventCounts::spans($enableCurlInstrumentationForClient ? 3 : 2)); + + // + // Assert + // + + if ($enableCurlInstrumentationForClient) { + $rootSpan = $exportedData->singleRootSpan(); + foreach ($exportedData->spans as $span) { + self::assertSame($rootSpan->traceId, $span->traceId); + } + $curlClientSpan = $exportedData->singleChildSpan($rootSpan->id); + $expectationsForCurlClientSpan->assertMatches($curlClientSpan); + $serverTxSpan = $exportedData->singleChildSpan($curlClientSpan->id); + } else { + $serverTxSpan = IterableUtil::singleValue($exportedData->findSpansWithAttributeValue(TraceAttributes::SERVER_PORT, $appCodeRequestParamsForServer->urlParts->port)); + self::assertNull($serverTxSpan->parentId); + $clientTxSpan = IterableUtil::singleValue(IterableUtil::findByPredicateOnValue($exportedData->spans, fn(Span $span) => $span->parentId === null && $span !== $serverTxSpan)); + self::assertNotEquals($serverTxSpan->traceId, $clientTxSpan->traceId); + } + + $expectationsForServerTxSpan->assertMatches($serverTxSpan); + } + + /** + * @dataProvider dataProviderForTestRunAndEscalateLogLevelOnFailure + */ + public function testLocalClientServer(MixedMap $testArgs): void + { + self::runAndEscalateLogLevelOnFailure( + self::buildDbgDescForTestWithArgs(__CLASS__, __FUNCTION__, $testArgs), + function () use ($testArgs): void { + $this->implTestLocalClientServer($testArgs); + } + ); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/TransactionSpanTest.php b/tests/ElasticOTelTests/ComponentTests/TransactionSpanTest.php new file mode 100644 index 0000000..00f20d4 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/TransactionSpanTest.php @@ -0,0 +1,214 @@ + + */ + public static function dataProviderForTestFeatureWithVariousEnabledConfigCombos(): iterable + { + /** + * @return iterable> + */ + $generateDataSets = function (): iterable { + foreach (BoolUtil::ALL_NULLABLE_VALUES as $transactionSpanEnabled) { + foreach (BoolUtil::ALL_NULLABLE_VALUES as $transactionSpanEnabledCli) { + $shouldAppCodeCreateDummySpanValues = self::isTransactionSpanEnabled($transactionSpanEnabled, $transactionSpanEnabledCli) ? BoolUtil::ALL_VALUES : [true]; + foreach ($shouldAppCodeCreateDummySpanValues as $shouldAppCodeCreateDummySpan) { + yield [ + OptionForProdName::transaction_span_enabled->name => $transactionSpanEnabled, + OptionForProdName::transaction_span_enabled_cli->name => $transactionSpanEnabledCli, + self::SHOULD_APP_CODE_CREATE_DUMMY_SPAN_KEY => $shouldAppCodeCreateDummySpan, + ]; + } + } + } + }; + + return self::adaptDataSetsGeneratorToSmokeToDescToMixedMap($generateDataSets); + } + + public static function appCodeForTestFeatureWithVariousEnabledConfigCombos(MixedMap $appCodeArgs): void + { + self::appCodeSetsHowFinishedAttributes( + $appCodeArgs, + function () use ($appCodeArgs): void { + self::appCodeCreatesDummySpan($appCodeArgs); + } + ); + } + + public function implTestFeatureWithVariousEnabledConfigCombos(MixedMap $testArgs): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $testCaseHandle = $this->getTestCaseHandle(); + $transactionSpanEnabled = $testArgs->getNullableBool(OptionForProdName::transaction_span_enabled->name); + $transactionSpanEnabledCli = $testArgs->getNullableBool(OptionForProdName::transaction_span_enabled_cli->name); + $isTransactionSpanEnabled = self::isTransactionSpanEnabled($transactionSpanEnabled, $transactionSpanEnabledCli); + $shouldAppCodeCreateDummySpan = $testArgs->getBool(self::SHOULD_APP_CODE_CREATE_DUMMY_SPAN_KEY); + + $appCodeHost = $testCaseHandle->ensureMainAppCodeHost( + function (AppCodeHostParams $appCodeParams) use ($transactionSpanEnabled, $transactionSpanEnabledCli): void { + $appCodeParams->setProdOptionIfNotNull(OptionForProdName::transaction_span_enabled, $transactionSpanEnabled); + $appCodeParams->setProdOptionIfNotNull(OptionForProdName::transaction_span_enabled_cli, $transactionSpanEnabledCli); + } + ); + $appCodeHost->execAppCode( + AppCodeTarget::asRouted([__CLASS__, 'appCodeForTestFeatureWithVariousEnabledConfigCombos']), + function (AppCodeRequestParams $appCodeRequestParams) use ($testArgs): void { + $appCodeRequestParams->setAppCodeArgs($testArgs); + } + ); + + $expectedSpanCount = 0; + if ($isTransactionSpanEnabled) { + ++$expectedSpanCount; + } + if ($shouldAppCodeCreateDummySpan) { + ++$expectedSpanCount; + } + self::assertGreaterThan(0, $expectedSpanCount); + /** @var positive-int $expectedSpanCount */ + + /** @noinspection PhpIfWithCommonPartsInspection */ + if (self::isMainAppCodeHostHttp()) { + $expectedRootSpanKind = SpanKind::server; + /** @var HttpAppCodeHostHandle $appCodeHost */ + $expectedRootSpanUrlParts = UrlUtil::buildUrlPartsWithDefaults(port: $appCodeHost->httpServerHandle->getMainPort()); + $rootSpanAttributesExpectations = new SpanAttributesExpectations( + [ + TraceAttributes::HTTP_REQUEST_METHOD => HttpAppCodeRequestParams::DEFAULT_HTTP_REQUEST_METHOD, + TraceAttributes::SERVER_ADDRESS => $expectedRootSpanUrlParts->host, + TraceAttributes::SERVER_PORT => $expectedRootSpanUrlParts->port, + TraceAttributes::URL_FULL => UrlUtil::buildFullUrl($expectedRootSpanUrlParts), + TraceAttributes::URL_PATH => $expectedRootSpanUrlParts->path, + TraceAttributes::URL_SCHEME => $expectedRootSpanUrlParts->scheme, + self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY => true, + ] + ); + } else { + $expectedRootSpanKind = SpanKind::server; + $rootSpanAttributesExpectations = new SpanAttributesExpectations( + attributes: [ + self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY => true, + ], + notAllowedAttributeNames: [ + TraceAttributes::HTTP_REQUEST_METHOD, + TraceAttributes::HTTP_REQUEST_BODY_SIZE, + TraceAttributes::SERVER_ADDRESS, + TraceAttributes::URL_FULL, + TraceAttributes::URL_PATH, + TraceAttributes::URL_SCHEME, + TraceAttributes::USER_AGENT_ORIGINAL, + ] + ); + } + $expectationsForRootSpan = new SpanExpectations(self::getExpectedTransactionSpanName(), $expectedRootSpanKind, $rootSpanAttributesExpectations); + + $expectedDummySpanKind = SpanKind::internal; + $expectationsForDummySpan = new SpanExpectations(self::APP_CODE_DUMMY_SPAN_NAME, $expectedDummySpanKind); + + $exportedData = $testCaseHandle->waitForEnoughExportedData(WaitForEventCounts::spans($expectedSpanCount)); + $dbgCtx->add(compact('exportedData')); + + $rootSpan = null; + $dummySpan = null; + if ($isTransactionSpanEnabled) { + $rootSpans = IterableUtil::toList($exportedData->findRootSpans()); + self::assertCount(1, $rootSpans); + /** @var Span $rootSpan */ + $rootSpan = ArrayUtilForTests::getFirstValue($rootSpans); + if ($shouldAppCodeCreateDummySpan) { + $childSpans = IterableUtil::toList($exportedData->findChildSpans($rootSpan->id)); + self::assertCount(1, $childSpans); + /** @var Span $dummySpan */ + $dummySpan = ArrayUtilForTests::getFirstValue($childSpans); + } + } else { + $dummySpan = $exportedData->singleSpan(); + } + $dbgCtx->add(compact('rootSpan', 'dummySpan')); + + // Assert + + self::assertSame($isTransactionSpanEnabled, $rootSpan !== null); + if ($rootSpan !== null) { + $expectationsForRootSpan->assertMatches($rootSpan); + } + + self::assertSame($shouldAppCodeCreateDummySpan, $dummySpan !== null); + if ($dummySpan !== null) { + $expectationsForDummySpan->assertMatches($dummySpan); + } + } + + + /** + * @dataProvider dataProviderForTestFeatureWithVariousEnabledConfigCombos + */ + public function testFeatureWithVariousEnabledConfigCombos(MixedMap $testArgs): void + { + self::runAndEscalateLogLevelOnFailure( + self::buildDbgDescForTestWithArgs(__CLASS__, __FUNCTION__, $testArgs), + function () use ($testArgs): void { + $this->implTestFeatureWithVariousEnabledConfigCombos($testArgs); + } + ); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AgentToOTeCollectorEvent.php b/tests/ElasticOTelTests/ComponentTests/Util/AgentToOTeCollectorEvent.php new file mode 100644 index 0000000..23a9491 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/AgentToOTeCollectorEvent.php @@ -0,0 +1,36 @@ +logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Done'); + } + + #[Override] + protected function shouldTracingBeEnabled(): bool + { + return true; + } + + #[Override] + protected function processConfig(): void + { + parent::processConfig(); + AmbientContextForTests::testConfig()->validateForAppCode(); + } + + abstract protected function runImpl(): void; + + public static function run(): void + { + self::runSkeleton( + function (SpawnedProcessBase $thisObj): void { + Assert::assertInstanceOf(self::class, $thisObj); + if (!ElasticOTelExtensionUtil::isLoaded()) { + throw new ComponentTestsInfraException( + 'Environment hosting component tests application code should have ' + . ElasticOTelExtensionUtil::EXTENSION_NAME . ' extension loaded.' + . ' php_ini_loaded_file(): ' . php_ini_loaded_file() . '.' + ); + } + + AmbientContextForTests::testConfig()->validateForAppCodeRequest(); + + $thisObj->runImpl(); + } + ); + } + + #[Override] + protected function isThisProcessTestScoped(): bool + { + return true; + } + + protected function callAppCode(): void + { + $dataPerRequest = AmbientContextForTests::testConfig()->dataPerRequest(); + $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Calling application code...', compact('dataPerRequest')); + + $msg = LoggableToString::convert(AmbientContextForTests::testConfig()); + $appCodeTarget = $dataPerRequest->appCodeTarget; + Assert::assertNotNull($appCodeTarget, $msg); + Assert::assertNotNull($appCodeTarget->appCodeClass, $msg); + Assert::assertNotNull($appCodeTarget->appCodeMethod, $msg); + + try { + $methodToCall = [$appCodeTarget->appCodeClass, $appCodeTarget->appCodeMethod]; + Assert::assertIsCallable($methodToCall, $msg); + $appCodeArguments = $dataPerRequest->appCodeArguments; + if ($appCodeArguments === null) { + call_user_func($methodToCall); + } else { + call_user_func($methodToCall, new MixedMap($appCodeArguments)); + } + } catch (Throwable $throwable) { + $loggerProxyDebug && $loggerProxyDebug->logThrowable(__LINE__, $throwable, 'Call to application code exited by exception'); + throw new WrappedAppCodeException($throwable); + } + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Call to application code completed'); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostHandle.php new file mode 100644 index 0000000..45b8010 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostHandle.php @@ -0,0 +1,65 @@ +getSystemClockCurrentTime(); + return new AppCodeInvocation($appCodeRequestParams, $timestampBefore); + } + + protected function afterAppCodeInvocation(AppCodeInvocation $appCodeInvocation): void + { + $appCodeInvocation->after(); + $this->testCaseHandle->addAppCodeInvocation($appCodeInvocation); + } + + /** + * @return string[] + */ + protected static function propertiesExcludedFromLog(): array + { + return ['testCaseHandle']; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostKind.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostKind.php new file mode 100644 index 0000000..8e7dafa --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostKind.php @@ -0,0 +1,53 @@ + false, + self::builtinHttpServer => true, + }; + } + + #[Override] + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs($this->value); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostParams.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostParams.php new file mode 100644 index 0000000..7c5f96e --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeHostParams.php @@ -0,0 +1,248 @@ + + */ +class AppCodeHostParams implements LoggableInterface +{ + use LoggableTrait; + + /** @var OptionsForProdMap */ + private Map $prodOptions; + + public string $spawnedProcessInternalId; + + public function __construct( + public readonly string $dbgProcessName + ) { + $this->prodOptions = new Map(); + } + + /** + * @param OptionForProdName $optName + * @param OptionForProdValue $optVal + */ + public function setProdOption(OptionForProdName $optName, string|int|float|bool $optVal): void + { + $this->prodOptions[$optName] = $optVal; + } + + /** + * @param OptionForProdName $optName + * @param ?OptionForProdValue $optVal + */ + public function setProdOptionIfNotNull(OptionForProdName $optName, null|string|int|float|bool $optVal): void + { + if ($optVal !== null) { + $this->setProdOption($optName, $optVal); + } + } + + /** + * @param OptionsForProdMap $prodOptions + * + * @return bool + */ + private static function areAnyProdLogLevelRelatedOptionsSet(Map $prodOptions): bool + { + return !IterableUtil::isEmpty(IterableUtil::findByPredicateOnValue(IterableUtil::keys($prodOptions), fn($optName) => $optName->isLogLevelRelated())); + } + + private static function isProdEnvVarLogRelated(string $envVarName): bool + { + foreach (OptionForProdName::cases() as $optName) { + if ($optName->isLogRelated() && $optName->toEnvVarName() === $envVarName) { + return true; + } + } + return false; + } + + /** + * @phpstan-param EnvVars $inputEnvVars + * + * @return EnvVars + */ + private static function removeProdLogLevelRelatedEnvVars(array $inputEnvVars): array + { + $outputEnvVars = $inputEnvVars; + foreach (OptionForProdName::getAllLogLevelRelated() as $optName) { + $envVarName = $optName->toEnvVarName(); + if (array_key_exists($envVarName, $outputEnvVars)) { + unset($outputEnvVars[$envVarName]); + } + } + + return $outputEnvVars; + } + + /** + * @phpstan-param EnvVars $baseEnvVars + * @phpstan-param OptionsForProdMap $prodOptions + * + * @return EnvVars + */ + private static function filterBaseEnvVars(array $baseEnvVars, Map $prodOptions): array + { + $logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + if ($loggerProxyDebug !== null) { + ksort(/* ref */ $baseEnvVars); + $loggerProxyDebug->log(__LINE__, 'Entered', compact('baseEnvVars')); + } + + $areAnyProdLogLevelRelatedOptionsSet = self::areAnyProdLogLevelRelatedOptionsSet($prodOptions); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Before handling log related options', compact('areAnyProdLogLevelRelatedOptionsSet')); + $envVars = $baseEnvVars; + if ($areAnyProdLogLevelRelatedOptionsSet) { + $envVars = self::removeProdLogLevelRelatedEnvVars($envVars); + } + if ($loggerProxyDebug !== null) { + ksort(/* ref */ $envVars); + $loggerProxyDebug->log(__LINE__, 'After handling log related options', compact('envVars')); + } + + $result = array_filter( + $envVars, + function (string $envVarName): bool { + // Return false for entries to be removed + + // Keep environment variables related to testing infrastructure + if (TextUtil::isPrefixOfIgnoreCase(OptionForTestsName::ENV_VAR_NAME_PREFIX, $envVarName)) { + return true; + } + + // Keep environment variable 'is dev mode' + if ($envVarName === PhpPartFacade::CONFIG_ENV_VAR_NAME_DEV_INTERNAL_MODE_IS_DEV) { + return true; + } + + // Keep environment variables related to production code logging + if (self::isProdEnvVarLogRelated($envVarName)) { + return true; + } + + // Keep environment variables explicitly configured to be passed through + if (AmbientContextForTests::testConfig()->isEnvVarToPassThrough($envVarName)) { + return true; + } + + // Drop any other environment variables related to either vanilla or Elastic OTel + foreach (OptionForProdName::getEnvVarNamePrefixes() as $envVarPrefix) { + if (TextUtil::isPrefixOfIgnoreCase($envVarPrefix, $envVarName)) { + return false; + } + } + + // Keep the rest + return true; + }, + ARRAY_FILTER_USE_KEY + ); + + if ($loggerProxyDebug !== null) { + ksort(/* ref */ $result); + $loggerProxyDebug->log(__LINE__, 'Exiting', compact('result')); + } + return $result; + } + + /** + * @phpstan-param EnvVars $inheritedEnvVars + * @phpstan-param OptionsForProdMap $prodOptions + * + * @return EnvVars + */ + public static function buildEnvVarsForAppCodeProcessImpl(array $inheritedEnvVars, Map $prodOptions): array + { + $result = self::filterBaseEnvVars($inheritedEnvVars, $prodOptions); + + foreach ($prodOptions as $optName => $optVal) { + $result[$optName->toEnvVarName()] = ConfigUtilForTests::optionValueToString($optVal); + } + + $logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('', compact('result')); + return $result; + } + + /** + * @return EnvVars + */ + public function buildEnvVarsForAppCodeProcess(): array + { + return self::buildEnvVarsForAppCodeProcessImpl(EnvVarUtilForTests::getAll(), $this->prodOptions); + } + + public function buildProdConfig(): ConfigSnapshotForProd + { + $envVarsToInheritSource = new MockConfigRawSnapshotSource(); + $envVars = $this->buildEnvVarsForAppCodeProcess(); + $allOptsMeta = OptionsForProdMetadata::get(); + foreach (IterableUtil::keys($allOptsMeta) as $optName) { + $envVarName = OptionForProdName::findByName($optName)->toEnvVarName(); + if (array_key_exists($envVarName, $envVars)) { + $envVarsToInheritSource->set($optName, $envVars[$envVarName]); + } + } + + $explicitlySetOptionsSource = new MockConfigRawSnapshotSource(); + foreach ($this->prodOptions as $optName => $optVal) { + $explicitlySetOptionsSource->set($optName->name, ConfigUtilForTests::optionValueToString($optVal)); + } + $rawSnapshotSource = new CompositeRawSnapshotSource([$explicitlySetOptionsSource, $envVarsToInheritSource]); + $rawSnapshot = $rawSnapshotSource->currentSnapshot($allOptsMeta); + + // Set log level above ERROR to hide potential errors when parsing the provided test configuration snapshot + $logBackend = AmbientContextForTests::loggerFactory()->getBackend()->clone(); + $logBackend->setMaxEnabledLevel(LogLevel::critical); + $loggerFactory = new LoggerFactory($logBackend); + $parser = new ConfigParser($loggerFactory); + return new ConfigSnapshotForProd($parser->parse($allOptsMeta, $rawSnapshot)); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeInvocation.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeInvocation.php new file mode 100644 index 0000000..314614e --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeInvocation.php @@ -0,0 +1,45 @@ +timestampAfter = AmbientContextForTests::clock()->getSystemClockCurrentTime(); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeRequestParams.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeRequestParams.php new file mode 100644 index 0000000..3a5f455 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeRequestParams.php @@ -0,0 +1,48 @@ +dataPerRequest = new TestInfraDataPerRequest(spawnedProcessInternalId: $spawnedProcessInternalId, appCodeTarget: $appCodeTarget); + } + + /** + * @param MixedMap|array $appCodeArgs + */ + public function setAppCodeArgs(MixedMap|array $appCodeArgs): void + { + $this->dataPerRequest->appCodeArguments = $appCodeArgs instanceof MixedMap ? $appCodeArgs->cloneAsArray() : $appCodeArgs; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/AppCodeTarget.php b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeTarget.php new file mode 100644 index 0000000..f37d560 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/AppCodeTarget.php @@ -0,0 +1,47 @@ +appCodeClass = $appCodeClassMethod[0]; + $thisObj->appCodeMethod = $appCodeClassMethod[1]; + return $thisObj; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ArrayExpectations.php b/tests/ElasticOTelTests/ComponentTests/Util/ArrayExpectations.php new file mode 100644 index 0000000..9ef6818 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/ArrayExpectations.php @@ -0,0 +1,110 @@ +|(ArrayReadInterface & Countable) + */ +class ArrayExpectations implements ExpectationsInterface +{ + use ExpectationsTrait; + + /** + * @param array $expectedArray + */ + public function __construct( + private readonly array $expectedArray, + private readonly bool $allowOtherKeysInActual = true, + ) { + } + + #[Override] + public function assertMatchesMixed(mixed $actual): void + { + Assert::assertTrue(is_array($actual) || $actual instanceof ArrayReadInterface); + /** @var ArrayLike $actual */ + $this->assertMatches($actual); + } + + /** + * @param ArrayLike $actual + */ + public function assertMatches(array|ArrayReadInterface $actual): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + if (!$this->allowOtherKeysInActual) { + Assert::assertCount(count($this->expectedArray), $actual); + } + foreach ($this->expectedArray as $expectedKey => $expectedValue) { + $dbgCtx->add(compact('expectedKey', 'expectedValue')); + self::keyExists($expectedKey, $this->expectedArray); + $actualValue = self::getValue($expectedKey, $actual); + $dbgCtx->add(compact('actualValue')); + $this->assertValueMatches($expectedKey, $expectedValue, $actualValue); + } + } + + /** + * @phpstan-param TKey $key + * @phpstan-param ArrayLike $array + */ + private static function keyExists(string|int $key, array|ArrayReadInterface $array): bool + { + return is_array($array) ? array_key_exists($key, $array) : $array->keyExists($key); + } + + /** + * @phpstan-param TKey $key + * @phpstan-param ArrayLike $array + * + * @return TValue + */ + private static function getValue(string|int $key, array|ArrayReadInterface $array): mixed + { + if (is_array($array)) { + Assert::assertArrayHasKey($key, $array); + return $array[$key]; + } + return $array->getValue($key); + } + + /** + * @phpstan-param TKey $key + * @phpstan-param TValue $expectedValue + * @phpstan-param TValue $actualValue + */ + protected function assertValueMatches(string|int $key, mixed $expectedValue, mixed $actualValue): void + { + Assert::assertSame($expectedValue, $actualValue); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHost.php b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHost.php new file mode 100644 index 0000000..ef7a2c9 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHost.php @@ -0,0 +1,99 @@ +logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Received request', ['URI' => GlobalUnderscoreServer::requestUri(), 'method' => GlobalUnderscoreServer::requestMethod()]); + } + + protected static function isStatusCheck(): bool + { + return GlobalUnderscoreServer::requestUri() === HttpServerHandle::STATUS_CHECK_URI_PATH; + } + + #[Override] + protected function shouldRegisterThisProcessWithResourcesCleaner(): bool + { + // We should register with ResourcesCleaner only on the status-check request + return self::isStatusCheck(); + } + + #[Override] + protected function processConfig(): void + { + Assert::assertCount(1, AmbientContextForTests::testConfig()->dataPerProcess()->thisServerPorts); + + parent::processConfig(); + + AmbientContextForTests::reconfigure(new RequestHeadersRawSnapshotSource(fn(string $headerName) => GlobalUnderscoreServer::getRequestHeaderValue($headerName))); + } + + #[Override] + protected function runImpl(): void + { + $dataPerRequest = AmbientContextForTests::testConfig()->dataPerRequest(); + if (($response = self::verifySpawnedProcessInternalId($dataPerRequest->spawnedProcessInternalId)) !== null) { + self::sendResponse($response); + return; + } + if (self::isStatusCheck()) { + self::sendResponse(self::buildResponseWithPid()); + return; + } + + $this->callAppCode(); + } + + private static function sendResponse(ResponseInterface $response): void + { + $localLogger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + $loggerProxyDebug = $localLogger->ifDebugLevelEnabledNoLine(__FUNCTION__); + + $httpResponseStatusCode = $response->getStatusCode(); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Sending response ...', compact('httpResponseStatusCode', 'response')); + + http_response_code($httpResponseStatusCode); + echo $response->getBody(); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostHandle.php new file mode 100644 index 0000000..3a2c96f --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostHandle.php @@ -0,0 +1,46 @@ +spawnedProcessInternalId = $httpServerHandle->spawnedProcessInternalId; + + parent::__construct($testCaseHandle, $appCodeHostParams, $httpServerHandle); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostStarter.php b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostStarter.php new file mode 100644 index 0000000..dd76830 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/BuiltinHttpServerAppCodeHostStarter.php @@ -0,0 +1,79 @@ +dbgProcessName); + } + + /** + * @param int[] $portsInUse + */ + public static function startBuiltinHttpServerAppCodeHost(HttpAppCodeHostParams $appCodeHostParams, ResourcesCleanerHandle $resourcesCleaner, array $portsInUse): HttpServerHandle + { + return (new self($appCodeHostParams, $resourcesCleaner))->startHttpServer($portsInUse); + } + + /** @inheritDoc */ + #[Override] + protected function buildCommandLine(array $ports): string + { + Assert::assertCount(1, $ports); + $routerScriptNameFullPath = FileUtil::listToPath([__DIR__, self::APP_CODE_HOST_ROUTER_SCRIPT]); + if (!file_exists($routerScriptNameFullPath)) { + throw new ConfigException(ExceptionUtil::buildMessage('Router script does not exist', compact('routerScriptNameFullPath'))); + } + + return InfraUtilForTests::buildAppCodePhpCmd() + . ' -S ' . HttpServerHandle::SERVER_LOCALHOST_ADDRESS . ':' . $ports[0] + . ' "' . $routerScriptNameFullPath . '"'; + } + + /** @inheritDoc */ + #[Override] + protected function buildEnvVarsForSpawnedProcess(string $spawnedProcessInternalId, array $ports): array + { + Assert::assertCount(1, $ports); + return InfraUtilForTests::addTestInfraDataPerProcessToEnvVars( + $this->appCodeHostParams->buildEnvVarsForAppCodeProcess(), + $spawnedProcessInternalId, + $ports, + $this->resourcesCleaner, + $this->appCodeHostParams->dbgProcessName + ); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHost.php b/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHost.php new file mode 100644 index 0000000..309d740 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHost.php @@ -0,0 +1,37 @@ +callAppCode(); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHostHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHostHandle.php new file mode 100644 index 0000000..af07e32 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/CliScriptAppCodeHostHandle.php @@ -0,0 +1,104 @@ +logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + $dbgProcessName = ClassNameUtil::fqToShort(CliScriptAppCodeHost::class) . '(' . $dbgInstanceName . ')'; + $appCodeHostParams = new AppCodeHostParams($dbgProcessName); + $appCodeHostParams->spawnedProcessInternalId = InfraUtilForTests::generateSpawnedProcessInternalId(); + $setParamsFunc($appCodeHostParams); + + parent::__construct($testCaseHandle, $appCodeHostParams); + + $this->logger->addAllContext(compact('this')); + } + + public static function getRunScriptNameFullPath(): string + { + return FileUtil::listToPath([__DIR__, CliScriptAppCodeHost::SCRIPT_TO_RUN_APP_CODE_HOST]); + } + + /** @inheritDoc */ + #[Override] + public function execAppCode(AppCodeTarget $appCodeTarget, ?Closure $setParamsFunc = null): void + { + $localLogger = $this->logger->inherit()->addAllContext(compact('appCodeTarget')); + $loggerProxyDebug = $localLogger->ifDebugLevelEnabledNoLine(__FUNCTION__); + $requestParams = new AppCodeRequestParams($this->appCodeHostParams->spawnedProcessInternalId, $appCodeTarget); + if ($setParamsFunc !== null) { + $setParamsFunc($requestParams); + } + $localLogger->addAllContext(compact('requestParams')); + + $runScriptNameFullPath = self::getRunScriptNameFullPath(); + if (!file_exists($runScriptNameFullPath)) { + throw new ConfigException(ExceptionUtil::buildMessage('Run script does not exist', compact('runScriptNameFullPath'))); + } + + $cmdLine = InfraUtilForTests::buildAppCodePhpCmd() . ' "' . $runScriptNameFullPath . '"'; + + $envVars = InfraUtilForTests::addTestInfraDataPerProcessToEnvVars( + $this->appCodeHostParams->buildEnvVarsForAppCodeProcess(), + $this->appCodeHostParams->spawnedProcessInternalId, + [] /* <- targetServerPorts */, + $this->resourcesCleaner, + $this->appCodeHostParams->dbgProcessName + ); + $envVars[OptionForTestsName::data_per_request->toEnvVarName()] = PhpSerializationUtil::serializeToString($requestParams->dataPerRequest); + ksort(/* ref */ $envVars); + $localLogger->addAllContext(compact('envVars')); + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Executing app code ...'); + + $appCodeInvocation = $this->beforeAppCodeInvocation($requestParams); + ProcessUtil::startProcessAndWaitUntilExit($cmdLine, $envVars); + $this->afterAppCodeInvocation($appCodeInvocation); + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Executed app code'); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestCaseBase.php b/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestCaseBase.php new file mode 100644 index 0000000..a77a4a3 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestCaseBase.php @@ -0,0 +1,485 @@ +testCaseHandle !== null) { + return $this->testCaseHandle; + } + $this->testCaseHandle = new TestCaseHandle($escalatedLogLevelForProdCode); + return $this->testCaseHandle; + } + + protected function getTestCaseHandle(): TestCaseHandle + { + return $this->initTestCaseHandle(); + } + + #[Override] + public function tearDown(): void + { + if ($this->testCaseHandle !== null) { + $this->testCaseHandle->tearDown(); + $this->testCaseHandle = null; + } + + parent::tearDown(); + } + + public static function appCodeEmpty(): void + { + } + + /** + * @param callable(): void $appCodeImpl + * + * @noinspection PhpDocMissingThrowsInspection + */ + public static function appCodeSetsHowFinishedAttributes(MixedMap $appCodeArgs, callable $appCodeImpl): void + { + $shouldSetAttributes = $appCodeArgs->isBoolIsNotSetOrSetToTrue(self::SHOULD_APP_CODE_SET_HOW_FINISHED_ATTRIBUTES_KEY); + + $logger = self::getLoggerStatic(__NAMESPACE__, __CLASS__, __FILE__); + $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + $logger->addAllContext(compact('shouldSetAttributes', 'appCodeArgs')); + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Calling $appCodeImpl() ...'); + try { + $appCodeImpl(); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Call to $appCodeImpl() finished successfully'); + if ($shouldSetAttributes) { + OTelUtil::setActiveSpanAttributes([self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY => true]); + } + } catch (Throwable $throwable) { + $loggerProxyDebug && $loggerProxyDebug->logThrowable(__LINE__, $throwable, 'Call to $appCodeImpl() thrown'); + if ($shouldSetAttributes) { + OTelUtil::setActiveSpanAttributes([self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY => false, self::THROWABLE_FROM_APP_CODE_KEY => LoggableToString::convert($throwable)]); + } + throw $throwable; + } + } + + public static function appCodeCreatesDummySpan(MixedMap $appCodeArgs): void + { + if ($appCodeArgs->isBoolIsNotSetOrSetToTrue(self::SHOULD_APP_CODE_CREATE_DUMMY_SPAN_KEY)) { + OTelUtil::startEndSpan(OTelUtil::getTracer(), self::APP_CODE_DUMMY_SPAN_NAME); + } + } + + protected static function buildResourcesClientForAppCode(): ResourcesClient + { + $resCleanerId = AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerSpawnedProcessInternalId; + Assert::assertNotNull($resCleanerId); + $resCleanerPort = AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerPort; + Assert::assertNotNull($resCleanerPort); + return new ResourcesClient($resCleanerId, $resCleanerPort); + } + + public static function isSmoke(): bool + { + return AmbientContextForTests::testConfig()->isSmoke(); + } + + public static function isMainAppCodeHostHttp(): bool + { + return AmbientContextForTests::testConfig()->appCodeHostKind()->isHttp(); + } + + protected function skipIfMainAppCodeHostIsNotCliScript(): bool + { + if (self::isMainAppCodeHostHttp()) { + self::dummyAssert(); + return true; + } + + return false; + } + + protected function skipIfMainAppCodeHostIsNotHttp(): bool + { + if (!self::isMainAppCodeHostHttp()) { + self::dummyAssert(); + return true; + } + + return false; + } + + protected static function waitForOneSpan(TestCaseHandle $testCaseHandle): Span + { + $exportedData = $testCaseHandle->waitForEnoughExportedData(WaitForEventCounts::spans(1)); + return $exportedData->singleSpan(); + } + + /** + * @template T + * + * @param iterable $variants + * + * @return iterable + */ + public static function adaptToSmoke(iterable $variants): iterable + { + if (!self::isSmoke()) { + return $variants; + } + foreach ($variants as $key => $value) { + return [$key => $value]; + } + return []; + } + + /** + * @template TKey of array-key + * @template TValue + * + * @param iterable $variants + * + * @return iterable + */ + public function adaptKeyValueToSmoke(iterable $variants): iterable + { + if (!self::isSmoke()) { + return $variants; + } + foreach ($variants as $key => $value) { + return [$key => $value]; + } + return []; + } + + /** + * @return callable(iterable): iterable + */ + public static function adaptToSmokeAsCallable(): callable + { + /** + * @template T + * + * @param iterable $dataSets + * + * @return iterable + */ + return function (iterable $dataSets): iterable { + return self::adaptToSmoke($dataSets); + }; + } + + /** + * @param callable(): iterable> $dataSetsGenerator + * + * @return iterable + */ + public static function adaptDataSetsGeneratorToSmokeToDescToMixedMap(callable $dataSetsGenerator): iterable + { + return DataProviderForTestBuilder::convertEachDataSetToMixedMapAndAddDesc(fn() => self::adaptToSmoke($dataSetsGenerator())); + } + + /** + * @return iterable + */ + public static function adaptDataProviderForTestBuilderToSmokeToDescToMixedMap(DataProviderForTestBuilder $dataProviderForTestBuilder): iterable + { + return self::adaptDataSetsGeneratorToSmokeToDescToMixedMap(fn() => $dataProviderForTestBuilder->buildWithoutDataSetName()); // @phpstan-ignore argument.type + } + + /** + * @return iterable + */ + public static function dataProviderOneBoolArgAdaptedToSmoke(): iterable + { + return self::adaptToSmoke(self::dataProviderOneBoolArg()); + } + + /** + * @param callable(): void $testCall + * + * @noinspection PhpDocMissingThrowsInspection + */ + protected function runAndEscalateLogLevelOnFailure(string $dbgTestDesc, callable $testCall): void + { + $logLevelForTestCodeToRestore = AmbientContextForTests::testConfig()->logLevel; + try { + $this->runAndEscalateLogLevelOnFailureImpl($dbgTestDesc, $testCall); + } finally { + AmbientContextForTests::resetLogLevel($logLevelForTestCodeToRestore); + } + } + + /** + * @param callable(): void $testCall + * + * @noinspection PhpDocMissingThrowsInspection + */ + private function runAndEscalateLogLevelOnFailureImpl(string $dbgTestDesc, callable $testCall): void + { + try { + $testCall(); + return; + } catch (Throwable $ex) { + $initiallyFailedTestException = $ex; + } + + $logger = self::getLoggerStatic(__NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('dbgTestDesc')); + $loggerProxyOutsideIt = $logger->ifCriticalLevelEnabledNoLine(__FUNCTION__); + if ($this->testCaseHandle === null) { + $loggerProxyOutsideIt && $loggerProxyOutsideIt->log(__LINE__, 'Test failed but $this->testCaseHandle is null - NOT re-running the test with escalated log levels'); + throw $initiallyFailedTestException; + } + $initiallyFailedTestLogLevels = $this->getCurrentLogLevels($this->testCaseHandle); + $logger->addAllContext(compact('initiallyFailedTestLogLevels', 'initiallyFailedTestException')); + $loggerProxyOutsideIt && $loggerProxyOutsideIt->log(__LINE__, 'Test failed'); + + $escalatedLogLevelsSeq = self::generateLevelsForRunAndEscalateLogLevelOnFailure($initiallyFailedTestLogLevels, AmbientContextForTests::testConfig()->escalatedRerunsMaxCount); + $rerunCount = 0; + foreach ($escalatedLogLevelsSeq as $escalatedLogLevels) { + $this->tearDown(); + + ++$rerunCount; + $loggerPerIt = $logger->inherit()->addAllContext(compact('rerunCount', 'escalatedLogLevels')); + $loggerProxyPerIt = $loggerPerIt->ifCriticalLevelEnabledNoLine(__FUNCTION__); + + $loggerProxyPerIt && $loggerProxyPerIt->log(__LINE__, 'Re-running failed test with escalated log levels...'); + + AmbientContextForTests::resetLogLevel($escalatedLogLevels[self::LOG_LEVEL_FOR_TEST_CODE_KEY]); + $this->initTestCaseHandle($escalatedLogLevels[self::LOG_LEVEL_FOR_PROD_CODE_KEY]); + + try { + $testCall(); + $loggerProxyPerIt && $loggerProxyPerIt->log(__LINE__, 'Re-run of failed test with escalated log levels did NOT fail (which is bad :('); + } catch (Throwable $ex) { + $loggerProxyPerIt && $loggerProxyPerIt->log(__LINE__, 'Re-run of failed test with escalated log levels failed (which is good :)', compact('ex')); + throw $ex; + } + } + + if ($rerunCount === 0) { + $loggerProxyOutsideIt && $loggerProxyOutsideIt->log(__LINE__, 'There were no test re-runs with escalated log levels - re-throwing original test failure exception'); + } else { + $loggerProxyOutsideIt && $loggerProxyOutsideIt->log(__LINE__, 'All test re-runs with escalated log levels did NOT fail (which is bad :( - re-throwing original test failure exception'); + } + throw $initiallyFailedTestException; + } + + /** + * @param class-string $testClass + */ + protected static function buildDbgDescForTest(string $testClass, string $testFunc): string + { + return ClassNameUtil::fqToShort($testClass) . '::' . $testFunc; + } + + /** + * @param class-string $testClass + */ + protected static function buildDbgDescForTestWithArgs(string $testClass, string $testFunc, MixedMap $testArgs): string + { + return ClassNameUtil::fqToShort($testClass) . '::' . $testFunc . '(' . LoggableToString::convert($testArgs) . ')'; + } + + /** + * @param TestCaseHandle $testCaseHandle + * + * @return array + */ + private function getCurrentLogLevels(TestCaseHandle $testCaseHandle): array + { + /** @var array $result */ + $result = []; + $prodCodeLogLevels = $testCaseHandle->getProdCodeLogLevels(); + Assert::assertNotEmpty($prodCodeLogLevels); + $result[self::LOG_LEVEL_FOR_PROD_CODE_KEY] = self::findLogLevelByValue(min(array_map(fn(LogLevel $logLevel) => $logLevel->value, $prodCodeLogLevels))); + $result[self::LOG_LEVEL_FOR_TEST_CODE_KEY] = AmbientContextForTests::testConfig()->logLevel; + return $result; + } + + public static function findLogLevelByValue(int $logLevelValue): LogLevel + { + $logLevel = LogLevel::tryFrom($logLevelValue); + Assert::assertNotNull($logLevel); + return $logLevel; + } + + /** + * @param array $initialLevels + * + * @return iterable> + */ + public static function generateEscalatedLogLevels(array $initialLevels): iterable + { + Assert::assertNotEmpty($initialLevels); + + /** + * @param array $currentLevels + */ + $haveCurrentLevelsReachedInitial = function (array $currentLevels) use ($initialLevels): bool { + foreach ($initialLevels as $levelTypeKey => $initialLevel) { + /** @var LogLevel $initialLevel */ + /** @var array $currentLevels */ + if ($initialLevel->value < $currentLevels[$levelTypeKey]->value) { + return false; + } + } + return true; + }; + + /** @var int $minInitialLevel */ + $minInitialLevel = min(array_map(fn(LogLevel $logLevel) => $logLevel->value, $initialLevels)); + $maxDelta = 0; + foreach ($initialLevels as $initialLevel) { + $maxDelta = max($maxDelta, LogLevelUtil::getHighest()->value - $initialLevel->value); + } + foreach (RangeUtil::generateDown(LogLevelUtil::getHighest()->value, $minInitialLevel) as $baseLevelAsInt) { + Assert::assertGreaterThan(LogLevel::off->value, $baseLevelAsInt); + /** @var array $currentLevels */ + $currentLevels = []; + foreach (self::LOG_LEVEL_FOR_CODE_KEYS as $levelTypeKey) { + $currentLevels[$levelTypeKey] = self::findLogLevelByValue($baseLevelAsInt); + } + yield $currentLevels; + + foreach (RangeUtil::generate(1, $maxDelta + 1) as $delta) { + foreach (self::LOG_LEVEL_FOR_CODE_KEYS as $levelTypeKey) { + if ($baseLevelAsInt < $initialLevels[$levelTypeKey]->value + $delta) { + continue; + } + $currentLevels[$levelTypeKey] = self::findLogLevelByValue($baseLevelAsInt - $delta); + if (!$haveCurrentLevelsReachedInitial($currentLevels)) { + yield $currentLevels; + } + $currentLevels[$levelTypeKey] = self::findLogLevelByValue($baseLevelAsInt); + } + } + } + } + + /** + * @param array $initialLevels + * + * @return iterable> + */ + protected static function generateLevelsForRunAndEscalateLogLevelOnFailure(array $initialLevels, int $eachLevelsSetMaxCount): iterable + { + $result = self::generateEscalatedLogLevels($initialLevels); + $result = IterableUtil::concat($result, [$initialLevels]); + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $result = IterableUtil::duplicateEachElement($result, $eachLevelsSetMaxCount); + return $result; + } + + /** + * @template TKey of array-key + * @template TValue + * + * @param array $array + * @param TKey $key + * + * @return TValue + */ + public static function &assertAndGetFromArrayByKey(array $array, $key) + { + Assert::assertArrayHasKey($key, $array); + return $array[$key]; + } + + protected static function getTracerFromAppCode(): TracerInterface + { + return Globals::tracerProvider()->getTracer('co.elastic.edot.php.ComponentTests', null, Version::VERSION_1_25_0->url()); + } + + protected static function buildProdConfigFromAppCode(): ConfigSnapshotForProd + { + /** @var ?array $envVarPrefixToOptNames */ + static $envVarPrefixToOptNames = null; + + $allOptsMeta = OptionsForProdMetadata::get(); + + if ($envVarPrefixToOptNames === null) { + $envVarPrefixToOptNames = []; + foreach (OptionForProdName::cases() as $optName) { + $envVarNamePrefix = $optName->getEnvVarNamePrefix(); + if (!array_key_exists($envVarNamePrefix, $envVarPrefixToOptNames)) { + $envVarPrefixToOptNames[$envVarNamePrefix] = []; + } + $envVarPrefixToOptNames[$envVarNamePrefix][] = $optName->name; + } + } + + $envVarsSnapSources = []; + foreach ($envVarPrefixToOptNames as $currentEnvVarsPrefix => $optNames) { + $envVarsSnapSources[] = new EnvVarsRawSnapshotSource($currentEnvVarsPrefix, $optNames); + } + $rawSnapshotSource = new CompositeRawSnapshotSource($envVarsSnapSources); + + $parser = new ConfigParser(AmbientContextForTests::loggerFactory()); + $rawSnapshot = $rawSnapshotSource->currentSnapshot($allOptsMeta); + return new ConfigSnapshotForProd($parser->parse($allOptsMeta, $rawSnapshot)); + } + + protected static function getExpectedTransactionSpanName(): string + { + return self::isMainAppCodeHostHttp() + ? HttpAppCodeRequestParams::DEFAULT_HTTP_REQUEST_METHOD . ' ' . HttpAppCodeRequestParams::DEFAULT_HTTP_REQUEST_URL_PATH + : CliScriptAppCodeHostHandle::getRunScriptNameFullPath(); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestsInfraException.php b/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestsInfraException.php new file mode 100644 index 0000000..a59cd9d --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/ComponentTestsInfraException.php @@ -0,0 +1,30 @@ +logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + $this->logger->addContext('appCodeHostKind', AmbientContextForTests::testConfig()->appCodeHostKind()); + + try { + // We spin off test infrastructure servers here and not on demand + // in self::getGlobalTestInfra() because PHPUnit might fork to run individual tests + // and ResourcesCleaner would track the PHPUnit child process as its master which would be wrong + self::$globalTestInfra = new GlobalTestInfra(); + } catch (Throwable $throwable) { + ($loggerProxy = $this->logger->ifCriticalLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->logThrowable($throwable, 'Throwable escaped from GlobalTestInfra constructor'); + throw $throwable; + } + } + + public function __destruct() + { + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Destroying...'); + + self::$globalTestInfra?->getResourcesCleaner()->signalAndWaitForItToExit(); + } + + public static function getGlobalTestInfra(): GlobalTestInfra + { + Assert::assertNotNull(self::$globalTestInfra); + return self::$globalTestInfra; + } + + #[Override] + protected function logLevelForEnvInfo(): LogLevel + { + return LogLevel::info; + } + + #[Override] + protected function logLevelForBeforeTestCaseIsRun(): LogLevel + { + return LogLevel::info; + } + + #[Override] + protected function logLevelForAfterTestCasePassed(): LogLevel + { + return LogLevel::info; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ConfigUtilForTests.php b/tests/ElasticOTelTests/ComponentTests/Util/ConfigUtilForTests.php new file mode 100644 index 0000000..20dfcc6 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/ConfigUtilForTests.php @@ -0,0 +1,91 @@ +parse($allOptsMeta, $configSource->currentSnapshot($allOptsMeta)); + return new ConfigSnapshotForTests($optNameToParsedValue); + } + + public static function verifyTracingIsDisabled(): void + { + if (!ElasticOTelExtensionUtil::isLoaded()) { + return; + } + + $envVarName = OptionForProdName::enabled->toEnvVarName(); + $envVarValue = EnvVarUtilForTests::get($envVarName); + if ($envVarValue !== 'false') { + throw new TestsInfraException( + 'Environment variable ' . $envVarName . ' should be set to `false\'.' + . ' Instead it is ' . ($envVarValue === null ? 'not set' : 'set to `' . $envVarValue . '\'') + ); + } + + $msgPrefix = 'Component tests auxiliary processes should not be recorded'; + // elastic_otel_is_enabled is provided by the extension + if (function_exists('elastic_otel_is_enabled') && elastic_otel_is_enabled()) { + throw new ComponentTestsInfraException($msgPrefix . '; elastic_otel_is_enabled() returned true'); + } + if (PhpPartFacade::$wasBootstrapCalled) { + throw new ComponentTestsInfraException($msgPrefix . '; PhpPartFacade::$wasBootstrapCalled is true'); + } + } + + public static function optionValueToString(string|int|float|bool $optVal): string + { + if (is_string($optVal)) { + return $optVal; + } + + if (is_bool($optVal)) { + return BoolUtil::toString($optVal); + } + + return strval($optVal); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/CurlHandleForTests.php b/tests/ElasticOTelTests/ComponentTests/Util/CurlHandleForTests.php new file mode 100644 index 0000000..cdd16d5 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/CurlHandleForTests.php @@ -0,0 +1,134 @@ +curlHandle = $curlHandle; + } + + public function setOpt(int $option, mixed $value): bool + { + Assert::assertNotNull($this->curlHandle); + return curl_setopt($this->curlHandle, $option, $value); + } + + /** + * @param array $options + */ + public function setOptArray(array $options): bool + { + Assert::assertNotNull($this->curlHandle); + return curl_setopt_array($this->curlHandle, $options); + } + + public function exec(): string|bool + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + Assert::assertNotNull($this->curlHandle); + + $verboseOutputFilePath = $this->resourcesClient->createTempFile('curl verbose output'); + $dbgCtx->add(compact('verboseOutputFilePath')); + /** @var null|resource|false $verboseOutputFile */ + $verboseOutputFile = null; + $isAfterCurlExec = false; + try { + $verboseOutputFile = fopen($verboseOutputFilePath, 'w'); // open file for write + Assert::assertIsResource($verboseOutputFile, 'Failed to open temp file for curl verbose output; ' . LoggableToString::convert(compact('verboseOutputFilePath'))); + Assert::assertTrue($this->setOpt(CURLOPT_VERBOSE, true)); + Assert::assertTrue($this->setOpt(CURLOPT_STDERR, $verboseOutputFile)); + $retVal = curl_exec($this->curlHandle); + $isAfterCurlExec = true; + } finally { + if (is_resource($verboseOutputFile)) { + Assert::assertTrue(fflush($verboseOutputFile)); + Assert::assertTrue(fclose($verboseOutputFile)); + if ($isAfterCurlExec) { + $verboseOutput = file_get_contents($verboseOutputFilePath); + Assert::assertIsString($verboseOutput); + $this->lastVerboseOutput = $verboseOutput; + } + Assert::assertTrue(unlink($verboseOutputFilePath)); + $verboseOutputFile = null; + } + } + + return $retVal; + } + + public function error(): string + { + Assert::assertNotNull($this->curlHandle); + return curl_error($this->curlHandle); + } + + public function errno(): int + { + Assert::assertNotNull($this->curlHandle); + return curl_errno($this->curlHandle); + } + + public function lastVerboseOutput(): ?string + { + return $this->lastVerboseOutput; + } + + /** + * @return array + */ + public function getInfo(): array + { + Assert::assertNotNull($this->curlHandle); + return curl_getinfo($this->curlHandle); + } + + public function getResponseStatusCode(): mixed + { + Assert::assertNotNull($this->curlHandle); + return curl_getinfo($this->curlHandle, CURLINFO_RESPONSE_CODE); + } + + public function close(): void + { + Assert::assertNotNull($this->curlHandle); + curl_close($this->curlHandle); + $this->curlHandle = null; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/EnvVarUtilForTests.php b/tests/ElasticOTelTests/ComponentTests/Util/EnvVarUtilForTests.php new file mode 100644 index 0000000..d1de872 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/EnvVarUtilForTests.php @@ -0,0 +1,74 @@ +assertObjectMatchesTraitImpl($this, $actual); + } + + protected static function assertMatchesValue(mixed $expected, mixed $actual): void + { + if ($expected === null) { + return; + } + + if (is_object($expected)) { + self::assertObjectMatches($expected, $actual); + return; + } + + Assert::assertSame($expected, $actual); + } + + protected static function assertObjectMatches(object $expected, mixed $actual): void + { + if ($expected instanceof ExpectationsInterface) { + $expected->assertMatchesMixed($actual); + return; + } + + static::assertObjectMatchesTraitImpl($expected, $actual); + } + + protected static function assertObjectMatchesTraitImpl(object $expected, mixed $actual): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + Assert::assertIsObject($actual); + + /** @var string $propName */ + foreach ($expected as $propName => $expectationsPropValue) { // @phpstan-ignore foreach.nonIterable + $dbgCtx->add(compact('propName', 'expectationsPropValue')); + Assert::assertTrue(property_exists($actual, $propName)); + static::assertMatchesValue($expectationsPropValue, $actual->$propName); + } + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ExportedData.php b/tests/ElasticOTelTests/ComponentTests/Util/ExportedData.php new file mode 100644 index 0000000..f3e196e --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/ExportedData.php @@ -0,0 +1,37 @@ +addIntakeApiRequest($event); + } elseif ($event instanceof AgentToOTelCollectorConnectionStarted) { + $this->addNewConnection($event); + } + } + } + + private function addNewConnection(AgentToOTelCollectorConnectionStarted $event): void + { + if ($this->openIntakeApiConnection === null) { + Assert::assertCount(0, $this->openIntakeApiConnectionRequests); + } else { + $this->closedIntakeApiConnections[] = new IntakeApiConnection($this->openIntakeApiConnection, $this->openIntakeApiConnectionRequests); + $this->openIntakeApiConnectionRequests = []; + } + + $this->openIntakeApiConnection = $event; + } + + private function addIntakeApiRequest(IntakeApiRequest $intakeApiRequest): void + { + $this->openIntakeApiConnectionRequests[] = $intakeApiRequest; + + $newDataParsed = IntakeApiRequestDeserializer::deserialize($intakeApiRequest); + ArrayUtilForTests::append(from: $newDataParsed->spans, to: $this->spans); + } + + public function isEnough(IsEnoughExportedDataInterface $isEnoughExportedData): bool + { + return $isEnoughExportedData->isEnough($this->spans); + } + + public function getAccumulatedData(): ExportedData + { + $intakeApiConnections = $this->closedIntakeApiConnections; + if ($this->openIntakeApiConnection !== null) { + $intakeApiConnections[] = new IntakeApiConnection($this->openIntakeApiConnection, $this->openIntakeApiConnectionRequests); + } + return new ExportedData(new RawExportedData($intakeApiConnections), $this->spans); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/GlobalTestInfra.php b/tests/ElasticOTelTests/ComponentTests/Util/GlobalTestInfra.php new file mode 100644 index 0000000..298cbf4 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/GlobalTestInfra.php @@ -0,0 +1,115 @@ +resourcesCleaner = $this->startResourcesCleaner(); + $this->mockOTelCollector = $this->startMockOTelCollector($this->resourcesCleaner); + } + + public function onTestStart(): void + { + $this->cleanTestScoped(); + } + + public function onTestEnd(): void + { + $this->cleanTestScoped(); + } + + private function cleanTestScoped(): void + { + $this->mockOTelCollector->cleanTestScoped(); + $this->resourcesCleaner->cleanTestScoped(); + } + + public function getResourcesCleaner(): ResourcesCleanerHandle + { + return $this->resourcesCleaner; + } + + public function getMockOTelCollector(): MockOTelCollectorHandle + { + return $this->mockOTelCollector; + } + + /** + * @return int[] + */ + public function getPortsInUse(): array + { + return $this->portsInUse; + } + + /** + * @param int[] $ports + * + * @return void + */ + private function addPortsInUse(array $ports): void + { + foreach ($ports as $port) { + Assert::assertNotContains($port, $this->portsInUse); + $this->portsInUse[] = $port; + } + } + + private function startResourcesCleaner(): ResourcesCleanerHandle + { + $httpServerHandle = TestInfraHttpServerStarter::startTestInfraHttpServer( + dbgServerDesc: ClassNameUtil::fqToShort(ResourcesCleaner::class), + runScriptName: 'runResourcesCleaner.php', + portsInUse: $this->portsInUse, + portsToAllocateCount: 1, + resourcesCleaner: null, + ); + $this->addPortsInUse($httpServerHandle->ports); + return new ResourcesCleanerHandle($httpServerHandle); + } + + private function startMockOTelCollector(ResourcesCleanerHandle $resourcesCleaner): MockOTelCollectorHandle + { + $httpServerHandle = TestInfraHttpServerStarter::startTestInfraHttpServer( + dbgServerDesc: ClassNameUtil::fqToShort(MockOTelCollector::class), + runScriptName: 'runMockOTelCollector.php', + portsInUse: $this->portsInUse, + portsToAllocateCount: 2, + resourcesCleaner: $resourcesCleaner, + ); + $this->addPortsInUse($httpServerHandle->ports); + return new MockOTelCollectorHandle($httpServerHandle); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostHandle.php new file mode 100644 index 0000000..30ff9b4 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostHandle.php @@ -0,0 +1,73 @@ +logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + } + + /** @inheritDoc */ + #[Override] + public function execAppCode(AppCodeTarget $appCodeTarget, ?Closure $setParamsFunc = null): void + { + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Sending HTTP request to app code ...', compact('appCodeTarget')); + $this->sendHttpRequestToAppCode($appCodeTarget, $setParamsFunc); + } + + /** + * @param null|Closure(HttpAppCodeRequestParams): void $setParamsFunc + */ + private function sendHttpRequestToAppCode(AppCodeTarget $appCodeTarget, ?Closure $setParamsFunc = null): void + { + $requestParams = $this->buildRequestParams($appCodeTarget); + if ($setParamsFunc !== null) { + $setParamsFunc($requestParams); + } + + $appCodeInvocation = $this->beforeAppCodeInvocation($requestParams); + HttpClientUtilForTests::sendRequestToAppCode($requestParams); + $this->afterAppCodeInvocation($appCodeInvocation); + } + + public function buildRequestParams(AppCodeTarget $appCodeTarget): HttpAppCodeRequestParams + { + return new HttpAppCodeRequestParams($this->httpServerHandle, $appCodeTarget); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostParams.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostParams.php new file mode 100644 index 0000000..8bf877c --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpAppCodeHostParams.php @@ -0,0 +1,28 @@ +spawnedProcessInternalId, $appCodeTarget); + + $this->urlParts = new UrlParts(scheme: HttpSchemes::HTTP, host: HttpServerHandle::CLIENT_LOCALHOST_ADDRESS, port: $httpServerHandle->getMainPort(), path: self::DEFAULT_HTTP_REQUEST_URL_PATH); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpClientUtilForTests.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpClientUtilForTests.php new file mode 100644 index 0000000..3617e3b --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpClientUtilForTests.php @@ -0,0 +1,159 @@ +loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + } + + return self::$logger; + } + + public static function sendRequestToAppCode(HttpAppCodeRequestParams $requestParams): ResponseInterface + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $localLogger = self::getLogger()->inherit()->addAllContext(compact('requestParams')); + $loggerProxyDebug = $localLogger->ifDebugLevelEnabledNoLine(__FUNCTION__); + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Sending HTTP request to app code...'); + + $response = HttpClientUtilForTests::sendRequest($requestParams->httpRequestMethod, $requestParams->urlParts, $requestParams->dataPerRequest); + $actualResponseStatusCode = $response->getStatusCode(); + $localLogger->addAllContext(compact('actualResponseStatusCode')); + $dbgCtx->add(compact('actualResponseStatusCode')); + + if ($requestParams->expectedHttpResponseStatusCode !== null) { + TestCase::assertSame($requestParams->expectedHttpResponseStatusCode, $actualResponseStatusCode); + } + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Successfully sent HTTP request to app code'); + return $response; + } + + /** + * @param array $headers + * + * @noinspection PhpDocMissingThrowsInspection + */ + public static function sendRequest(string $httpMethod, UrlParts $urlParts, TestInfraDataPerRequest $dataPerRequest, array $headers = []): ResponseInterface + { + $localLogger = self::getLogger()->inherit()->addAllContext(compact('httpMethod', 'urlParts', 'dataPerRequest', 'headers')); + ($loggerProxyDebug = $localLogger->ifDebugLevelEnabledNoLine(__FUNCTION__)); + + $baseUrl = UrlUtil::buildRequestBaseUrl($urlParts); + $urlRelPart = UrlUtil::buildRequestMethodArg($urlParts); + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, "Sending HTTP request...", compact('baseUrl', 'urlRelPart')); + + $client = new Client(['base_uri' => $baseUrl]); + $response = $client->request( + $httpMethod, + $urlRelPart, + [ + RequestOptions::HEADERS => + $headers + + [RequestHeadersRawSnapshotSource::optionNameToHeaderName(OptionForTestsName::data_per_request->name) => PhpSerializationUtil::serializeToString($dataPerRequest)], + /* + * http://docs.guzzlephp.org/en/stable/request-options.html#http-errors + * + * http_errors + * + * Set to false to disable throwing exceptions on an HTTP protocol errors (i.e., 4xx and 5xx responses). + * Exceptions are thrown by default when HTTP protocol errors are encountered. + */ + RequestOptions::HTTP_ERRORS => false, + /* + * https://docs.guzzlephp.org/en/stable/request-options.html#connect-timeout + * + * connect-timeout + * + * Float describing the number of seconds to wait while trying to connect to a server. + * Use 0 to wait indefinitely (the default behavior). + */ + RequestOptions::CONNECT_TIMEOUT => self::CONNECT_TIMEOUT_SECONDS, + /* + * https://docs.guzzlephp.org/en/stable/request-options.html#timeout + * + * timeout + * + * Float describing the total timeout of the request in seconds. + * Use 0 to wait indefinitely (the default behavior). + */ + RequestOptions::TIMEOUT => self::TIMEOUT_SECONDS, + ] + ); + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Sent HTTP request', ['response status code' => $response->getStatusCode()]); + return $response; + } + + /** @noinspection PhpUnused */ + public static function createCurlHandleToSendRequestToAppCode(UrlParts $urlParts, TestInfraDataPerRequest $dataPerRequest, ResourcesClient $resourcesClient): CurlHandleForTests + { + $curlInitRetVal = curl_init(UrlUtil::buildFullUrl($urlParts)); + Assert::assertInstanceOf(CurlHandle::class, $curlInitRetVal); + $curlHandle = new CurlHandleForTests($curlInitRetVal, $resourcesClient); + $dataPerRequestHeaderName = RequestHeadersRawSnapshotSource::optionNameToHeaderName(OptionForTestsName::data_per_request->name); + $dataPerRequestHeaderVal = PhpSerializationUtil::serializeToString($dataPerRequest); + $curlHandle->setOpt(CURLOPT_HTTPHEADER, [$dataPerRequestHeaderName . ': ' . $dataPerRequestHeaderVal]); + return $curlHandle; + } + + /** + * @param array> $headers + */ + public static function getSingleHeaderValue(string $headerName, array $headers): string + { + Assert::assertTrue(ArrayUtil::getValueIfKeyExists($headerName, $headers, /* out */ $values)); + return ArrayUtilForTests::getSingleValue($values); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpServerHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerHandle.php new file mode 100644 index 0000000..f538d5f --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerHandle.php @@ -0,0 +1,84 @@ +ports); + return $this->ports[0]; + } + + /** + * @param array $headers + */ + public function sendRequest(string $httpMethod, string $path, array $headers = []): ResponseInterface + { + return HttpClientUtilForTests::sendRequest( + $httpMethod, + new UrlParts(port: $this->getMainPort(), path: $path), + new TestInfraDataPerRequest(spawnedProcessInternalId: $this->spawnedProcessInternalId), + $headers + ); + } + + public function signalAndWaitForItToExit(): void + { + $response = $this->sendRequest(HttpMethods::POST, TestInfraHttpServerProcessBase::EXIT_URI_PATH); + Assert::assertSame(HttpStatusCodes::OK, $response->getStatusCode()); + + $hasExited = ProcessUtil::waitForProcessToExit( + $this->dbgServerDesc, + $this->spawnedProcessOsId, + 10 * 1000 * 1000 /* <- maxWaitTimeInMicroseconds - 10 seconds */ + ); + Assert::assertTrue($hasExited); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpServerProcessTrait.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerProcessTrait.php new file mode 100644 index 0000000..d8a5e1f --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerProcessTrait.php @@ -0,0 +1,72 @@ +dataPerProcess()->thisSpawnedProcessInternalId; + if ($expectedSpawnedProcessInternalId !== $receivedSpawnedProcessInternalId) { + return self::buildErrorResponse( + HttpStatusCodes::BAD_REQUEST, + 'Received server ID does not match the expected one.' + . ' Expected: ' . $expectedSpawnedProcessInternalId + . ', received: ' . $receivedSpawnedProcessInternalId + ); + } + + return null; + } + + protected static function buildErrorResponse(int $status, string $message): ResponseInterface + { + return new Response( + $status, + // headers: + [ + 'Content-Type' => 'application/json', + ], + // body: + JsonUtil::encode(['message' => $message], /* prettyPrint: */ true) + ); + } + + protected static function buildDefaultResponse(): ResponseInterface + { + return new Response(); + } + + protected static function buildResponseWithPid(): ResponseInterface + { + return Response::json([HttpServerHandle::PID_KEY => getmypid()]); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/HttpServerStarter.php b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerStarter.php new file mode 100644 index 0000000..b4559bd --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/HttpServerStarter.php @@ -0,0 +1,243 @@ +logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + } + + /** + * @param int[] $ports + */ + abstract protected function buildCommandLine(array $ports): string; + + /** + * @param int[] $ports + * + * @return EnvVars + */ + abstract protected function buildEnvVarsForSpawnedProcess(string $spawnedProcessInternalId, array $ports): array; + + /** + * @param int[] $portsInUse + * @param int $portsToAllocateCount + * + * @return HttpServerHandle + */ + protected function startHttpServer(array $portsInUse, int $portsToAllocateCount = 1): HttpServerHandle + { + Assert::assertGreaterThanOrEqual(1, $portsToAllocateCount); + /** @var ?int $lastTriedPort */ + $lastTriedPort = ArrayUtilForTests::isEmpty($portsInUse) ? null : ArrayUtilForTests::getLastValue($portsInUse); + for ($tryCount = 0; $tryCount < self::MAX_TRIES_TO_START_SERVER; ++$tryCount) { + /** @var int[] $currentTryPorts */ + $currentTryPorts = []; + self::findFreePortsToListen($portsInUse, $portsToAllocateCount, $lastTriedPort, /* out */ $currentTryPorts); + Assert::assertSame($portsToAllocateCount, count($currentTryPorts)); + /** + * We repeat $currentTryPorts type to fix PHPStan's + * "Unable to resolve the template type T in call to method static method" error + * + * @var int[] $currentTryPorts + * @noinspection PhpRedundantVariableDocTypeInspection + */ + $lastTriedPort = ArrayUtilForTests::getLastValue($currentTryPorts); + $currentTrySpawnedProcessInternalId = InfraUtilForTests::generateSpawnedProcessInternalId(); + $cmdLine = $this->buildCommandLine($currentTryPorts); + $envVars = $this->buildEnvVarsForSpawnedProcess($currentTrySpawnedProcessInternalId, $currentTryPorts); + + $logger = $this->logger->inherit()->addAllContext( + array_merge( + ['dbgServerDesc' => $this->dbgServerDesc, 'maxTries' => self::MAX_TRIES_TO_START_SERVER], + compact('tryCount', 'currentTryPorts', 'currentTrySpawnedProcessInternalId', 'cmdLine', 'envVars') + ) + ); + $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Starting HTTP server...'); + ProcessUtil::startBackgroundProcess($cmdLine, $envVars); + + $pid = -1; + if ($this->isHttpServerRunning($currentTrySpawnedProcessInternalId, $currentTryPorts[0], $logger, /* ref */ $pid)) { + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Started HTTP server', compact('pid')); + return new HttpServerHandle($this->dbgServerDesc, $pid, $currentTrySpawnedProcessInternalId, $currentTryPorts); + } + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Failed to start HTTP server'); + } + + throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Failed to start HTTP server', ['dbgServerDesc' => $this->dbgServerDesc])); + } + + /** + * @param int[] $portsInUse + * @param ?int $lastTriedPort + * @param int $portsToFindCount + * @param int[] &$result + * + * @return void + */ + private static function findFreePortsToListen( + array $portsInUse, + int $portsToFindCount, + ?int $lastTriedPort, + array &$result + ): void { + $result = []; + $lastTriedPortLocal = $lastTriedPort; + foreach (RangeUtil::generateUpTo($portsToFindCount) as $ignored) { + $foundPort = self::findFreePortToListen($portsInUse, $lastTriedPortLocal); + $result[] = $foundPort; + $lastTriedPortLocal = $foundPort; + } + } + + /** + * @param int[] $portsInUse + * @param ?int $lastTriedPort + * + * @return int + */ + private static function findFreePortToListen(array $portsInUse, ?int $lastTriedPort): int + { + $calcNextInCircularPortRange = function (int $port): int { + return $port === (self::PORTS_RANGE_END - 1) ? self::PORTS_RANGE_BEGIN : ($port + 1); + }; + + $portToStartSearchFrom = $lastTriedPort === null + ? RandomUtil::generateIntInRange(self::PORTS_RANGE_BEGIN, self::PORTS_RANGE_END - 1) + : $calcNextInCircularPortRange($lastTriedPort); + $candidate = $portToStartSearchFrom; + while (true) { + if (!in_array($candidate, $portsInUse)) { + break; + } + $candidate = $calcNextInCircularPortRange($candidate); + if ($candidate === $portToStartSearchFrom) { + TestCase::fail( + 'Could not find a free port' + . LoggableToString::convert( + [ + 'portsInUse' => $portsInUse, + 'portToStartSearchFrom' => $portToStartSearchFrom, + ] + ) + ); + } + } + return $candidate; + } + + private function isHttpServerRunning(string $spawnedProcessInternalId, int $port, Logger $logger, int &$pid): bool + { + /** @var ?Throwable $lastThrown */ + $lastThrown = null; + $dataPerRequest = new TestInfraDataPerRequest(spawnedProcessInternalId: $spawnedProcessInternalId); + $checkResult = (new PollingCheck( + $this->dbgServerDesc . ' started', + self::MAX_WAIT_SERVER_START_MICROSECONDS + ))->run( + function () use ($port, $dataPerRequest, $logger, &$lastThrown, &$pid) { + try { + $response = HttpClientUtilForTests::sendRequest( + HttpMethods::GET, + new UrlParts(host: HttpServerHandle::CLIENT_LOCALHOST_ADDRESS, port: $port, path: HttpServerHandle::STATUS_CHECK_URI_PATH), + $dataPerRequest + ); + } catch (Throwable $throwable) { + ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->logThrowable($throwable, 'Caught while checking if HTTP server is running'); + $lastThrown = $throwable; + return false; + } + + if ($response->getStatusCode() !== HttpStatusCodes::OK) { + ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + 'Received non-OK status code in response to status check', + ['receivedStatusCode' => $response->getStatusCode()] + ); + return false; + } + + /** @var array $decodedBody */ + $decodedBody = JsonUtil::decode($response->getBody()->getContents(), asAssocArray: true); + TestCase::assertArrayHasKey(HttpServerHandle::PID_KEY, $decodedBody); + $receivedPid = $decodedBody[HttpServerHandle::PID_KEY]; + TestCase::assertIsInt($receivedPid, LoggableToString::convert(['$decodedBody' => $decodedBody])); + $pid = $receivedPid; + + ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('HTTP server status is OK', ['PID' => $pid]); + return true; + } + ); + + if (!$checkResult) { + if ($lastThrown === null) { + ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Failed to send request to check HTTP server status'); + } else { + ($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->logThrowable($lastThrown, 'Failed to send request to check HTTP server status'); + } + } + + return $checkResult; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/IdGenerator.php b/tests/ElasticOTelTests/ComponentTests/Util/IdGenerator.php new file mode 100644 index 0000000..f3d13cc --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/IdGenerator.php @@ -0,0 +1,65 @@ + $binaryId + */ + public static function convertBinaryIdToString(array $binaryId): string + { + $result = ''; + for ($i = 0; $i < count($binaryId); ++$i) { + $result .= sprintf('%02x', $binaryId[$i]); + } + return $result; + } + + /** + * @return array + */ + private static function generateBinaryId(int $idLengthInBytes): array + { + $result = []; + for ($i = 0; $i < $idLengthInBytes; ++$i) { + $result[] = mt_rand(0, 255); + } + return $result; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/IdValidationUtil.php b/tests/ElasticOTelTests/ComponentTests/Util/IdValidationUtil.php new file mode 100644 index 0000000..869c9f5 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/IdValidationUtil.php @@ -0,0 +1,51 @@ +spawnedProcessInternalId, + resourcesCleanerPort: $resourcesCleaner?->getMainPort(), + thisSpawnedProcessInternalId: $targetSpawnedProcessInternalId, + thisServerPorts: $targetServerPorts + ); + } + + /** + * @phpstan-param EnvVars $baseEnvVars + * @phpstan-param int[] $targetServerPorts + * + * @return EnvVars + */ + public static function addTestInfraDataPerProcessToEnvVars( + array $baseEnvVars, + string $targetSpawnedProcessInternalId, + array $targetServerPorts, + ?ResourcesCleanerHandle $resourcesCleaner, + string $dbgProcessName + ): array { + $dataPerProcessEnvVarName = OptionForTestsName::data_per_process->toEnvVarName(); + $dataPerProcess = self::buildTestInfraDataPerProcess($targetSpawnedProcessInternalId, $targetServerPorts, $resourcesCleaner); + $result = $baseEnvVars; + $additionalEnvVars = [ + SpawnedProcessBase::DBG_PROCESS_NAME_ENV_VAR_NAME => $dbgProcessName, + $dataPerProcessEnvVarName => PhpSerializationUtil::serializeToString($dataPerProcess), + ]; + ArrayUtilForTests::append(from: $additionalEnvVars, to: $result); + ksort(/* ref */ $result); + return $result; + } + + public static function buildAppCodePhpCmd(): string + { + $result = AmbientContextForTests::testConfig()->appCodePhpExe ?? 'php'; + + if (($extBinary = AmbientContextForTests::testConfig()->appCodeExtBinary) !== null) { + $result .= ' -d "extension=' . $extBinary . '"'; + } + + if (($bootstrapPhpPartFile = AmbientContextForTests::testConfig()->appCodeBootstrapPhpPartFile) !== null) { + $bootstrapPhpPartFileIniOptName = IniRawSnapshotSource::DEFAULT_PREFIX . OptionForProdName::bootstrap_php_part_file->name; + $result .= ' -d "' . $bootstrapPhpPartFileIniOptName . '=' . $bootstrapPhpPartFile . '"'; + } + + return $result; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiConnection.php b/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiConnection.php new file mode 100644 index 0000000..882d662 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiConnection.php @@ -0,0 +1,36 @@ + $headers + */ + public function __construct( + MonotonicTime $monotonicTime, + SystemTime $systemTime, + public readonly array $headers, + public readonly string $bodyBase64Encoded, + ) { + parent::__construct($monotonicTime, $systemTime); + } + + /** + * @return string[] + */ + protected static function propertiesExcludedFromLog(): array + { + return ['bodyBase64Encoded']; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiRequestDeserializer.php b/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiRequestDeserializer.php new file mode 100644 index 0000000..c1dde7a --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/IntakeApiRequestDeserializer.php @@ -0,0 +1,142 @@ +loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('intakeApiRequest')); + $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Processing intake API request'); + + $body = base64_decode($intakeApiRequest->bodyBase64Encoded); + Assert::assertIsString($body); // @phpstan-ignore staticMethod.alreadyNarrowedType + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Body size: ' . strlen($body)); + + $contentLength = HttpClientUtilForTests::getSingleHeaderValue(HttpHeaderNames::CONTENT_LENGTH, $intakeApiRequest->headers); + AssertEx::stringSameAsInt(strlen($body), $contentLength); + + $contentType = HttpClientUtilForTests::getSingleHeaderValue(HttpHeaderNames::CONTENT_TYPE, $intakeApiRequest->headers); + Assert::assertSame(HttpContentTypes::PROTOBUF, $contentType); + + $serializer = ProtobufSerializer::getDefault(); + $exportTraceServiceRequest = new ExportTraceServiceRequest(); + $serializer->hydrate($exportTraceServiceRequest, $body); + + return new ParsedExportedData(self::deserializeSpans($exportTraceServiceRequest, $logger)); + } + + /** + * @param array $repeatedFieldNameToObj + * + * @return array + */ + private static function buildLogContextForRepeatedField(array $repeatedFieldNameToObj): array + { + $repeatedFieldName = array_key_first($repeatedFieldNameToObj); + return ['count(' . $repeatedFieldName . ')' => count($repeatedFieldNameToObj[$repeatedFieldName])]; + } + + /** + * @return Span[] + */ + private static function deserializeSpans(ExportTraceServiceRequest $exportTraceServiceRequest, Logger $logger): array + { + $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + + $resourceSpansRepeatedField = $exportTraceServiceRequest->getResourceSpans(); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', self::buildLogContextForRepeatedField(compact('resourceSpansRepeatedField'))); + + $result = []; + foreach ($resourceSpansRepeatedField as $resourceSpans) { + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', compact('resourceSpans')); + Assert::assertInstanceOf(ResourceSpans::class, $resourceSpans); + $scopeSpansRepeatedField = $resourceSpans->getScopeSpans(); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', self::buildLogContextForRepeatedField(compact('scopeSpansRepeatedField'))); + foreach ($scopeSpansRepeatedField as $scopeSpans) { + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', compact('scopeSpans')); + Assert::assertInstanceOf(ScopeSpans::class, $scopeSpans); + $spansRepeatedField = $scopeSpans->getSpans(); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', self::buildLogContextForRepeatedField(compact('spansRepeatedField'))); + foreach ($spansRepeatedField as $otelProtoSpan) { + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, '', compact('otelProtoSpan')); + Assert::assertInstanceOf(OTelProtoSpan::class, $otelProtoSpan); + $span = new Span($otelProtoSpan); + if (($reason = self::reasonToDiscard($span)) !== null) { + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Span discarded', compact('reason', 'span')); + continue; + } + $result[] = $span; + } + } + } + return $result; + } + + private static function reasonToDiscard(Span $span): ?string + { + /** @var string[] $attributesToCheckForTestsInfraUrlSubPath */ + static $attributesToCheckForTestsInfraUrlSubPath = [TraceAttributes::URL_PATH, TraceAttributes::URL_FULL, TraceAttributes::URL_ORIGINAL]; + foreach ($attributesToCheckForTestsInfraUrlSubPath as $attributeName) { + if (($reason = self::reasonToDiscardIfOptionalAttributeContainsString($span->attributes, $attributeName, TestInfraHttpServerProcessBase::BASE_URI_PATH)) !== null) { + return $reason; + } + } + + return null; + } + + /** @noinspection PhpSameParameterValueInspection */ + private static function reasonToDiscardIfOptionalAttributeContainsString(SpanAttributes $attributes, string $attributeName, string $subString): ?string + { + if ( + (($attributeValue = $attributes->tryToGetString($attributeName)) !== null) + && + str_contains($attributeValue, $subString) + ) { + return 'Attribute (key: `' . $attributeName . '\', value: `' . $attributeValue . '\') contains `' . $subString . '\''; + } + + return null; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/IsEnoughExportedDataInterface.php b/tests/ElasticOTelTests/ComponentTests/Util/IsEnoughExportedDataInterface.php new file mode 100644 index 0000000..eac829c --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/IsEnoughExportedDataInterface.php @@ -0,0 +1,32 @@ + */ + private array $pendingDataRequests = []; + private readonly Logger $logger; + private Clock $clock; + + public function __construct() + { + $this->clock = new Clock(AmbientContextForTests::loggerFactory()); + $this->cleanTestScoped(); + + /** @noinspection PhpUnhandledExceptionInspection */ + parent::__construct(); + + $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + } + + protected function expectedPortsCount(): int + { + return 2; + } + + #[Override] + protected function onNewConnection(int $socketIndex, ConnectionInterface $connection): void + { + parent::onNewConnection($socketIndex, $connection); + Assert::assertCount(2, $this->serverSockets); + Assert::assertLessThan(count($this->serverSockets), $socketIndex); + + // $socketIndex 0 is used for test infrastructure communication + // $socketIndex 1 is used for APM Agent <-> Server communication + if ($socketIndex == 1) { + $newEvent = new AgentToOTelCollectorConnectionStarted( + $this->clock->getMonotonicClockCurrentTime(), + $this->clock->getSystemClockCurrentTime(), + ); + $this->addAgentToOTeCollectorEvent($newEvent); + } + } + + private function addAgentToOTeCollectorEvent(AgentToOTeCollectorEvent $event): void + { + Assert::assertNotNull($this->reactLoop); + $this->agentToOTeCollectorEvents[] = $event; + + foreach ($this->pendingDataRequests as $pendingDataRequest) { + $this->reactLoop->cancelTimer($pendingDataRequest->timer); + ($pendingDataRequest->callToSendResponse)($this->fulfillDataRequest($pendingDataRequest->fromIndex)); + } + $this->pendingDataRequests = []; + } + + /** @inheritDoc */ + #[Override] + protected function processRequest(ServerRequestInterface $request): null|ResponseInterface|Promise + { + if ($request->getUri()->getPath() === self::INTAKE_API_URI) { + return $this->processIntakeApiRequest($request); + } + + if (TextUtil::isPrefixOf(self::MOCK_API_URI_PREFIX, $request->getUri()->getPath())) { + return $this->processMockApiRequest($request); + } + + if ($request->getUri()->getPath() === TestInfraHttpServerProcessBase::CLEAN_TEST_SCOPED_URI_PATH) { + $this->cleanTestScoped(); + return new Response(/* status: */ 200); + } + + return null; + } + + #[Override] + protected function shouldRequestHaveSpawnedProcessInternalId(ServerRequestInterface $request): bool + { + return $request->getUri()->getPath() !== self::INTAKE_API_URI; + } + + private function processIntakeApiRequest(ServerRequestInterface $request): ResponseInterface + { + Assert::assertNotNull($this->reactLoop); + + if ($request->getBody()->getSize() === 0) { + return $this->buildIntakeApiErrorResponse(/* status */ HttpStatusCodes::BAD_REQUEST, 'Intake API request should not have empty body'); + } + + $newRequest = new IntakeApiRequest( + AmbientContextForTests::clock()->getMonotonicClockCurrentTime(), + AmbientContextForTests::clock()->getSystemClockCurrentTime(), + $request->getHeaders(), // @phpstan-ignore argument.type + base64_encode($request->getBody()->getContents()), + ); + + ($loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__)) + && $loggerProxyDebug->log(__LINE__, 'Received request for Intake API', ['newRequest' => $newRequest]); + + if ($loggerProxyDebug !== null) { + $exportedData = IntakeApiRequestDeserializer::deserialize($newRequest); + if ($exportedData->isEmpty()) { + $loggerProxyDebug->log(__LINE__, 'All of the contents has been discarded'); + } else { + $loggerProxyDebug->log(__LINE__, 'Contents', compact('exportedData')); + } + } + + $this->addAgentToOTeCollectorEvent($newRequest); + + return new Response(/* status: */ 202); + } + + /** + * @return ResponseInterface|Promise + */ + private function processMockApiRequest(ServerRequestInterface $request): Promise|ResponseInterface + { + return match ($command = substr($request->getUri()->getPath(), strlen(self::MOCK_API_URI_PREFIX))) { + self::GET_AGENT_TO_OTEL_COLLECTOR_EVENTS_URI_SUBPATH => $this->getIntakeApiRequests($request), + default => $this->buildErrorResponse(HttpStatusCodes::BAD_REQUEST, 'Unknown Mock API command `' . $command . '\''), + }; + } + + /** + * @return ResponseInterface|Promise + */ + private function getIntakeApiRequests(ServerRequestInterface $request): Promise|ResponseInterface + { + $fromIndex = intval(self::getRequiredRequestHeader($request, self::FROM_INDEX_HEADER_NAME)); + $shouldWait = BoolUtil::fromString(self::getRequiredRequestHeader($request, self::SHOULD_WAIT_HEADER_NAME)); + if (!NumericUtil::isInClosedInterval(0, $fromIndex, count($this->agentToOTeCollectorEvents))) { + return $this->buildErrorResponse( + HttpStatusCodes::BAD_REQUEST /* status */, + 'Invalid `' . self::FROM_INDEX_HEADER_NAME . '\' HTTP request header value: ' . $fromIndex + . ' (should be in range[0, ' . count($this->agentToOTeCollectorEvents) . '])' + ); + } + + if ($this->hasNewDataFromAgentRequest($fromIndex) || !$shouldWait) { + return $this->fulfillDataRequest($fromIndex); + } + + /** @var Promise $promise */ + $promise = new Promise( + /** + * @param callable(ResponseInterface): void $callToSendResponse + */ + function (callable $callToSendResponse) use ($fromIndex): void { + $pendingDataRequestId = $this->pendingDataRequestNextId++; + Assert::assertNotNull($this->reactLoop); + $timer = $this->reactLoop->addTimer( + HttpClientUtilForTests::MAX_WAIT_TIME_SECONDS, + function () use ($pendingDataRequestId) { + $this->fulfillTimedOutPendingDataRequest($pendingDataRequestId); + } + ); + ArrayUtilForTests::addAssertingKeyNew($pendingDataRequestId, new MockOTelCollectorPendingDataRequest($fromIndex, $callToSendResponse, $timer), /* n,out */ $this->pendingDataRequests); + } + ); + return $promise; + } + + private function hasNewDataFromAgentRequest(int $fromIndex): bool + { + return count($this->agentToOTeCollectorEvents) > $fromIndex; + } + + private function fulfillDataRequest(int $fromIndex): ResponseInterface + { + $newEvents = $this->hasNewDataFromAgentRequest($fromIndex) ? array_slice($this->agentToOTeCollectorEvents, $fromIndex) : []; + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Sending response ...', ['fromIndex' => $fromIndex, 'newEvents count' => count($newEvents)]); + + return self::encodeResponse($newEvents); + } + + /** + * @param AgentToOTeCollectorEvent[] $events + * + * @noinspection PhpDocMissingThrowsInspection + */ + private static function encodeResponse(array $events): ResponseInterface + { + $eventsWrapped = new AgentToOTeCollectorEvents($events); + return new Response( + status: HttpStatusCodes::OK, + headers: [HttpHeaderNames::CONTENT_TYPE => HttpContentTypes::JSON], + body: JsonUtil::encode([AgentToOTeCollectorEvents::class => PhpSerializationUtil::serializeToString($eventsWrapped)]) + ); + } + + /** + * @return AgentToOTeCollectorEvent[] + * + * @noinspection PhpDocMissingThrowsInspection + */ + public static function decodeResponse(ResponseInterface $response): array + { + $responseBody = $response->getBody()->getContents(); + $contentType = HttpClientUtilForTests::getSingleHeaderValue(HttpHeaderNames::CONTENT_TYPE, $response->getHeaders()); // @phpstan-ignore argument.type + $dbgCtx = ['expected status code' => HttpStatusCodes::OK, 'actual status code' => $response->getStatusCode()]; + ArrayUtilForTests::append(['expected content type' => HttpContentTypes::JSON], to: $dbgCtx); + ArrayUtilForTests::append(compact('contentType', 'responseBody'), to: $dbgCtx); + if ($response->getStatusCode() !== HttpStatusCodes::OK) { + throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Unexpected status code', $dbgCtx)); + } + if ($contentType !== HttpContentTypes::JSON) { + throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Unexpected content type', $dbgCtx)); + } + + $responseBodyDecodedJson = JsonUtil::decode($responseBody, asAssocArray: true); + Assert::assertIsArray($responseBodyDecodedJson); + Assert::assertTrue(ArrayUtil::getValueIfKeyExists(AgentToOTeCollectorEvents::class, $responseBodyDecodedJson, /* out */ $newEventsWrappedSerialized)); + Assert::assertIsString($newEventsWrappedSerialized); + return PhpSerializationUtil::unserializeFromStringAssertType($newEventsWrappedSerialized, AgentToOTeCollectorEvents::class)->events; + } + + private function fulfillTimedOutPendingDataRequest(int $pendingDataRequestId): void + { + + if (!ArrayUtil::removeValue($pendingDataRequestId, $this->pendingDataRequests, /* out */ $pendingDataRequest)) { + // If request is already fulfilled then just return + return; + } + + ($loggerProxy = $this->logger->ifWarningLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Timed out while waiting for ' . self::GET_AGENT_TO_OTEL_COLLECTOR_EVENTS_URI_SUBPATH . ' to be fulfilled - returning empty data set...', compact('pendingDataRequestId')); + + ($pendingDataRequest->callToSendResponse)($this->fulfillDataRequest($pendingDataRequest->fromIndex)); + } + + protected function buildIntakeApiErrorResponse(int $status, string $message): ResponseInterface + { + return new Response(status: $status, headers: [HttpHeaderNames::CONTENT_TYPE => HttpContentTypes::TEXT], body: $message); + } + + private function cleanTestScoped(): void + { + $this->agentToOTeCollectorEvents = []; + $this->pendingDataRequestNextId = 1; + $this->pendingDataRequests = []; + } + + #[Override] + protected function exit(): void + { + Assert::assertNotNull($this->reactLoop); + + foreach ($this->pendingDataRequests as $pendingDataRequest) { + $this->reactLoop->cancelTimer($pendingDataRequest->timer); + } + + parent::exit(); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorHandle.php new file mode 100644 index 0000000..2779f9f --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorHandle.php @@ -0,0 +1,96 @@ +spawnedProcessOsId, + $httpSpawnedProcessHandle->spawnedProcessInternalId, + $httpSpawnedProcessHandle->ports + ); + + $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + } + + public function getPortForAgent(): int + { + Assert::assertCount(2, $this->ports); + return $this->ports[1]; + } + + /** + * @return AgentToOTeCollectorEvent[] + * + * @noinspection PhpDocMissingThrowsInspection + */ + public function fetchNewData(bool $shouldWait): array + { + $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Starting...'); + + $response = $this->sendRequest( + HttpMethods::GET, + MockOTelCollector::MOCK_API_URI_PREFIX . MockOTelCollector::GET_AGENT_TO_OTEL_COLLECTOR_EVENTS_URI_SUBPATH, + [ + MockOTelCollector::FROM_INDEX_HEADER_NAME => strval($this->nextIntakeApiRequestIndexToFetch), + MockOTelCollector::SHOULD_WAIT_HEADER_NAME => BoolUtil::toString($shouldWait), + ] + ); + + $newEvents = MockOTelCollector::decodeResponse($response); + + if (ArrayUtilForTests::isEmpty($newEvents)) { + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Fetched NO new data from agent receiver events'); + } else { + $this->nextIntakeApiRequestIndexToFetch += count($newEvents); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Fetched new data from agent receiver events', ['count(newEvents)' => count($newEvents)]); + } + return $newEvents; + } + + public function cleanTestScoped(): void + { + $this->nextIntakeApiRequestIndexToFetch = 0; + + $response = $this->sendRequest(HttpMethods::POST, TestInfraHttpServerProcessBase::CLEAN_TEST_SCOPED_URI_PATH); + Assert::assertSame(HttpStatusCodes::OK, $response->getStatusCode()); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorPendingDataRequest.php b/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorPendingDataRequest.php new file mode 100644 index 0000000..aedce42 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/MockOTelCollectorPendingDataRequest.php @@ -0,0 +1,40 @@ + + * @phpstan-type OTelAttributesMapIterable iterable + * @phpstan-type IntLimitedToOTelSpanKind OTelSpanKind::KIND_* + */ +class OTelUtil +{ + public static function getTracer(): TracerInterface + { + return Globals::tracerProvider()->getTracer(name: 'co.elastic.php.elastic.component-tests', version: Version::VERSION_1_27_0->value); + } + + /** + * @phpstan-param non-empty-string $spanName + * @phpstan-param IntLimitedToOTelSpanKind $spanKind + * @phpstan-param OTelAttributesMapIterable $attributes + */ + public static function startSpan(TracerInterface $tracer, string $spanName, int $spanKind = OTelSpanKind::KIND_INTERNAL, iterable $attributes = []): OTelApiSpanInterface + { + $parentCtx = Context::getCurrent(); + $newSpanBuilder = $tracer->spanBuilder($spanName)->setParent($parentCtx)->setSpanKind($spanKind)->setAttributes($attributes); + $newSpan = $newSpanBuilder->startSpan(); + $newSpanCtx = $newSpan->storeInContext($parentCtx); + Context::storage()->attach($newSpanCtx); + return $newSpan; + } + + /** + * @param OTelAttributesMapIterable $attributes + */ + public static function endActiveSpan(?Throwable $throwable = null, ?string $errorStatus = null, iterable $attributes = []): void + { + $scope = Context::storage()->scope(); + if ($scope === null) { + return; + } + $scope->detach(); + $span = OTelApiSpan::fromContext($scope->context()); + + $span->setAttributes($attributes); + + if ($errorStatus !== null) { + $span->setAttribute(TraceAttributes::EXCEPTION_MESSAGE, $errorStatus); + $span->setStatus(StatusCode::STATUS_ERROR, $errorStatus); + } + + if ($throwable) { + $span->recordException($throwable); + $span->setStatus(StatusCode::STATUS_ERROR, $throwable->getMessage()); + } + + $span->end(); + } + + /** + * @phpstan-param non-empty-string $spanName + * @phpstan-param IntLimitedToOTelSpanKind $spanKind + * @phpstan-param OTelAttributesMapIterable $attributes + */ + public static function startEndSpan( + TracerInterface $tracer, + string $spanName, + int $spanKind = OTelSpanKind::KIND_INTERNAL, + iterable $attributes = [], + ?Throwable $throwable = null, + ?string $errorStatus = null + ): void { + self::startSpan($tracer, $spanName, $spanKind, $attributes); + self::endActiveSpan($throwable, $errorStatus); + } + + /** + * @param iterable $attributeKeys + * + * @return array + */ + public static function dbgDescForSpan(OTelApiSpanInterface $span, iterable $attributeKeys): array + { + $result = ['class' => get_class($span), 'isRecording' => $span->isRecording()]; + if (method_exists($span, 'getName')) { + $result['name'] = $span->getName(); + } + if (method_exists($span, 'getAttribute')) { + $attributes = []; + foreach ($attributeKeys as $attributeKey) { + $attributes[$attributeKey] = $span->getAttribute($attributeKey); + } + $result['attributes'] = $attributes; + } + return $result; + } + + /** + * @param OTelAttributesMapIterable $attributes + */ + public static function setActiveSpanAttributes(iterable $attributes): void + { + $logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + $logger->addAllContext(compact('attributes')); + + $currentCtx = Context::getCurrent(); + $span = OTelApiSpan::fromContext($currentCtx); + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Before setting attributes', ['span' => self::dbgDescForSpan($span, IterableUtil::keys($attributes))]); + $span->setAttributes($attributes); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'After setting attributes', ['span' => self::dbgDescForSpan($span, IterableUtil::keys($attributes))]); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ParsedExportedData.php b/tests/ElasticOTelTests/ComponentTests/Util/ParsedExportedData.php new file mode 100644 index 0000000..b5650df --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/ParsedExportedData.php @@ -0,0 +1,146 @@ +spans); + } + + /** + * @return Span[] + */ + public function findSpansByName(string $name): array + { + $result = []; + foreach ($this->spans as $span) { + if ($span->name === $name) { + $result[] = $span; + } + } + return $result; + } + + /** + * @noinspection PhpUnused + */ + public function singleSpanByName(string $name): Span + { + $spans = $this->findSpansByName($name); + Assert::assertCount(1, $spans); + return $spans[0]; + } + + /** + * @return iterable + * + * @noinspection PhpUnused + */ + public function findChildSpans(string $parentId): iterable + { + foreach ($this->spans as $span) { + if ($span->parentId === $parentId) { + yield $span; + } + } + } + + /** + * @return iterable + */ + public function findRootSpans(): iterable + { + foreach ($this->spans as $span) { + if ($span->parentId === null) { + yield $span; + } + } + } + + public function singleRootSpan(): Span + { + return IterableUtil::singleValue($this->findRootSpans()); + } + + public function singleChildSpan(string $parentId): Span + { + return IterableUtil::singleValue($this->findChildSpans($parentId)); + } + + /** + * @param non-empty-string $attributeName + * @param SpanAttributeValue $attributeValueToFind + * + * @return iterable + */ + public function findSpansWithAttributeValue(string $attributeName, array|bool|float|int|null|string $attributeValueToFind): iterable + { + foreach ($this->spans as $span) { + if ($span->attributes->tryToGetValue($attributeName, /* out */ $actualAttributeValue) && $actualAttributeValue === $attributeValueToFind) { + yield $span; + } + } + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/PhpSerializationUtil.php b/tests/ElasticOTelTests/ComponentTests/Util/PhpSerializationUtil.php new file mode 100644 index 0000000..a671530 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/PhpSerializationUtil.php @@ -0,0 +1,80 @@ + $checksum, self::DATA_KEY => $data]); + } + + public static function unserializeFromString(string $serialized): mixed + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $decodedJson = JsonUtil::decode($serialized, asAssocArray: true); + $dbgCtx->add(compact('decodedJson')); + Assert::assertIsArray($decodedJson); + Assert::assertTrue(ArrayUtil::getValueIfKeyExists(self::CHECKSUM_KEY, $decodedJson, /* out */ $receivedChecksum)); + $dbgCtx->add(compact('receivedChecksum')); + Assert::assertTrue(ArrayUtil::getValueIfKeyExists(self::DATA_KEY, $decodedJson, /* out */ $data)); + $dbgCtx->add(compact('data')); + Assert::assertIsString($data); + Assert::assertSame($receivedChecksum, crc32($data)); + Assert::assertNotFalse($compressed = base64_decode($data, strict: true)); + Assert::assertNotFalse($serialized = gzuncompress($compressed)); + return unserialize($serialized); + } + + /** + * @template T of object + * + * @param class-string $className + * + * @phpstan-return T + */ + public static function unserializeFromStringAssertType(string $serialized, string $className): object + { + Assert::assertTrue(class_exists($className)); + $obj = self::unserializeFromString($serialized); + Assert::assertInstanceOf($className, $obj); + return $obj; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/PollingCheck.php b/tests/ElasticOTelTests/ComponentTests/Util/PollingCheck.php new file mode 100644 index 0000000..9e3d877 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/PollingCheck.php @@ -0,0 +1,115 @@ +dbgDesc = $dbgDesc; + $this->maxWaitTimeInMicroseconds = $maxWaitTimeInMicroseconds; + $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + } + + /** + * @param Closure(): bool $check + */ + public function run(Closure $check): bool + { + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + 'Starting to check if ' . $this->dbgDesc . '...', + ['maxWaitTime' => TimeUtil::formatDurationInMicroseconds($this->maxWaitTimeInMicroseconds)] + ); + + $numberOfAttempts = 0; + $sinceStarted = new Stopwatch(); + $sinceLastReport = new Stopwatch(); + while (true) { + ++$numberOfAttempts; + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Starting attempt ' . $numberOfAttempts . ' to check if ' . $this->dbgDesc . '...'); + /** @noinspection PhpIfWithCommonPartsInspection */ + if ($check()) { + $elapsedTime = $sinceStarted->elapsedInMicroseconds(); + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + 'Successfully completed checking if ' . $this->dbgDesc, + ['elapsedTime' => TimeUtil::formatDurationInMicroseconds($elapsedTime)] + ); + return true; + } + + $elapsedTime = $sinceStarted->elapsedInMicroseconds(); + if ($elapsedTime >= $this->maxWaitTimeInMicroseconds) { + break; + } + + if ($sinceLastReport->elapsedInMicroseconds() >= $this->reportIntervalInMicroseconds) { + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + 'Still checking if ' . $this->dbgDesc . '...', + [ + 'elapsedTime' => TimeUtil::formatDurationInMicroseconds($elapsedTime), + 'numberOfAttempts' => $numberOfAttempts, + 'maxWaitTime' => TimeUtil::formatDurationInMicroseconds($this->maxWaitTimeInMicroseconds), + ] + ); + $sinceLastReport->restart(); + } + + ($loggerProxy = $this->logger->ifTraceLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + 'Sleeping ' . TimeUtil::formatDurationInMicroseconds($this->sleepTimeInMicroseconds) + . ' before checking again if ' . $this->dbgDesc + . ' (numberOfAttempts: ' . $numberOfAttempts . ')' + . '...' + ); + usleep($this->sleepTimeInMicroseconds); + } + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + 'Reached max wait time while checking if ' . $this->dbgDesc, + [ + 'elapsedTime' => TimeUtil::formatDurationInMicroseconds($sinceStarted->elapsedInMicroseconds()), + 'numberOfAttempts' => $numberOfAttempts, + 'maxWaitTime' => TimeUtil::formatDurationInMicroseconds($this->maxWaitTimeInMicroseconds), + ] + ); + return false; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ProcessUtil.php b/tests/ElasticOTelTests/ComponentTests/Util/ProcessUtil.php new file mode 100644 index 0000000..708f689 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/ProcessUtil.php @@ -0,0 +1,163 @@ +run( + function () use ($pid) { + return !self::doesProcessExist($pid); + } + ); + } + + public static function terminateProcess(int $pid): bool + { + exec("kill $pid > /dev/null", /* ref */ $cmdOutput, /* ref */ $cmdExitCode); + return $cmdExitCode === 0; + } + + /** + * @phpstan-param EnvVars $envVars + */ + public static function startBackgroundProcess(string $cmd, array $envVars): void + { + self::startProcessImpl("$cmd > /dev/null &", $envVars, descriptorSpec: [], isBackground: true); + } + + /** + * @phpstan-param EnvVars $envVars + */ + public static function startProcessAndWaitUntilExit(string $cmd, array $envVars, bool $shouldCaptureStdOutErr = false, ?int $expectedExitCode = null): int + { + $descriptorSpec = []; + $tempOutputFilePath = ''; + if ($shouldCaptureStdOutErr) { + $tempOutputFilePath = FileUtil::createTempFile(dbgTempFilePurpose: 'spawn process stdout and stderr'); + $descriptorSpec[1] = [self::PROC_OPEN_DESCRIPTOR_FILE_TYPE, $tempOutputFilePath, "w"]; // 1 - stdout + $descriptorSpec[2] = [self::PROC_OPEN_DESCRIPTOR_FILE_TYPE, $tempOutputFilePath, "w"]; // 2 - stderr + } + + $hasReturnedExitCode = false; + $exitCode = -1; + try { + $exitCode = self::startProcessImpl($cmd, $envVars, $descriptorSpec, isBackground: false); + $hasReturnedExitCode = true; + } finally { + $logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + $logLevel = $hasReturnedExitCode ? LogLevel::debug : LogLevel::error; + $logCtx = []; + if ($hasReturnedExitCode) { + $logCtx['exit code'] = $exitCode; + } + if ($shouldCaptureStdOutErr) { + $logCtx['file for stdout + stderr'] = $tempOutputFilePath; + if (file_exists($tempOutputFilePath)) { + $logCtx['stdout + stderr'] = file_get_contents($tempOutputFilePath); + Assert::assertTrue(unlink($tempOutputFilePath)); + } + } + + ($loggerProxy = $logger->ifLevelEnabled($logLevel, __LINE__, __FUNCTION__)) + && $loggerProxy->log($cmd . ' exited', $logCtx); + + if ($expectedExitCode !== null && $hasReturnedExitCode) { + TestCase::assertSame($expectedExitCode, $exitCode, LoggableToString::convert($logCtx)); + } + } + + return $exitCode; + } + + /** + * @phpstan-param EnvVars $envVars + * @phpstan-param array $descriptorSpec + */ + private static function startProcessImpl(string $adaptedCmd, array $envVars, array $descriptorSpec, bool $isBackground): int + { + $logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + $logger->addAllContext(compact('adaptedCmd', 'envVars', 'isBackground')); + + $loggerProxyDebug = $logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Starting process...'); + + $pipes = []; + $openedProc = proc_open($adaptedCmd, $descriptorSpec, /* ref */ $pipes, /* cwd */ null, $envVars); + if ($openedProc === false) { + ($loggerProxyError = $logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxyError->log('Failed to start process'); + throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Failed to start process', compact('adaptedCmd', 'envVars'))); + } + + $newProcessInfo = proc_get_status($openedProc); + $pid = ArrayUtil::getValueIfKeyExistsElse('pid', $newProcessInfo, null); + $logger->addAllContext(compact('pid', 'newProcessInfo')); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Process started'); + + if ($isBackground) { + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Waiting for process to exit...'); + } + $exitCode = proc_close($openedProc); + $logger->addAllContext(compact('exitCode')); + if ($exitCode === SpawnedProcessBase::FAILURE_PROCESS_EXIT_CODE) { + ($loggerProxyError = $logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxyError->log('Process exited with the failure exit code'); + throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Process exited with the failure exit code', compact('exitCode', 'newProcessInfo', 'adaptedCmd', 'envVars'))); + } + + if ($isBackground) { + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Process to exited'); + } + + return $exitCode; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/RawExportedData.php b/tests/ElasticOTelTests/ComponentTests/Util/RawExportedData.php new file mode 100644 index 0000000..e725eac --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/RawExportedData.php @@ -0,0 +1,47 @@ + + * + * @noinspection PhpUnused + */ + public function getAllIntakeApiRequests(): iterable + { + foreach ($this->intakeApiConnections as $intakeApiConnection) { + yield from $intakeApiConnection->requests; + } + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/RequestHeadersRawSnapshotSource.php b/tests/ElasticOTelTests/ComponentTests/Util/RequestHeadersRawSnapshotSource.php new file mode 100644 index 0000000..995120d --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/RequestHeadersRawSnapshotSource.php @@ -0,0 +1,71 @@ +getHeaderValue = $getHeaderValue; + } + + public static function optionNameToHeaderName(string $optName): string + { + return self::HEADER_NAMES_PREFIX . strtoupper($optName); + } + + /** @inheritDoc */ + #[Override] + public function currentSnapshot(array $optionNameToMeta): RawSnapshotInterface + { + /** @var array $optionNameToHeaderValue */ + $optionNameToHeaderValue = []; + + foreach ($optionNameToMeta as $optionName => $optionMeta) { + $headerValue = ($this->getHeaderValue)(self::optionNameToHeaderName($optionName)); + if ($headerValue !== null) { + $optionNameToHeaderValue[$optionName] = $headerValue; + } + } + + return new RawSnapshotFromArray($optionNameToHeaderValue); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleaner.php b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleaner.php new file mode 100644 index 0000000..f2eeec9 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleaner.php @@ -0,0 +1,226 @@ + */ + private Set $globalFilesToDeletePaths; + + /** @var Set */ + private Set $testScopedFilesToDeletePaths; + + /** @var Set */ + private Set $globalProcessesToTerminateIds; + + /** @var Set */ + private Set $testScopedProcessesToTerminateIds; + + private ?TimerInterface $parentProcessTrackingTimer = null; + + private Logger $logger; + + public function __construct() + { + $this->globalFilesToDeletePaths = new Set(); + $this->testScopedFilesToDeletePaths = new Set(); + + $this->globalProcessesToTerminateIds = new Set(); + $this->testScopedProcessesToTerminateIds = new Set(); + + $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + + parent::__construct(); + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Done'); + } + + #[Override] + protected function beforeLoopRun(): void + { + parent::beforeLoopRun(); + + Assert::assertNotNull($this->reactLoop); + $this->parentProcessTrackingTimer = $this->reactLoop->addPeriodicTimer( + 1 /* interval in seconds */, + function () { + $rootProcessId = AmbientContextForTests::testConfig()->dataPerProcess()->rootProcessId; + if (!ProcessUtil::doesProcessExist($rootProcessId)) { + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Detected that parent process does not exist'); + $this->exit(); + } + } + ); + } + + #[Override] + protected function exit(): void + { + $this->cleanSpawnedProcesses(isTestScopedOnly: false); + $this->cleanFiles(isTestScopedOnly: false); + + Assert::assertNotNull($this->reactLoop); + Assert::assertNotNull($this->parentProcessTrackingTimer); + $this->reactLoop->cancelTimer($this->parentProcessTrackingTimer); + + parent::exit(); + } + + private function cleanSpawnedProcesses(bool $isTestScopedOnly): void + { + $this->cleanSpawnedProcessesFrom(/* dbgFilesSetDesc */ 'test scoped', $this->testScopedProcessesToTerminateIds); + if (!$isTestScopedOnly) { + $this->cleanSpawnedProcessesFrom(/* dbgFilesSetDesc */ 'global', $this->globalProcessesToTerminateIds); + } + } + + private function cleanTestScoped(): void + { + $this->cleanSpawnedProcesses(isTestScopedOnly: true); + $this->cleanFiles(isTestScopedOnly: true); + } + + /** + * @param Set $processesToTerminateIds + */ + private function cleanSpawnedProcessesFrom(string $dbgProcessSetDesc, Set $processesToTerminateIds): void + { + $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + + $processesToTerminateIdsCount = $processesToTerminateIds->count(); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Terminating spawned processes ()...', compact('dbgProcessSetDesc', 'processesToTerminateIdsCount')); + + foreach ($processesToTerminateIds as $spawnedProcessesId) { + if (!ProcessUtil::doesProcessExist($spawnedProcessesId)) { + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Spawned process does not exist anymore - no need to terminate', compact('spawnedProcessesId')); + continue; + } + $hasExitedNormally = ProcessUtil::terminateProcess($spawnedProcessesId); + $hasExited = ProcessUtil::waitForProcessToExit(/* dbgProcessDesc: */ 'Spawned', $spawnedProcessesId, /* maxWaitTimeInMicroseconds = 10 seconds */ 10 * 1000 * 1000); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Issued command to terminate spawned process', compact('spawnedProcessesId', 'hasExited', 'hasExitedNormally')); + } + + $processesToTerminateIds->clear(); + } + + private function cleanFiles(bool $isTestScopedOnly): void + { + $this->cleanFilesFrom(/* dbgFilesSetDesc */ 'test scoped', $this->testScopedFilesToDeletePaths); + if (!$isTestScopedOnly) { + $this->cleanFilesFrom(/* dbgFilesSetDesc */ 'global', $this->globalFilesToDeletePaths); + } + } + + /** + * @param Set $filesToDeletePaths + */ + private function cleanFilesFrom(string $dbgFilesSetDesc, Set $filesToDeletePaths): void + { + $filesToDeletePathsCount = $filesToDeletePaths->count(); + $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Deleting files...', compact('dbgFilesSetDesc', 'filesToDeletePathsCount')); + + foreach ($filesToDeletePaths as $fileToDeletePath) { + if (!file_exists($fileToDeletePath)) { + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'File does not exist - so there is nothing to delete', compact('fileToDeletePath')); + continue; + } + + $unlinkRetVal = unlink($fileToDeletePath); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Called unlink() to delete file', compact('fileToDeletePath', 'unlinkRetVal')); + } + + $filesToDeletePaths->clear(); + } + + /** @inheritDoc */ + #[Override] + protected function processRequest(ServerRequestInterface $request): ?ResponseInterface + { + switch ($request->getUri()->getPath()) { + case self::REGISTER_PROCESS_TO_TERMINATE_URI_PATH: + $this->registerProcessToTerminate($request); + break; + case self::REGISTER_FILE_TO_DELETE_URI_PATH: + $this->registerFileToDelete($request); + break; + case self::CLEAN_TEST_SCOPED_URI_PATH: + $this->cleanTestScoped(); + break; + default: + return null; + } + return self::buildDefaultResponse(); + } + + protected function registerProcessToTerminate(ServerRequestInterface $request): void + { + $pid = intval(self::getRequiredRequestHeader($request, self::PID_QUERY_HEADER_NAME)); + $isTestScopedAsString = self::getRequiredRequestHeader($request, self::IS_TEST_SCOPED_QUERY_HEADER_NAME); + $isTestScoped = JsonUtil::decode($isTestScopedAsString, asAssocArray: true); + $processesToTerminateIds = $isTestScoped ? $this->testScopedProcessesToTerminateIds : $this->globalProcessesToTerminateIds; + $processesToTerminateIds->add($pid); + $processesToTerminateIdsCount = $processesToTerminateIds->count(); + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Successfully registered process to terminate', compact('pid', 'isTestScoped', 'processesToTerminateIdsCount')); + } + + protected function registerFileToDelete(ServerRequestInterface $request): void + { + $path = self::getRequiredRequestHeader($request, self::PATH_QUERY_HEADER_NAME); + $isTestScopedAsString = self::getRequiredRequestHeader($request, self::IS_TEST_SCOPED_QUERY_HEADER_NAME); + $isTestScoped = JsonUtil::decode($isTestScopedAsString, asAssocArray: true); + $filesToDeletePaths = $isTestScoped ? $this->testScopedFilesToDeletePaths : $this->globalFilesToDeletePaths; + $filesToDeletePaths->add($path); + $filesToDeletePathsCount = $filesToDeletePaths->count(); + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Successfully registered file to delete', compact('path', 'isTestScoped', 'filesToDeletePathsCount')); + } + + #[Override] + protected function shouldRegisterThisProcessWithResourcesCleaner(): bool + { + return false; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleanerHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleanerHandle.php new file mode 100644 index 0000000..b60771c --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesCleanerHandle.php @@ -0,0 +1,57 @@ +spawnedProcessOsId, + $httpSpawnedProcessHandle->spawnedProcessInternalId, + $httpSpawnedProcessHandle->ports + ); + + $this->resourcesClient = new ResourcesClient($this->spawnedProcessInternalId, $this->getMainPort()); + } + + public function getClient(): ResourcesClient + { + return $this->resourcesClient; + } + + public function cleanTestScoped(): void + { + $response = $this->sendRequest(HttpMethods::POST, TestInfraHttpServerProcessBase::CLEAN_TEST_SCOPED_URI_PATH); + Assert::assertSame(HttpStatusCodes::OK, $response->getStatusCode()); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/ResourcesClient.php b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesClient.php new file mode 100644 index 0000000..e535e62 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/ResourcesClient.php @@ -0,0 +1,100 @@ +logger = $this->buildLogger(); + } + + public function buildLogger(): Logger + { + return AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + } + + /** + * @return list + */ + public function __sleep(): array + { + $result = []; + /** @var string $propName */ + foreach ($this as $propName => $_) { // @phpstan-ignore foreach.nonIterable + if ($propName === 'logger') { + continue; + } + $result[] = $propName; + } + return $result; + } + + public function __wakeup(): void + { + $this->logger = $this->buildLogger(); + } + + /** @noinspection PhpSameParameterValueInspection */ + private function registerFileToDelete(string $fullPath, bool $isTestScoped): void + { + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Registering file to delete with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class), compact('fullPath')); + + $response = HttpClientUtilForTests::sendRequest( + HttpMethods::POST, + new UrlParts(port: $this->resourcesCleanerPort, path: ResourcesCleaner::REGISTER_FILE_TO_DELETE_URI_PATH), + new TestInfraDataPerRequest(spawnedProcessInternalId: $this->resourcesCleanerSpawnedProcessInternalId), + [ResourcesCleaner::PATH_QUERY_HEADER_NAME => $fullPath, ResourcesCleaner::IS_TEST_SCOPED_QUERY_HEADER_NAME => BoolUtil::toString($isTestScoped)] /* <- headers */ + ); + if ($response->getStatusCode() !== HttpStatusCodes::OK) { + throw new ComponentTestsInfraException('Failed to register with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class)); + } + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Successfully registered file to delete with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class), compact('fullPath')); + } + + public function createTempFile(?string $dbgTempFilePurpose = null, bool $shouldBeDeletedOnTestExit = true): string + { + $tempFileFullPath = FileUtil::createTempFile($dbgTempFilePurpose); + if ($shouldBeDeletedOnTestExit) { + $this->registerFileToDelete($tempFileFullPath, isTestScoped: true); + } + return $tempFileFullPath; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/Span.php b/tests/ElasticOTelTests/ComponentTests/Util/Span.php new file mode 100644 index 0000000..83682e2 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/Span.php @@ -0,0 +1,84 @@ +attributes = new SpanAttributes($protoSpan->getAttributes()); + $this->id = self::convertId($protoSpan->getSpanId()); + $this->kind = SpanKind::fromOTelProtoSpanKind($protoSpan->getKind()); + $this->name = $protoSpan->getName(); + $this->parentId = self::convertNullableId($protoSpan->getParentSpanId()); + $this->traceId = self::convertId($protoSpan->getTraceId()); + } + + private static function convertNullableId(string $binaryId): ?string + { + return TextUtil::isEmptyString($binaryId) ? null : self::convertId($binaryId); + } + + private static function convertId(string $binaryId): string + { + Assert::assertFalse(TextUtil::isEmptyString($binaryId)); + + /** @var int[] $idAsBytesSeq */ + $idAsBytesSeq = []; + foreach (TextUtilForTests::iterateOverChars($binaryId) as $binaryIdCharAsInt) { + $idAsBytesSeq[] = $binaryIdCharAsInt; + } + + return IdGenerator::convertBinaryIdToString($idAsBytesSeq); + } + + public function toLog(LogStreamInterface $stream): void + { + /** @var array $asMap */ + $asMap = []; + $asMap['name'] = $this->name; + $asMap['kind'] = $this->kind->name; + $asMap['id'] = $this->id; + $asMap['traceId'] = $this->traceId; + if ($this->parentId !== null) { + $asMap['parentId'] = $this->parentId; + } + $asMap['attributes'] = $this->attributes; + $stream->toLogAs($asMap); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributes.php b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributes.php new file mode 100644 index 0000000..2815a4f --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributes.php @@ -0,0 +1,229 @@ +|array|bool|float|int|null|string + * + * @implements ArrayReadInterface + */ +final class SpanAttributes implements ArrayReadInterface, Countable, LoggableInterface +{ + /** @var array $keyToValueMap */ + private readonly array $keyToValueMap; + + public function __construct(ProtobufRepeatedField $protobufRepeatedField) + { + $this->keyToValueMap = self::convertProtobufRepeatedFieldToMap($protobufRepeatedField); + } + + /** + * @return AttributeValue + */ + private static function extractValue(OTelProtoKeyValue $keyValue): array|bool|float|int|null|string + { + if (!$keyValue->hasValue()) { + return null; + } + + $anyValue = $keyValue->getValue(); + if ($anyValue === null) { + return null; + } + + if ($anyValue->hasArrayValue()) { + $arrayValue = $anyValue->getArrayValue(); + if ($arrayValue === null) { + return null; + } + $result = []; + foreach ($arrayValue->getValues() as $repeatedFieldSubValue) { + $result[] = $repeatedFieldSubValue; + } + return $result; + } + + if ($anyValue->hasBoolValue()) { + return $anyValue->getBoolValue(); + } + + if ($anyValue->hasBytesValue()) { + return IterableUtil::toList(TextUtilForTests::iterateOverChars($anyValue->getBytesValue())); + } + + if ($anyValue->hasDoubleValue()) { + return $anyValue->getDoubleValue(); + } + + if ($anyValue->hasIntValue()) { + $value = $anyValue->getIntValue(); + if (is_int($value)) { + return $value; + } + return AssertEx::stringIsInt($value); + } + + if ($anyValue->hasKvlistValue()) { + $kvListValue = $anyValue->getKvlistValue(); + if ($kvListValue === null) { + return null; + } + $result = []; + foreach ($kvListValue->getValues() as $repeatedFieldSubKey => $repeatedFieldSubValue) { + Assert::assertTrue(is_int($repeatedFieldSubKey) || is_string($repeatedFieldSubKey)); + Assert::assertArrayNotHasKey($repeatedFieldSubKey, $result); + $result[$repeatedFieldSubKey] = $repeatedFieldSubValue; + } + return $result; + } + + if ($anyValue->hasStringValue()) { + return $anyValue->getStringValue(); + } + + Assert::fail('Unknown value type; ' . LoggableToString::convert(compact('keyValue'))); + } + + /** + * @param ProtobufRepeatedField $protobufRepeatedField + * + * @return array + */ + private static function convertProtobufRepeatedFieldToMap(ProtobufRepeatedField $protobufRepeatedField): array + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $result = []; + foreach ($protobufRepeatedField as $keyValue) { + $dbgCtx->add(compact('keyValue')); + Assert::assertInstanceOf(OTelProtoKeyValue::class, $keyValue); + Assert::assertArrayNotHasKey($keyValue->getKey(), $result); + $result[$keyValue->getKey()] = self::extractValue($keyValue); + } + + return $result; + } + + #[Override] + public function keyExists(int|string $key): bool + { + return array_key_exists($key, $this->keyToValueMap); + } + + #[Override] + public function getValue(int|string $key): mixed + { + Assert::assertIsString($key); + Assert::assertTrue(ArrayUtil::getValueIfKeyExists($key, $this->keyToValueMap, /* out */ $attributeValue)); + return $attributeValue; + } + + #[Override] + public function count(): int + { + return count($this->keyToValueMap); + } + + /** + * @param AttributeValue &$attributeValueOut + * + * @param-out AttributeValue $attributeValueOut + */ + public function tryToGetValue(string $attributeName, array|bool|float|int|null|string &$attributeValueOut): bool + { + return ArrayUtil::getValueIfKeyExists($attributeName, $this->keyToValueMap, /* out */ $attributeValueOut); // @phpstan-ignore staticMethod.alreadyNarrowedType + } + + public function tryToGetBool(string $attributeName): ?bool + { + if (!$this->tryToGetValue($attributeName, /* out */ $attributeValue)) { + return null; + } + return AssertEx::isBool($attributeValue); + } + + public function tryToGetFloat(string $attributeName): ?float + { + if (!$this->tryToGetValue($attributeName, /* out */ $attributeValue)) { + return null; + } + return AssertEx::isFloat($attributeValue); + } + + public function tryToGetInt(string $attributeName): ?int + { + if (!$this->tryToGetValue($attributeName, /* out */ $attributeValue)) { + return null; + } + return AssertEx::isInt($attributeValue); + } + + public function tryToGetString(string $attributeName): ?string + { + if (!ArrayUtil::getValueIfKeyExists($attributeName, $this->keyToValueMap, /* out */ $attributeValue)) { + return null; + } + return AssertEx::isString($attributeValue); + } + + public function getBool(string $attributeName): bool + { + return AssertEx::notNull($this->tryToGetBool($attributeName)); + } + + public function getFloat(string $attributeName): float + { + return AssertEx::notNull($this->tryToGetFloat($attributeName)); + } + + public function getInt(string $attributeName): int + { + return AssertEx::notNull($this->tryToGetInt($attributeName)); + } + + public function getString(string $attributeName): string + { + return AssertEx::notNull($this->tryToGetString($attributeName)); + } + + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs($this->keyToValueMap); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesArrayExpectations.php b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesArrayExpectations.php new file mode 100644 index 0000000..0db1888 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesArrayExpectations.php @@ -0,0 +1,46 @@ + + */ +final class SpanAttributesArrayExpectations extends ArrayExpectations +{ + #[Override] + protected function assertValueMatches(string|int $key, mixed $expectedValue, mixed $actualValue): void + { + if ($key === TraceAttributes::URL_SCHEME) { + Assert::assertEqualsIgnoringCase($expectedValue, $actualValue); + } else { + Assert::assertSame($expectedValue, $actualValue); + } + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesExpectations.php b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesExpectations.php new file mode 100644 index 0000000..8a6ed75 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/SpanAttributesExpectations.php @@ -0,0 +1,65 @@ + $attributes + * @param array $notAllowedAttributeNames + */ + public function __construct( + array $attributes, + bool $allowOtherKeysInActual = true, + private readonly array $notAllowedAttributeNames = [] + ) { + $this->arrayExpectations = new SpanAttributesArrayExpectations($attributes, $allowOtherKeysInActual); + } + + #[Override] + public function assertMatchesMixed(mixed $actual): void + { + Assert::assertInstanceOf(SpanAttributes::class, $actual); + $this->assertMatches($actual); + } + + public function assertMatches(SpanAttributes $actual): void + { + $this->arrayExpectations->assertMatches($actual); + + foreach ($this->notAllowedAttributeNames as $notAllowedAttributeName) { + Assert::assertFalse($actual->keyExists($notAllowedAttributeName)); + } + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpanExpectations.php b/tests/ElasticOTelTests/ComponentTests/Util/SpanExpectations.php new file mode 100644 index 0000000..404c6d8 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/SpanExpectations.php @@ -0,0 +1,46 @@ +assertMatchesMixed($actual); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpanKind.php b/tests/ElasticOTelTests/ComponentTests/Util/SpanKind.php new file mode 100644 index 0000000..c479da3 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/SpanKind.php @@ -0,0 +1,58 @@ + self::unspecified, + OTelProtoSpanKind::SPAN_KIND_INTERNAL => self::internal, + OTelProtoSpanKind::SPAN_KIND_CLIENT => self::client, + OTelProtoSpanKind::SPAN_KIND_SERVER => self::server, + OTelProtoSpanKind::SPAN_KIND_PRODUCER => self::producer, + OTelProtoSpanKind::SPAN_KIND_CONSUMER => self::consumer, + ]; + + public static function fromOTelProtoSpanKind(int $otelProtoSpanKind): self + { + if (ArrayUtil::getValueIfKeyExists($otelProtoSpanKind, self::FROM_OTEL_PROTO_SPAN_KIND, /* out */ $result)) { + return $result; + } + Assert::fail('Unexpected span kind: ' . $otelProtoSpanKind); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/SpawnedProcessBase.php b/tests/ElasticOTelTests/ComponentTests/Util/SpawnedProcessBase.php new file mode 100644 index 0000000..27800df --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/SpawnedProcessBase.php @@ -0,0 +1,167 @@ +logger = self::buildLogger()->addAllContext(compact('this')); + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Done', ['AmbientContext::testConfig()' => AmbientContextForTests::testConfig(), 'Environment variables' => EnvVarUtilForTests::getAll()]); + } + + private static function buildLogger(): Logger + { + return AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + } + + protected function processConfig(): void + { + AmbientContextForTests::testConfig()->validateForSpawnedProcess(); + + if ($this->shouldRegisterThisProcessWithResourcesCleaner()) { + TestCase::assertNotNull( + AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerSpawnedProcessInternalId, + LoggableToString::convert(AmbientContextForTests::testConfig()) + ); + TestCase::assertNotNull( + AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerPort, + LoggableToString::convert(AmbientContextForTests::testConfig()) + ); + } + } + + /** + * @param Closure(SpawnedProcessBase): void $runImpl + * + * @throws Throwable + */ + protected static function runSkeleton(Closure $runImpl): void + { + LoggingSubsystem::$isInTestingContext = true; + + try { + $dbgProcessName = EnvVarUtilForTests::get(self::DBG_PROCESS_NAME_ENV_VAR_NAME); + TestCase::assertIsString($dbgProcessName); + + AmbientContextForTests::init($dbgProcessName); + + $thisObj = new static(); // @phpstan-ignore new.static + + if (!$thisObj->shouldTracingBeEnabled()) { + ConfigUtilForTests::verifyTracingIsDisabled(); + } + + $thisObj->processConfig(); + + if ($thisObj->shouldRegisterThisProcessWithResourcesCleaner()) { + $thisObj->registerWithResourcesCleaner(); + } + + $runImpl($thisObj); + } catch (Throwable $throwable) { + $level = LogLevel::critical; + $isFromAppCode = false; + $throwableToLog = $throwable; + if ($throwable instanceof WrappedAppCodeException) { + $isFromAppCode = true; + $level = LogLevel::info; + $throwableToLog = $throwable->wrappedException(); + } + $logger = isset($thisObj) ? $thisObj->logger : self::buildLogger(); + ($loggerProxy = $logger->ifLevelEnabled($level, __LINE__, __FUNCTION__)) + && $loggerProxy->logThrowable($throwableToLog, 'Throwable escaped to the top of the script', compact('isFromAppCode')); + if ($isFromAppCode) { + /** @noinspection PhpUnhandledExceptionInspection */ + throw $throwableToLog; + } else { + exit(self::FAILURE_PROCESS_EXIT_CODE); + } + } + } + + protected function shouldTracingBeEnabled(): bool + { + return false; + } + + protected function shouldRegisterThisProcessWithResourcesCleaner(): bool + { + return true; + } + + protected function isThisProcessTestScoped(): bool + { + return false; + } + + protected function registerWithResourcesCleaner(): void + { + $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Registering with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class) . '...'); + + TestCase::assertNotNull(AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerPort); + $resCleanerId = AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerSpawnedProcessInternalId; + TestCase::assertNotNull($resCleanerId); + $response = HttpClientUtilForTests::sendRequest( + HttpMethods::POST, + new UrlParts(port: AmbientContextForTests::testConfig()->dataPerProcess()->resourcesCleanerPort, path: ResourcesCleaner::REGISTER_PROCESS_TO_TERMINATE_URI_PATH), + new TestInfraDataPerRequest(spawnedProcessInternalId: $resCleanerId), + [ + ResourcesCleaner::PID_QUERY_HEADER_NAME => strval(getmypid()), + ResourcesCleaner::IS_TEST_SCOPED_QUERY_HEADER_NAME => BoolUtil::toString($this->isThisProcessTestScoped()), + ] + ); + if ($response->getStatusCode() !== HttpStatusCodes::OK) { + throw new ComponentTestsInfraException('Failed to register with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class)); + } + + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Successfully registered with ' . ClassNameUtil::fqToShort(ResourcesCleaner::class)); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/Stopwatch.php b/tests/ElasticOTelTests/ComponentTests/Util/Stopwatch.php new file mode 100644 index 0000000..373a975 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/Stopwatch.php @@ -0,0 +1,51 @@ +clock = AmbientContextForTests::clock(); + $this->restart(); + } + + public function elapsedInMicroseconds(): float + { + return TimeUtil::calcDurationInMicrosecondsClampNegativeToZero($this->timeStarted, $this->clock->getMonotonicClockCurrentTime()); + } + + public function restart(): void + { + $this->timeStarted = $this->clock->getMonotonicClockCurrentTime(); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/TestCaseHandle.php b/tests/ElasticOTelTests/ComponentTests/Util/TestCaseHandle.php new file mode 100644 index 0000000..d149add --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/TestCaseHandle.php @@ -0,0 +1,215 @@ +logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + + $globalTestInfra = ComponentTestsPHPUnitExtension::getGlobalTestInfra(); + $globalTestInfra->onTestStart(); + $this->resourcesCleaner = $globalTestInfra->getResourcesCleaner(); + $this->MockOTelCollector = $globalTestInfra->getMockOTelCollector(); + $this->portsInUse = $globalTestInfra->getPortsInUse(); + } + + /** + * @param null|Closure(AppCodeHostParams): void $setParamsFunc + */ + public function ensureMainAppCodeHost(?Closure $setParamsFunc = null, string $dbgInstanceName = 'main'): AppCodeHostHandle + { + if ($this->mainAppCodeHost === null) { + $this->mainAppCodeHost = $this->startAppCodeHost( + function (AppCodeHostParams $params) use ($setParamsFunc): void { + $this->autoSetProdOptions($params); + if ($setParamsFunc !== null) { + $setParamsFunc($params); + } + }, + $dbgInstanceName + ); + } + return $this->mainAppCodeHost; + } + + /** + * @param null|Closure(HttpAppCodeHostParams): void $setParamsFunc + * + * @noinspection PhpUnused + */ + public function ensureAdditionalHttpAppCodeHost(string $dbgInstanceName, ?Closure $setParamsFunc = null): HttpAppCodeHostHandle + { + if ($this->additionalHttpAppCodeHost === null) { + $this->additionalHttpAppCodeHost = $this->startBuiltinHttpServerAppCodeHost( + function (HttpAppCodeHostParams $appCodeHostParams) use ($setParamsFunc): void { + $this->autoSetProdOptions($appCodeHostParams); + if ($setParamsFunc !== null) { + $setParamsFunc($appCodeHostParams); + } + }, + $dbgInstanceName + ); + } + return $this->additionalHttpAppCodeHost; + } + + public function waitForEnoughExportedData(IsEnoughExportedDataInterface $isEnoughExportedData): ExportedData + { + Assert::assertNotEmpty($this->appCodeInvocations); + $dataAccumulator = new ExportedDataAccumulator(); + $hasPassed = (new PollingCheck(__FUNCTION__ . ' passes', intval(TimeUtil::secondsToMicroseconds(self::MAX_WAIT_TIME_DATA_FROM_AGENT_SECONDS))))->run( + function () use ($isEnoughExportedData, $dataAccumulator) { + $dataAccumulator->addAgentToOTeCollectorEvents($this->MockOTelCollector->fetchNewData(shouldWait: true)); + return $dataAccumulator->isEnough($isEnoughExportedData); + } + ); + Assert::assertTrue($hasPassed, 'The expected data from agent has not arrived. ' . LoggableToString::convert(compact('isEnoughExportedData', 'dataAccumulator'))); + + return $dataAccumulator->getAccumulatedData(); + } + + private function autoSetProdOptions(AppCodeHostParams $params): void + { + if ($this->escalatedLogLevelForProdCode !== null) { + $escalatedLogLevelForProdCodeAsString = $this->escalatedLogLevelForProdCode->name; + $params->setProdOption(AmbientContextForTests::testConfig()->escalatedRerunsProdCodeLogLevelOptionName() ?? OptionForProdName::log_level_syslog, $escalatedLogLevelForProdCodeAsString); + } + /** @noinspection HttpUrlsUsage */ + $params->setProdOption(OptionForProdName::exporter_otlp_endpoint, 'http://' . HttpServerHandle::CLIENT_LOCALHOST_ADDRESS . ':' . $this->MockOTelCollector->getPortForAgent()); + } + + public function addAppCodeInvocation(AppCodeInvocation $appCodeInvocation): void + { + $appCodeInvocation->appCodeHostsParams = []; + if ($this->mainAppCodeHost !== null) { + $appCodeInvocation->appCodeHostsParams[] = $this->mainAppCodeHost->appCodeHostParams; + } + if ($this->additionalHttpAppCodeHost !== null) { + $appCodeInvocation->appCodeHostsParams[] = $this->additionalHttpAppCodeHost->appCodeHostParams; + } + $this->appCodeInvocations[] = $appCodeInvocation; + } + + /** + * @return array + */ + public function getProdCodeLogLevels(): array + { + $result = []; + /** @var ?AppCodeHostHandle $appCodeHost */ + foreach ([$this->mainAppCodeHost, $this->additionalHttpAppCodeHost] as $appCodeHost) { + if ($appCodeHost !== null) { + $result[] = $appCodeHost->appCodeHostParams->buildProdConfig()->effectiveLogLevel(); + } + } + return $result; + } + + public function tearDown(): void + { + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Tearing down...'); + + ComponentTestsPHPUnitExtension::getGlobalTestInfra()->onTestEnd(); + } + + /** + * @param int[] $ports + * + * @return void + */ + private function addPortsInUse(array $ports): void + { + foreach ($ports as $port) { + Assert::assertNotContains($port, $this->portsInUse); + $this->portsInUse[] = $port; + } + } + + private function startBuiltinHttpServerAppCodeHost(Closure $setParamsFunc, string $dbgInstanceName): BuiltinHttpServerAppCodeHostHandle + { + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Starting built-in HTTP server to host app code ...', compact('dbgInstanceName')); + + $result = new BuiltinHttpServerAppCodeHostHandle($this, $setParamsFunc, $this->resourcesCleaner, $this->portsInUse, $dbgInstanceName); + $this->addPortsInUse($result->httpServerHandle->ports); + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Started built-in HTTP server to host app code', ['ports' => $result->httpServerHandle->ports]); + + return $result; + } + + /** + * @param Closure(AppCodeHostParams): void $setParamsFunc + */ + private function startAppCodeHost(Closure $setParamsFunc, string $dbgInstanceName): AppCodeHostHandle + { + return match (AmbientContextForTests::testConfig()->appCodeHostKind()) { + AppCodeHostKind::cliScript => new CliScriptAppCodeHostHandle($this, $setParamsFunc, $this->resourcesCleaner, $dbgInstanceName), + AppCodeHostKind::builtinHttpServer => $this->startBuiltinHttpServerAppCodeHost($setParamsFunc, $dbgInstanceName), + }; + } + + /** @noinspection PhpUnused */ + public function getResourcesClient(): ResourcesClient + { + return $this->resourcesCleaner->getClient(); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/TestInfraDataPerProcess.php b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraDataPerProcess.php new file mode 100644 index 0000000..a20a1e5 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraDataPerProcess.php @@ -0,0 +1,39 @@ + $appCodeArguments + */ + public function __construct( + public readonly string $spawnedProcessInternalId, + public readonly ?AppCodeTarget $appCodeTarget = null, + public ?array $appCodeArguments = null, + ) { + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerProcessBase.php b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerProcessBase.php new file mode 100644 index 0000000..8bf5c84 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerProcessBase.php @@ -0,0 +1,288 @@ + $type, 'source code location' => $srcFile . ':' . $srcLine]), + code: 0, + severity: $type, + filename: $srcFile, + line: $srcLine + ); + } + ); + + $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + + parent::__construct(); + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Done'); + } + + #[Override] + protected function processConfig(): void + { + parent::processConfig(); + + Assert::assertCount( + $this->expectedPortsCount(), + AmbientContextForTests::testConfig()->dataPerProcess()->thisServerPorts, + LoggableToString::convert(AmbientContextForTests::testConfig()) + ); + + // At this point request is not parsed and applied to config yet + TestCase::assertNull(AmbientContextForTests::testConfig()->getOptionValueByName(OptionForTestsName::data_per_request)); + } + + /** + * @return int + */ + protected function expectedPortsCount(): int + { + return 1; + } + + protected function onNewConnection(int $socketIndex, ConnectionInterface $connection): void + { + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + 'New connection', + [ + 'socketIndex' => $socketIndex, + 'connection addresses' => [ + 'remote' => $connection->getRemoteAddress(), + 'local' => $connection->getLocalAddress(), + ] + ] + ); + } + + /** + * @return null|ResponseInterface|Promise + */ + abstract protected function processRequest(ServerRequestInterface $request): null|ResponseInterface|Promise; + + public static function run(): void + { + self::runSkeleton( + function (SpawnedProcessBase $thisObj): void { + /** @var self $thisObj */ + $thisObj->runImpl(); + } + ); + } + + public function runImpl(): void + { + $this->runHttpService(); + } + + private function runHttpService(): void + { + $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + + $ports = AmbientContextForTests::testConfig()->dataPerProcess()->thisServerPorts; + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Running HTTP service...', compact('ports')); + + $this->reactLoop = Loop::get(); + TestCase::assertNotEmpty($ports); + foreach ($ports as $port) { + $uri = HttpServerHandle::SERVER_LOCALHOST_ADDRESS . ':' . $port; + $serverSocket = new SocketServer($uri, /* context */ [], $this->reactLoop); + $socketIndex = count($this->serverSockets); + $this->serverSockets[] = $serverSocket; + $serverSocket->on( + 'connection' /* <- event */, + function (ConnectionInterface $connection) use ($socketIndex): void { + $this->onNewConnection($socketIndex, $connection); + } + ); + $httpServer = new HttpServer( + /** + * @return ResponseInterface|Promise + */ + function (ServerRequestInterface $request): ResponseInterface|Promise { + return $this->processRequestWrapper($request); + } + ); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Listening for incoming requests...', ['serverSocket address' => $serverSocket->getAddress()]); + $httpServer->listen($serverSocket); + } + + $this->beforeLoopRun(); + + Assert::assertNotNull($this->reactLoop); + $this->reactLoop->run(); + } + + protected function beforeLoopRun(): void + { + } + + protected function shouldRequestHaveSpawnedProcessInternalId(ServerRequestInterface $request): bool + { + return true; + } + + /** + * @return ResponseInterface|Promise + */ + private function processRequestWrapper(ServerRequestInterface $request): Promise|ResponseInterface + { + $loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__); + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Received request', ['URI' => $request->getUri(), 'method' => $request->getMethod(), 'target' => $request->getRequestTarget()]); + + try { + $response = $this->processRequestWrapperImpl($request); + + if ($response instanceof ResponseInterface) { + $loggerProxyDebug && $loggerProxyDebug->log( + __LINE__, + 'Sending response ...', + ['statusCode' => $response->getStatusCode(), 'reasonPhrase' => $response->getReasonPhrase(), 'body' => $response->getBody()] + ); + } else { + Assert::assertInstanceOf(Promise::class, $response); // @phpstan-ignore staticMethod.alreadyNarrowedType + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Promise returned - response will be returned later...'); + } + + return $response; + } catch (Throwable $throwable) { + ($loggerProxy = $this->logger->ifCriticalLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('processRequest() exited by exception - terminating this process', compact('throwable')); + exit(self::FAILURE_PROCESS_EXIT_CODE); + } + } + + /** + * @return ResponseInterface|Promise + */ + private function processRequestWrapperImpl(ServerRequestInterface $request): Promise|ResponseInterface + { + if ($this->shouldRequestHaveSpawnedProcessInternalId($request)) { + $testConfigForRequest = ConfigUtilForTests::read( + new RequestHeadersRawSnapshotSource( + function (string $headerName) use ($request): ?string { + return self::getRequestHeader($request, $headerName); + } + ), + AmbientContextForTests::loggerFactory() + ); + + $verifySpawnedProcessInternalIdResponse = self::verifySpawnedProcessInternalId( + $testConfigForRequest->dataPerRequest()->spawnedProcessInternalId + ); + if ($verifySpawnedProcessInternalIdResponse !== null) { + return $verifySpawnedProcessInternalIdResponse; + } + } + + if ($request->getUri()->getPath() === HttpServerHandle::STATUS_CHECK_URI_PATH) { + return self::buildResponseWithPid(); + } elseif ($request->getUri()->getPath() === self::EXIT_URI_PATH) { + $this->exit(); + return self::buildDefaultResponse(); + } + + if (($response = $this->processRequest($request)) !== null) { + return $response; + } + + return self::buildErrorResponse( + HttpStatusCodes::BAD_REQUEST, + 'Unknown URI path: `' . $request->getRequestTarget() . '\'' + ); + } + + protected function exit(): void + { + foreach ($this->serverSockets as $serverSocket) { + $serverSocket->close(); + } + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Exiting...'); + } + + protected static function getRequestHeader(ServerRequestInterface $request, string $headerName): ?string + { + $headerValues = $request->getHeader($headerName); + if (ArrayUtilForTests::isEmpty($headerValues)) { + return null; + } + if (count($headerValues) !== 1) { + throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('The header should not have more than one value', compact('headerName', 'headerValues'))); + } + return $headerValues[0]; + } + + protected static function getRequiredRequestHeader(ServerRequestInterface $request, string $headerName): string + { + $headerValue = self::getRequestHeader($request, $headerName); + if ($headerValue === null) { + throw new ComponentTestsInfraException(ExceptionUtil::buildMessage('Missing required HTTP request header', compact('headerName'))); + } + return $headerValue; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerStarter.php b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerStarter.php new file mode 100644 index 0000000..edbf439 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/TestInfraHttpServerStarter.php @@ -0,0 +1,92 @@ +startHttpServer($portsInUse, $portsToAllocateCount); + } + + private function __construct(string $dbgServerDesc, string $runScriptName, ?ResourcesCleanerHandle $resourcesCleaner) + { + parent::__construct($dbgServerDesc); + + $this->runScriptName = $runScriptName; + $this->resourcesCleaner = $resourcesCleaner; + } + + /** @inheritDoc */ + #[Override] + protected function buildCommandLine(array $ports): string + { + $runScriptNameFullPath = FileUtil::listToPath([__DIR__, $this->runScriptName]); + if (!file_exists($runScriptNameFullPath)) { + throw new ConfigException(ExceptionUtil::buildMessage('Run script does not exist', array_merge(['runScriptName' => $this->runScriptName], compact('runScriptNameFullPath')))); + } + + return 'php ' . '"' . FileUtil::listToPath([__DIR__, $this->runScriptName]) . '"'; + } + + /** @inheritDoc */ + #[Override] + protected function buildEnvVarsForSpawnedProcess(string $spawnedProcessInternalId, array $ports): array + { + $baseEnvVars = EnvVarUtilForTests::getAll(); + $additionalEnvVars = [ + OptionForProdName::autoload_enabled->toEnvVarName() => BoolUtil::toString(false), + OptionForProdName::disabled_instrumentations->toEnvVarName() => ConfigUtilForTests::PROD_DISABLED_INSTRUMENTATIONS_ALL, + OptionForProdName::enabled->toEnvVarName() => BoolUtil::toString(false), + ]; + ArrayUtilForTests::append(from: $additionalEnvVars, to: $baseEnvVars); + + return InfraUtilForTests::addTestInfraDataPerProcessToEnvVars( + $baseEnvVars, + $spawnedProcessInternalId, + $ports, + $this->resourcesCleaner, + $this->dbgServerDesc + ); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/UrlParts.php b/tests/ElasticOTelTests/ComponentTests/Util/UrlParts.php new file mode 100644 index 0000000..ceb4eba --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/UrlParts.php @@ -0,0 +1,55 @@ +path + . ', ' + . 'query: ' . $this->query + . '}'; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/UrlUtil.php b/tests/ElasticOTelTests/ComponentTests/Util/UrlUtil.php new file mode 100644 index 0000000..f6d32c8 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/UrlUtil.php @@ -0,0 +1,187 @@ +scheme ?? 'http') . '://'; + $result .= $urlParts->host ?? 'localhost'; + if ($urlParts->port !== null) { + $result .= ':' . $urlParts->port; + } + return $result; + } + + public static function normalizeUrlPath(string $urlPath): string + { + return TextUtil::isPrefixOf('/', $urlPath) ? $urlPath : ('/' . $urlPath); + } + + public static function buildRequestMethodArg(UrlParts $urlParts): string + { + $result = $urlParts->path === null + ? '/' + : self::normalizeUrlPath($urlParts->path); + + if ($urlParts->query !== null) { + $result .= '?' . $urlParts->query; + } + + return $result; + } + + public static function buildFullUrl(UrlParts $urlParts): string + { + return self::buildRequestBaseUrl($urlParts) . self::buildRequestMethodArg($urlParts); + } + + public static function buildUrlPartsWithDefaults( + string $scheme = HttpSchemes::HTTP, + string $host = HttpServerHandle::CLIENT_LOCALHOST_ADDRESS, + ?int $port = null, + string $path = HttpAppCodeRequestParams::DEFAULT_HTTP_REQUEST_URL_PATH, + ?string $query = null, + ): UrlParts { + return new UrlParts(scheme: $scheme, host: $host, port: $port, path: $path, query: $query); + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/WaitForEventCounts.php b/tests/ElasticOTelTests/ComponentTests/Util/WaitForEventCounts.php new file mode 100644 index 0000000..aaaa18f --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/WaitForEventCounts.php @@ -0,0 +1,77 @@ +minSpanCount = $min; + $result->maxSpanCount = $max ?? $min; + + return $result; + } + + private function __construct() + { + $this->logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + } + + public function isEnough(array $spans): bool + { + $spansCount = count($spans); + Assert::assertLessThanOrEqual($this->maxSpanCount, $spansCount); + + $result = $spansCount >= $this->minSpanCount; + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Checked if exported data events counts reached the waited for values', compact('result', 'spansCount', 'this')); + + return $result; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/WrappedAppCodeException.php b/tests/ElasticOTelTests/ComponentTests/Util/WrappedAppCodeException.php new file mode 100644 index 0000000..5a53ecc --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/WrappedAppCodeException.php @@ -0,0 +1,42 @@ +wrappedException = $wrappedException; + } + + public function wrappedException(): Throwable + { + return$this->wrappedException; + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/Util/routeToBuiltinHttpServerAppCodeHost.php b/tests/ElasticOTelTests/ComponentTests/Util/routeToBuiltinHttpServerAppCodeHost.php new file mode 100644 index 0000000..205b434 --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/Util/routeToBuiltinHttpServerAppCodeHost.php @@ -0,0 +1,28 @@ + + */ + public static function dataProviderForTestRunAndEscalateLogLevelOnFailure(): iterable + { + $initialLogLevels = [LogLevel::info, LogLevel::trace, LogLevel::debug]; + + return self::adaptDataProviderForTestBuilderToSmokeToDescToMixedMap( + (new DataProviderForTestBuilder()) + ->addKeyedDimensionOnlyFirstValueCombinable(self::LOG_LEVEL_FOR_PROD_CODE_KEY, $initialLogLevels) + ->addKeyedDimensionOnlyFirstValueCombinable(self::LOG_LEVEL_FOR_TEST_CODE_KEY, $initialLogLevels) + ->addKeyedDimensionOnlyFirstValueCombinable(self::FAIL_ON_RERUN_COUNT_KEY, [1, 2, 3]) + ->addBoolKeyedDimensionOnlyFirstValueCombinable(self::SHOULD_FAIL_KEY) + ->addKeyedDimensionOnlyFirstValueCombinable(OptionForTestsName::escalated_reruns_max_count->name, [2, 0]) + ); + } + + private static function buildFailMessage(int $runCount): string + { + return 'Dummy failed; run count: ' . $runCount; + } + + public static function appCodeForTestRunAndEscalateLogLevelOnFailure(MixedMap $appCodeArgs): void + { + self::appCodeSetsHowFinishedAttributes( + $appCodeArgs, + function () use ($appCodeArgs): void { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $dbgCtx->add(compact('appCodeArgs')); + $dbgCtx->add(['testConfig' => AmbientContextForTests::testConfig()]); + $expectedLogLevelForProdCode = $appCodeArgs->getLogLevel(self::LOG_LEVEL_FOR_PROD_CODE_KEY); + $dbgCtx->add(compact('expectedLogLevelForProdCode')); + $prodConfig = self::buildProdConfigFromAppCode(); + $dbgCtx->add(compact('prodConfig')); + $actualLogLevelForProdCode = $prodConfig->effectiveLogLevel(); + $dbgCtx->add(compact('actualLogLevelForProdCode')); + self::assertSame($expectedLogLevelForProdCode, $actualLogLevelForProdCode); + $expectedLogLevelForTestCode = $appCodeArgs->getLogLevel(self::LOG_LEVEL_FOR_TEST_CODE_KEY); + $dbgCtx->add(compact('expectedLogLevelForTestCode')); + $actualLogLevelForTestCode = AmbientContextForTests::testConfig()->logLevel; + $dbgCtx->add(compact('actualLogLevelForTestCode')); + self::assertSame($expectedLogLevelForTestCode, $actualLogLevelForTestCode); + } + ); + } + + public function test0WithoutEscalation(): void + { + $testCaseHandle = $this->getTestCaseHandle(); + $appCodeHost = $testCaseHandle->ensureMainAppCodeHost(); + $appCodeHost->execAppCode( + AppCodeTarget::asRouted([__CLASS__, 'appCodeForTestRunAndEscalateLogLevelOnFailure']), + function (AppCodeRequestParams $appCodeRequestParams) use ($testCaseHandle): void { + /** @var array $appCodeArgs */ + $appCodeArgs = [ + self::LOG_LEVEL_FOR_PROD_CODE_KEY => ArrayUtilForTests::getSingleValue($testCaseHandle->getProdCodeLogLevels()), + self::LOG_LEVEL_FOR_TEST_CODE_KEY => AmbientContextForTests::testConfig()->logLevel, + ]; + $appCodeRequestParams->setAppCodeArgs($appCodeArgs); + } + ); + + $span = $this->waitForOneSpan($testCaseHandle); + self::assertTrue($span->attributes->getBool(self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY)); + } + + /** + * @return array + */ + private static function unsetLogLevelRelatedEnvVars(): array + { + $envVars = EnvVarUtilForTests::getAll(); + $logLevelRelatedEnvVarsToRestore = []; + foreach (OptionForProdName::getAllLogLevelRelated() as $optName) { + $envVarName = $optName->toEnvVarName(); + if (array_key_exists($envVarName, $envVars)) { + $logLevelRelatedEnvVarsToRestore[$envVarName] = $envVars[$envVarName]; + EnvVarUtilForTests::unset($envVarName); + } else { + $logLevelRelatedEnvVarsToRestore[$envVarName] = null; + } + + self::assertNull(EnvVarUtilForTests::get($envVarName)); + } + return $logLevelRelatedEnvVarsToRestore; + } + + /** + * @dataProvider dataProviderForTestRunAndEscalateLogLevelOnFailure + */ + public function testRunAndEscalateLogLevelOnFailure(MixedMap $testArgs): void + { + $logLevelRelatedEnvVarsToRestore = self::unsetLogLevelRelatedEnvVars(); + $prodCodeSyslogLevelEnvVarName = OptionForProdName::log_level_syslog->toEnvVarName(); + $initialLogLevelForProdCode = $testArgs->getLogLevel(self::LOG_LEVEL_FOR_PROD_CODE_KEY); + EnvVarUtilForTests::set($prodCodeSyslogLevelEnvVarName, $initialLogLevelForProdCode->name); + + $logLevelForTestCodeToRestore = AmbientContextForTests::testConfig()->logLevel; + $initialLogLevelForTestCode = $testArgs->getLogLevel(self::LOG_LEVEL_FOR_TEST_CODE_KEY); + AmbientContextForTests::resetLogLevel($initialLogLevelForTestCode); + + $rerunsMaxCountToRestore = AmbientContextForTests::testConfig()->escalatedRerunsMaxCount; + $rerunsMaxCount = $testArgs->getInt(OptionForTestsName::escalated_reruns_max_count->name); + AmbientContextForTests::resetEscalatedRerunsMaxCount($rerunsMaxCount); + + $initialLevels = []; + foreach (self::LOG_LEVEL_FOR_CODE_KEYS as $levelTypeKey) { + $initialLevels[$levelTypeKey] = $testArgs->getLogLevel($levelTypeKey); + } + $testArgs[self::INITIAL_LOG_LEVELS_KEY] = $initialLevels; + $expectedEscalatedLevelsSeqCount = IterableUtil::count(self::generateLevelsForRunAndEscalateLogLevelOnFailure($initialLevels, $rerunsMaxCount)); + if ($rerunsMaxCount === 0) { + self::assertSame(0, $expectedEscalatedLevelsSeqCount); + } + $failOnRerunCountArg = $testArgs->getInt(self::FAIL_ON_RERUN_COUNT_KEY); + $expectedFailOnRunCount = $failOnRerunCountArg <= $expectedEscalatedLevelsSeqCount ? ($failOnRerunCountArg + 1) : 1; + $expectedMessage = self::buildFailMessage($expectedFailOnRunCount); + $shouldFail = $testArgs->getBool(self::SHOULD_FAIL_KEY); + + $nextRunCount = 1; + try { + self::runAndEscalateLogLevelOnFailure( + self::buildDbgDescForTestWithArgs(__CLASS__, __FUNCTION__, $testArgs), + function () use ($testArgs, &$nextRunCount): void { + $testArgs['currentRunCount'] = $nextRunCount++; + $this->implTestRunAndEscalateLogLevelOnFailure($testArgs); + } + ); + $runAndEscalateLogLevelOnFailureExitedNormally = true; + } catch (PHPUnitFrameworkException $ex) { + $runAndEscalateLogLevelOnFailureExitedNormally = false; + self::assertStringContainsString($expectedMessage, $ex->getMessage()); + } + self::assertSame(!$shouldFail, $runAndEscalateLogLevelOnFailureExitedNormally); + + self::assertSame($rerunsMaxCount, AmbientContextForTests::testConfig()->escalatedRerunsMaxCount); + AmbientContextForTests::resetEscalatedRerunsMaxCount($rerunsMaxCountToRestore); + + self::assertSame($initialLogLevelForTestCode, AmbientContextForTests::testConfig()->logLevel); + AmbientContextForTests::resetLogLevel($logLevelForTestCodeToRestore); + + self::assertSame($initialLogLevelForProdCode->name, EnvVarUtilForTests::get($prodCodeSyslogLevelEnvVarName)); + foreach ($logLevelRelatedEnvVarsToRestore as $envVarName => $envVarValue) { + EnvVarUtilForTests::setOrUnset($envVarName, $envVarValue); + } + } + + private function implTestRunAndEscalateLogLevelOnFailure(MixedMap $testArgs): void + { + $currentRunCount = $testArgs->getInt('currentRunCount'); + self::assertGreaterThanOrEqual(1, $currentRunCount); + $currentReRunCount = $currentRunCount === 1 ? 0 : ($currentRunCount - 1); + $shouldFail = $testArgs->getBool(self::SHOULD_FAIL_KEY); + $failOnRerunCountArg = $testArgs->getInt(self::FAIL_ON_RERUN_COUNT_KEY); + /** @var array $initialLevels */ + $initialLevels = $testArgs->getArray(self::INITIAL_LOG_LEVELS_KEY); + $shouldCurrentRunFail = $shouldFail && ($currentRunCount === 1 || $currentReRunCount === $failOnRerunCountArg); + if ($currentRunCount === 1) { + $expectedLevels = $initialLevels; + } else { + $rerunsMaxCount = $testArgs->getInt(OptionForTestsName::escalated_reruns_max_count->name); + self::assertTrue( + IterableUtil::getNthValue( + self::generateLevelsForRunAndEscalateLogLevelOnFailure($initialLevels, $rerunsMaxCount), + $currentReRunCount - 1, + $expectedLevels /* <- out */ + ) + ); + } + /** @var array $expectedLevels */ + + $testCaseHandle = $this->getTestCaseHandle(); + $appCodeHost = $testCaseHandle->ensureMainAppCodeHost(); + $appCodeHost->execAppCode( + AppCodeTarget::asRouted([__CLASS__, 'appCodeForTestRunAndEscalateLogLevelOnFailure']), + function (AppCodeRequestParams $appCodeRequestParams) use ($expectedLevels): void { + /** @var array $appCodeArgs */ + $appCodeArgs = []; + foreach (self::LOG_LEVEL_FOR_CODE_KEYS as $levelTypeKey) { + $appCodeArgs[$levelTypeKey] = $expectedLevels[$levelTypeKey]; + } + $appCodeRequestParams->setAppCodeArgs($appCodeArgs); + } + ); + + $span = $this->waitForOneSpan($testCaseHandle); + self::assertTrue($span->attributes->getBool(self::DID_APP_CODE_FINISH_SUCCESSFULLY_KEY)); + + if ($shouldCurrentRunFail) { + self::fail(self::buildFailMessage($currentRunCount)); + } + } +} diff --git a/tests/ElasticOTelTests/ComponentTests/bootstrapComponentTests.php b/tests/ElasticOTelTests/ComponentTests/bootstrapComponentTests.php new file mode 100644 index 0000000..f70f3ee --- /dev/null +++ b/tests/ElasticOTelTests/ComponentTests/bootstrapComponentTests.php @@ -0,0 +1,30 @@ + */ + private array $optNameToRawValue = []; + + public function set(string $optName, string $optVal): self + { + $this->optNameToRawValue[$optName] = $optVal; + return $this; + } + + /** @inheritDoc */ + #[Override] + public function currentSnapshot(array $optionNameToMeta): RawSnapshotInterface + { + return new RawSnapshotFromArray($this->optNameToRawValue); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSink.php b/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSink.php new file mode 100644 index 0000000..c192b10 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSink.php @@ -0,0 +1,53 @@ +consumed[] = new MockLogPreformattedSinkStatement( + $statementLevel, + $category, + $srcCodeFile, + $srcCodeLine, + $srcCodeFunc, + $messageWithContext + ); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSinkStatement.php b/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSinkStatement.php new file mode 100644 index 0000000..a5e7fa8 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/Util/MockLogPreformattedSinkStatement.php @@ -0,0 +1,39 @@ + $context + */ + public function __construct( + public LogLevel $statementLevel, + public string $message, + public array $context, + public string $category, + public string $srcCodeFile, + public int $srcCodeLine, + public string $srcCodeFunc, + public ?bool $includeStacktrace, + public int $numberOfStackFramesToSkip + ) { + } +} diff --git a/tests/ElasticOTelTests/UnitTests/Util/SourceCodeLocation.php b/tests/ElasticOTelTests/UnitTests/Util/SourceCodeLocation.php new file mode 100644 index 0000000..b4305c8 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/Util/SourceCodeLocation.php @@ -0,0 +1,33 @@ +, 'n': non-negative-int} + * @phpstan-type TestPopNArgs array{'input': TestPopNInput, 'expectedOutput': array} + */ +final class ArrayUtilTest extends TestCaseBase +{ + use DisableDebugContextTestTrait; + + /** + * @param mixed[] $args + * + * @return void + */ + private static function verifyArgs(array $args): void + { + self::assertCount(1, $args); + $arg0 = $args[0]; + self::assertIsString($arg0); + } + + /** + * @param mixed[] $args + * + * @return void + */ + private static function instrumentationFunc(array $args): void + { + self::assertCount(1, $args); + self::verifyArgs($args); + $someParam =& $args[0]; + self::assertSame('value set by instrumentedFunc caller', $someParam); + $someParam = 'value set by instrumentationFunc'; + } + + private static function instrumentedFunc(string $someParam): string + { + self::instrumentationFunc([&$someParam]); + return $someParam; + } + + public static function testReferencesInArray(): void + { + $instrumentedFuncRetVal = self::instrumentedFunc('value set by instrumentedFunc caller'); + self::assertSame('value set by instrumentationFunc', $instrumentedFuncRetVal); + } + + public static function testRemoveElementFromTwoLevelArrayViaReferenceToFirstLevel(): void + { + $myArr = [ + 'level 1 - a' => [ + 'level 2 - a' => 'value for level 2 - a', + 'level 2 - b' => 'value for level 2 - b', + ] + ]; + $level1ValRef =& $myArr['level 1 - a']; + self::assertArrayHasKey('level 2 - a', $level1ValRef); // @phpstan-ignore staticMethod.alreadyNarrowedType + self::assertSame('value for level 2 - a', $level1ValRef['level 2 - a']); // @phpstan-ignore staticMethod.alreadyNarrowedType + unset($level1ValRef['level 2 - a']); + self::assertArrayNotHasKey('level 2 - a', $myArr['level 1 - a']); + self::assertArrayHasKey('level 2 - b', $myArr['level 1 - a']); // @phpstan-ignore staticMethod.alreadyNarrowedType + } + + public static function testRemoveFirstByValue(): void + { + /** + * @param list $inArray + * @param ?list $expectedOutArray + */ + $testImpl = function (array $inArray, mixed $valueToRemove, ?array $expectedOutArray = null): void { + if ($expectedOutArray !== null) { + self::assertCount(count($inArray) - 1, $expectedOutArray); + } + $actualOutArray = $inArray; + self::assertSame($expectedOutArray !== null, ArrayUtilForTests::removeFirstByValue(/* in,out */ $actualOutArray, $valueToRemove)); + if ($expectedOutArray === null) { + self::assertSame($inArray, $actualOutArray); + } else { + self::assertNotSame($inArray, $actualOutArray); + $actualOutArrayIndexesFixed = [...$actualOutArray]; + AssertEx::arraysHaveTheSameContent($expectedOutArray, $actualOutArrayIndexesFixed); + } + }; + + $testImpl([], 'a'); + $testImpl(['a'], 'a', []); + $testImpl(['a', 'b'], 'b', ['a']); + $testImpl(['a', 'b', 'c'], 'b', ['a', 'c']); + + // The search is for identical value (i.e., using ===) + $testImpl(['1'], 1); + $testImpl(['1'], '1', []); + } + + public static function testRemoveAllValues(): void + { + /** + * @param list $inArray + * @param list $valuesToRemove + * @param ?list $expectedOutArray + * + * @return void + */ + $testImpl = function (array $inArray, array $valuesToRemove, ?array $expectedOutArray = null): void { + if ($expectedOutArray !== null) { + AssertEx::countAtMost(count($inArray) - 1, $expectedOutArray); + self::assertNotEmpty($valuesToRemove); + } + $expectedRemovedCount = $expectedOutArray === null ? 0 : (count($inArray) - count($expectedOutArray)); + $actualOutArray = $inArray; + self::assertSame($expectedRemovedCount, ArrayUtilForTests::removeAllValues(/* in,out */ $actualOutArray, $valuesToRemove)); + if ($expectedOutArray === null) { + self::assertSame($inArray, $actualOutArray); + } else { + self::assertNotSame($inArray, $actualOutArray); + $actualOutArrayIndexesFixed = [...$actualOutArray]; + AssertEx::arraysHaveTheSameContent($expectedOutArray, $actualOutArrayIndexesFixed); + } + }; + + $testImpl([], []); + $testImpl([], ['a']); + + $testImpl(['a'], ['a'], []); + $testImpl(['a', 'b'], ['b'], ['a']); + $testImpl(['a', 'b', 'c'], ['b'], ['a', 'c']); + + $testImpl(['a', 'b', 'c'], ['c', 'b'], ['a']); + $testImpl(['a', 'b', 'c', 'a'], ['c', 'a'], ['b']); + + // The search is for identical value (i.e., using ===) + $testImpl(['1'], [1]); + $testImpl(['1'], ['1'], []); + } + + /** + * @return iterable + */ + public static function dataProviderForTestIterateListInReverse(): iterable + { + yield [[], []]; + yield [[1], [1]]; + yield [['a'], ['a']]; + yield [['a', 'b'], ['b', 'a']]; + yield [[1, 2], [2, 1]]; + yield [['a', 2, 'c'], ['c', 2, 'a']]; + yield [[1, 'b', 3], [3, 'b', 1]]; + } + + /** + * @dataProvider dataProviderForTestIterateListInReverse + * + * @param mixed[] $input + * @param mixed[] $expectedOutput + */ + public static function testIterateListInReverse(array $input, array $expectedOutput): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $actualOutput = IterableUtil::toList(ArrayUtilForTests::iterateListInReverse($input)); + $dbgCtx->add(compact('actualOutput')); + self::assertCount(count($input), $actualOutput); + foreach (IterableUtil::zip($expectedOutput, $actualOutput) as [$expectedValue, $actualValue]) { + $dbgCtx->add(compact('expectedValue', 'actualValue')); + self::assertSame($expectedValue, $actualValue); + } + } + + /** + * @return iterable}> + */ + public static function dataProviderForTestIterateMapInReverse(): iterable + { + yield [[], []]; + yield [['a' => 1], ['a' => 1]]; + yield [[1 => 'a'], [1 => 'a']]; + yield [['a' => 1, 'b' => 2], ['b' => 2, 'a' => 1]]; + yield [[1 => 'a', 2 => 'b'], [2 => 'b', 1 => 'a']]; + yield [['a' => 1, 2 => 'b', 'c' => 3], ['c' => 3, 2 => 'b', 'a' => 1]]; + yield [[1 => 'a', 'b' => 2, 3 => 'c'], [3 => 'c', 'b' => 2, 1 => 'a']]; + } + + /** + * @dataProvider dataProviderForTestIterateMapInReverse + * + * @param array $input + * @param array $expectedOutput + */ + public static function testIterateMapInReverse(array $input, array $expectedOutput): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $actualOutput = IterableUtil::toMap(ArrayUtilForTests::iterateMapInReverse($input)); + $dbgCtx->add(compact('actualOutput')); + self::assertCount(count($input), $actualOutput); + foreach (IterableUtil::zip($expectedOutput, $actualOutput) as [$expectedValue, $actualValue]) { + $dbgCtx->add(compact('expectedValue', 'actualValue')); + self::assertSame($expectedValue, $actualValue); + } + } + + /** + * @template T + * + * @param array $input + * @param non-negative-int $numberOfElementsToPop + * + * @return array + */ + private static function popNOnCopy(array $input, int $numberOfElementsToPop): array + { + ArrayUtilForTests::popN(/* in,out */ $input, $numberOfElementsToPop); + return $input; + } + + /** + * @return iterable + */ + public static function dataProviderForTestPopNForValidInput(): iterable + { + /** + * @return iterable + */ + $genDataSets = function (): iterable { + yield ['input' => ['array' => [], 'n' => 0], 'expectedOutput' => []]; + yield ['input' => ['array' => ['a'], 'n' => 1], 'expectedOutput' => []]; + yield ['input' => ['array' => ['a', 'b'], 'n' => 2], 'expectedOutput' => []]; + yield ['input' => ['array' => ['a', 'b', 'c'], 'n' => 3], 'expectedOutput' => []]; + + yield ['input' => ['array' => ['a'], 'n' => 0], 'expectedOutput' => ['a']]; + yield ['input' => ['array' => ['a', 'b'], 'n' => 0], 'expectedOutput' => ['a', 'b']]; + yield ['input' => ['array' => ['a', 'b', 'c'], 'n' => 0], 'expectedOutput' => ['a', 'b', 'c']]; + + yield ['input' => ['array' => ['a', 'b'], 'n' => 1], 'expectedOutput' => ['a']]; + + yield ['input' => ['array' => ['a', 'b', 'c'], 'n' => 1], 'expectedOutput' => ['a', 'b']]; + yield ['input' => ['array' => ['a', 'b', 'c'], 'n' => 2], 'expectedOutput' => ['a']]; + }; + + return DataProviderForTestBuilder::keyEachDataSetWithDbgDesc($genDataSets); + } + + /** + * @dataProvider dataProviderForTestPopNForValidInput + * + * @param TestPopNInput $input + * @param array $expectedOutput + */ + public static function testPopNForValidInput(array $input, array $expectedOutput): void + { + AssertEx::sameValuesListIterables($expectedOutput, self::popNOnCopy($input['array'], $input['n'])); + } + + /** + * @return iterable + */ + public static function dataProviderForTestPopNForInvalidInput(): iterable + { + yield [fn() => self::popNOnCopy([], 1)]; + yield [fn() => self::popNOnCopy(['a'], 2)]; + yield [fn() => self::popNOnCopy(['a', 'b'], 3)]; + yield [fn() => self::popNOnCopy(['a', 'b', 'c'], 4)]; + yield [fn() => self::popNOnCopy(['a', 'b', 'c'], 5)]; + } + + /** + * @dataProvider dataProviderForTestPopNForInvalidInput + * + * @param callable(): mixed $callThatThrows + */ + public static function testPopNForInvalidInput(callable $callThatThrows): void + { + AssertEx::throws(OutOfBoundsException::class, $callThatThrows); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/BoolUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/BoolUtilTest.php new file mode 100644 index 0000000..743f869 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/BoolUtilTest.php @@ -0,0 +1,58 @@ +> + */ + public static function dataProviderForSplitFqClassName(): array + { + return [ + ['My\\Name\\Space\\MyClass', 'My\\Name\\Space', 'MyClass'], + ['\\My\\Name\\Space\\MyClass', 'My\\Name\\Space', 'MyClass'], + ['\\MyNameSpace\\MyClass', 'MyNameSpace', 'MyClass'], + ['MyNameSpace\\MyClass', 'MyNameSpace', 'MyClass'], + ['\\MyClass', '', 'MyClass'], + ['MyClass', '', 'MyClass'], + ['MyNameSpace\\', 'MyNameSpace', ''], + ['\\MyNameSpace\\', 'MyNameSpace', ''], + ['', '', ''], + ['\\', '', ''], + ['a\\', 'a', ''], + ['\\a\\', 'a', ''], + ['\\b', '', 'b'], + ['\\\\', '', ''], + ['\\\\\\', '\\', ''], + ]; + } + + /** + * @dataProvider dataProviderForSplitFqClassName + * + * @param string $fqClassName + * @param string $expectedNamespace + * @param string $expectedShortName + */ + public function testSplitFqClassName( + string $fqClassName, + string $expectedNamespace, + string $expectedShortName + ): void { + /** @var class-string $fqClassName */ + $actualNamespace = ''; + $actualShortName = ''; + ClassNameUtil::splitFqClassName($fqClassName, /* ref */ $actualNamespace, /* ref */ $actualShortName); + self::assertSame($expectedNamespace, $actualNamespace); + self::assertSame($expectedShortName, $actualShortName); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/CombinatorialUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/CombinatorialUtilTest.php new file mode 100644 index 0000000..ea95891 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/CombinatorialUtilTest.php @@ -0,0 +1,122 @@ + $totalSet + * @param int $subSetSize + * + * @return array> + */ + $permutationsAsArray = function (array $totalSet, int $subSetSize): array { + return IterableUtil::toList(CombinatorialUtil::permutations($totalSet, $subSetSize)); + }; + + self::assertEqualsCanonicalizing([[]], $permutationsAsArray([], 0)); + self::assertEqualsCanonicalizing([[]], $permutationsAsArray(['a'], 0)); + self::assertEqualsCanonicalizing([[]], $permutationsAsArray(['a', 'b'], 0)); + self::assertEqualsCanonicalizing([['a']], $permutationsAsArray(['a'], 1)); + self::assertEqualsCanonicalizing([['a'], ['b']], $permutationsAsArray(['a', 'b'], 1)); + self::assertEqualsCanonicalizing([['a', 'b'], ['b', 'a']], $permutationsAsArray(['a', 'b'], 2)); + self::assertEqualsCanonicalizing([['a'], ['b'], ['c']], $permutationsAsArray(['a', 'b', 'c'], 1)); + self::assertEqualsCanonicalizing( + [ + ['a', 'b'], + ['a', 'c'], + ['b', 'a'], + ['b', 'c'], + ['c', 'a'], + ['c', 'b'], + ], + $permutationsAsArray(['a', 'b', 'c'], 2) + ); + self::assertEqualsCanonicalizing( + [ + ['a', 'b', 'c'], + ['a', 'c', 'b'], + ['b', 'a', 'c'], + ['b', 'c', 'a'], + ['c', 'a', 'b'], + ['c', 'b', 'a'], + ], + $permutationsAsArray(['a', 'b', 'c'], 3) + ); + } + + public function testCartesianProduct(): void + { + /** + * @param array> $iterables + * + * @return array> + */ + $cartesianProductAsArray = function (array $iterables): array { + /** @var array> $iterables */ + return IterableUtil::toList(CombinatorialUtil::cartesianProduct($iterables)); + }; + + self::assertEqualsCanonicalizing([[]], $cartesianProductAsArray([])); + + self::assertEqualsCanonicalizing( + [ + ['digit' => 1], + ['digit' => 2], + ['digit' => 3], + ], + $cartesianProductAsArray(['digit' => [1, 2, 3]]) + ); + + self::assertEqualsCanonicalizing( + [ + ['digit' => 1, 'letter' => 'a'], + ['digit' => 1, 'letter' => 'b'], + ['digit' => 2, 'letter' => 'a'], + ['digit' => 2, 'letter' => 'b'], + ['digit' => 3, 'letter' => 'a'], + ['digit' => 3, 'letter' => 'b'], + ], + $cartesianProductAsArray(['digit' => [1, 2, 3], 'letter' => ['a', 'b']]) + ); + + self::assertEqualsCanonicalizing( + [ + [1, 'a'], + [1, 'b'], + [2, 'a'], + [2, 'b'], + [3, 'a'], + [3, 'b'], + ], + $cartesianProductAsArray([[1, 2, 3], ['a', 'b']]) + ); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsEnvVarsForAppCodeTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsEnvVarsForAppCodeTest.php new file mode 100644 index 0000000..8efa051 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsEnvVarsForAppCodeTest.php @@ -0,0 +1,211 @@ + + */ + public static function dataProviderForTestInheritedEnvVarsAutoPass(): iterable + { + /** + * @return iterable + */ + $genInheritedEnvVarsVariants = function (): iterable { + yield 'Unrelated to OTel/EDOT' => ['COMPOSER_BINARY', 'SHELL', 'XDG_SESSION_TYPE']; + + yield 'All options for tests' => array_map(fn($optName) => $optName->name, OptionForTestsName::cases()); + + yield 'DEV_INTERNAL_MODE_IS_DEV' => [PhpPartFacade::CONFIG_ENV_VAR_NAME_DEV_INTERNAL_MODE_IS_DEV]; + }; + + $allProdOptionNames = OptionForProdName::cases(); + foreach ($genInheritedEnvVarsVariants() as $inheritedEnvVarNamesDesc => $inheritedEnvVarNames) { + foreach (['All' => $allProdOptionNames, 'None' => []] as $prodOptionNamesDesc => $prodOptionNames) { + yield ('Inherited env vars: ' . $inheritedEnvVarNamesDesc . ', Options for production: ' . $prodOptionNamesDesc) => compact('inheritedEnvVarNames', 'prodOptionNames'); + } + } + + // Inherited Log related (but not log related) options for production should be automatically passed through + // except for log level related options which should be automatically passed through if and only if none of log level related production options is set + foreach (OptionForProdName::getAllLogRelated() as $optName) { + $inheritedEnvVarNames = [$optName->toEnvVarName()]; + $prodOptionNamesAllExceptSome = $allProdOptionNames; + if ($optName->isLogLevelRelated()) { + foreach (OptionForProdName::getAllLogLevelRelated() as $currentProdOptName) { + self::assertTrue(ArrayUtilForTests::removeFirstByValue(/* in,out */ $prodOptionNamesAllExceptSome, $currentProdOptName)); + } + } else { + self::assertTrue(ArrayUtilForTests::removeFirstByValue(/* in,out */ $prodOptionNamesAllExceptSome, $optName)); + } + foreach (['All except some' => $prodOptionNamesAllExceptSome, 'None' => []] as $prodOptionNamesDesc => $prodOptionNames) { + yield ('Inherited env vars: ' . $optName->toEnvVarName() . ', Options for production: ' . $prodOptionNamesDesc) => compact('inheritedEnvVarNames', 'prodOptionNames'); + } + } + } + + /** + * @dataProvider dataProviderForTestInheritedEnvVarsAutoPass + * + * @param string[] $inheritedEnvVarNames + * @param OptionForProdName[] $prodOptionNames + */ + public static function testInheritedEnvVarsAutoPass(array $inheritedEnvVarNames, array $prodOptionNames): void + { + $expectedBuiltEnvVars = []; + /** @var OptionsForProdMap $prodOptions */ + $prodOptions = new Map(); + foreach ($prodOptionNames as $prodOptName) { + $prodOptValue = 'value for production option ' . $prodOptName->name; + $prodOptions->put($prodOptName, $prodOptValue); + ArrayUtilForTests::addAssertingKeyNew($prodOptName->toEnvVarName(), $prodOptValue, /* in,out */ $expectedBuiltEnvVars); + } + + $inheritedEnvVars = []; + foreach ($inheritedEnvVarNames as $inheritedEnvVarName) { + $inheritedEnvVarValue = 'value for inherited env var ' . $inheritedEnvVarName; + ArrayUtilForTests::addAssertingKeyNew($inheritedEnvVarName, $inheritedEnvVarValue, /* in,out */ $inheritedEnvVars); + ArrayUtilForTests::addAssertingKeyNew($inheritedEnvVarName, $inheritedEnvVarValue, /* in,out */ $expectedBuiltEnvVars); + } + + self::buildAndAssertAsExpected($inheritedEnvVars, $prodOptions, $expectedBuiltEnvVars); + } + + /** + * @return iterable + */ + public static function dataProviderForTestLogLevelRelatedProdOverridesInheritedEnvVars(): iterable + { + foreach (OptionForProdName::getAllLogLevelRelated() as $prodOptName) { + yield $prodOptName->name => [$prodOptName]; + } + } + + /** + * @dataProvider dataProviderForTestLogLevelRelatedProdOverridesInheritedEnvVars + */ + public static function testLogLevelRelatedProdOptionOverridesInheritedEnvVars(OptionForProdName $prodOptName): void + { + $inheritedEnvVars = []; + foreach (OptionForProdName::getAllLogLevelRelated() as $currentLogLevelRelatedProdOptName) { + $inheritedEnvVarValue = 'value for inherited env var ' . $currentLogLevelRelatedProdOptName->toEnvVarName(); + ArrayUtilForTests::addAssertingKeyNew($currentLogLevelRelatedProdOptName->toEnvVarName(), $inheritedEnvVarValue, /* in,out */ $inheritedEnvVars); + } + + $prodOptValue = 'value for production option ' . $prodOptName->name; + /** @var OptionsForProdMap $prodOptions */ + $prodOptions = new Map(); + $prodOptions->put($prodOptName, $prodOptValue); + $expectedBuiltEnvVars = [$prodOptName->toEnvVarName() => $prodOptValue]; + + self::buildAndAssertAsExpected($inheritedEnvVars, $prodOptions, $expectedBuiltEnvVars); + } + + /** + * @return iterable + */ + public static function dataProviderForTestInheritedEnvVarForProdOptionAllowedViaPassThrough(): iterable + { + foreach (OptionForProdName::cases() as $prodOptName) { + if (!$prodOptName->isLogRelated()) { + yield $prodOptName->name => [[$prodOptName]]; + foreach (OptionForProdName::cases() as $secondProdOptName) { + if (!$secondProdOptName->isLogRelated() && $secondProdOptName !== $prodOptName) { + yield "[$prodOptName->name, $secondProdOptName->name]" => [[$prodOptName, $secondProdOptName]]; + } + } + } + } + } + + /** + * @dataProvider dataProviderForTestInheritedEnvVarForProdOptionAllowedViaPassThrough + * + * @param OptionForProdName[] $prodOptNames + */ + public static function testInheritedEnvVarForProdOptionAllowedViaPassThrough(array $prodOptNames): void + { + /** @var OptionsForProdMap $emptyProdOptions */ + $emptyProdOptions = new Map(); + + $inheritedEnvVars = []; + foreach ($prodOptNames as $prodOptName) { + ArrayUtilForTests::addAssertingKeyNew($prodOptName->toEnvVarName(), 'value for inherited env var ' . $prodOptName->toEnvVarName(), /* in,out */ $inheritedEnvVars); + } + + // Without pass-though option inherited env var for production option (unless it's log related) should not be passed to app code + self::buildAndAssertAsExpected($inheritedEnvVars, $emptyProdOptions, expectedBuiltEnvVars: []); + + $envVarNamesToPassThrough = []; + $expectedBuiltEnvVars = []; + foreach ($prodOptNames as $prodOptName) { + $envVarNamesToPassThrough[] = $prodOptName->toEnvVarName(); + $passThroughOptEnvVarName = OptionForTestsName::env_vars_to_pass_through->toEnvVarName(); + $passThroughOptValue = join(',', $envVarNamesToPassThrough); + $inheritedEnvVars[$passThroughOptEnvVarName] = $passThroughOptValue; + $expectedBuiltEnvVars[$passThroughOptEnvVarName] = $passThroughOptValue; + ArrayUtilForTests::addAssertingKeyNew($prodOptName->toEnvVarName(), $inheritedEnvVars[$prodOptName->toEnvVarName()], /* in,out */ $expectedBuiltEnvVars); + + $passThroughOptEnvVarValueToRestore = EnvVarUtil::get($passThroughOptEnvVarName); + try { + // Set env var the pass-through option for this process and recompute AmbientContextForTests::testConfig + // because tested code depends on AmbientContextForTests::testConfig + EnvVarUtil::set($passThroughOptEnvVarName, $passThroughOptValue); + AmbientContextForTests::reconfigure(); + self::buildAndAssertAsExpected($inheritedEnvVars, $emptyProdOptions, $expectedBuiltEnvVars); + } finally { + EnvVarUtil::setOrUnsetIfValueNull($passThroughOptEnvVarName, $passThroughOptEnvVarValueToRestore); + AmbientContextForTests::reconfigure(); + } + } + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsUtilUnitTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsUtilUnitTest.php new file mode 100644 index 0000000..a0469b6 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ComponentTestsUtilUnitTest.php @@ -0,0 +1,115 @@ +, array>}> + */ + public static function dataProviderForTestGenerateEscalatedLogLevels(): iterable + { + $prodCodeKey = ComponentTestCaseBase::LOG_LEVEL_FOR_PROD_CODE_KEY; + $testCodeKey = ComponentTestCaseBase::LOG_LEVEL_FOR_TEST_CODE_KEY; + $highestLevel = LogLevelUtil::getHighest(); + + /** + * When the initial already the highest + */ + yield [[$prodCodeKey => $highestLevel, $testCodeKey => $highestLevel], []]; + + /** + * When the initial one step below the highest + */ + yield [ + // initialLevels: + [$prodCodeKey => LogLevel::from($highestLevel->value - 1), $testCodeKey => $highestLevel], + // expectedEscalatedLevelsSeq: + [[$prodCodeKey => $highestLevel, $testCodeKey => $highestLevel]], + ]; + + yield [ + // initialLevels: + [$prodCodeKey => $highestLevel, $testCodeKey => LogLevel::from($highestLevel->value - 1)], + // expectedEscalatedLevelsSeq: + [[$prodCodeKey => $highestLevel, $testCodeKey => $highestLevel]], + ]; + + /** + * When the initial is the default + */ + yield [ + // initialLevels: + [$prodCodeKey => LogLevel::info, $testCodeKey => LogLevel::info], + // expectedEscalatedLevelsSeq: + [ + [$prodCodeKey => LogLevel::trace, $testCodeKey => LogLevel::trace], + [$prodCodeKey => LogLevel::debug, $testCodeKey => LogLevel::trace], + [$prodCodeKey => LogLevel::trace, $testCodeKey => LogLevel::debug], + [$prodCodeKey => LogLevel::info, $testCodeKey => LogLevel::trace], + [$prodCodeKey => LogLevel::trace, $testCodeKey => LogLevel::info], + [$prodCodeKey => LogLevel::debug, $testCodeKey => LogLevel::debug], + [$prodCodeKey => LogLevel::info, $testCodeKey => LogLevel::debug], + [$prodCodeKey => LogLevel::debug, $testCodeKey => LogLevel::info], + ] + ]; + } + + /** + * @dataProvider dataProviderForTestGenerateEscalatedLogLevels + * + * @param array $initialLevels + * @param array> $expectedLevelsSeq + * + * @return void + */ + public function testGenerateEscalatedLogLevels(array $initialLevels, array $expectedLevelsSeq): void + { + $dbgCtx = ['initialLevels' => $initialLevels, 'expectedLevelsSeq' => $expectedLevelsSeq]; + $actualEscalatedLevelsSeq = IterableUtil::toList(ComponentTestCaseBase::generateEscalatedLogLevels($initialLevels)); + $dbgCtx['actualEscalatedLevelsSeq'] = $actualEscalatedLevelsSeq; + $i = 0; + foreach ($actualEscalatedLevelsSeq as $actualLevels) { + $dbgCtxPerIter = array_merge(['i' => $i, 'actualLevels' => $actualLevels], $dbgCtx); + self::assertGreaterThan($i, count($expectedLevelsSeq), LoggableToString::convert($dbgCtxPerIter)); + $expectedLevels = $expectedLevelsSeq[$i]; + $dbgCtxPerIter['expectedLevels'] = $expectedLevels; + self::assertCount(count($expectedLevels), $actualLevels, LoggableToString::convert($dbgCtxPerIter)); + foreach ($expectedLevels as $levelTypeKey => $expectedLevel) { + $dbgCtxPerIter2 = array_merge(['levelTypeKey' => $levelTypeKey], $dbgCtxPerIter); + $dbgCtxPerIter2Str = LoggableToString::convert($dbgCtxPerIter2); + self::assertSame($expectedLevel, $actualLevels[$levelTypeKey], $dbgCtxPerIter2Str); + } + ++$i; + } + self::assertCount($i, $expectedLevelsSeq, LoggableToString::convert($dbgCtx)); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/DurationOptionTestValuesGenerator.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/DurationOptionTestValuesGenerator.php new file mode 100644 index 0000000..eea6ccf --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/DurationOptionTestValuesGenerator.php @@ -0,0 +1,211 @@ + + */ +final class DurationOptionTestValuesGenerator implements OptionTestValuesGeneratorInterface +{ + private DurationOptionParser $optionParser; + private FloatOptionTestValuesGenerator $auxFloatValuesGenerator; + + public function __construct(DurationOptionParser $optionParser) + { + $this->optionParser = $optionParser; + $this->auxFloatValuesGenerator = self::buildAuxFloatValuesGenerator($optionParser); + } + + /** + * @return iterable> + */ + private function createIfValidValue(string $valueAsString, DurationUnit $units, string $unitsSuffix): iterable + { + $value = new Duration(floatval($valueAsString), $units); + if ($this->auxFloatValuesGenerator->isInValidRange($value->toMilliseconds())) { + yield new OptionTestValidValue($valueAsString . $unitsSuffix, $value); + } + } + + /** + * @return iterable> + */ + public function validValues(): iterable + { + $noUnits = function (float|int $valueWithoutUnits): float { + return Duration::valueToMilliseconds(floatval($valueWithoutUnits), $this->optionParser->defaultUnits); + }; + + /** + * We are forced to use list-array of pairs instead of regular associative array + * because in an associative array if the key is numeric string it's automatically converted to int + * (see https://www.php.net/manual/en/language.types.array.php) + * + * @var array $predefinedValidValues + */ + $predefinedValidValues = [ + ['0', 0], + [' 0 ms', 0], + ["\t 0 s ", 0], + ['0m', 0], + ['1', $noUnits(1)], + ['0.01', $noUnits(0.01)], + ['97.5', $noUnits(97.5)], + ['1ms', 1], + [" \n 97 \t ms ", 97], + ['1s', 1000], + ['1m', 60 * 1000], + ['0.0', 0], + ['0.0ms', 0], + ['0.0s', 0], + ['0.0m', 0], + ['1.5ms', 1.5], + ['1.5s', 1.5 * 1000], + ['1.5m', 1.5 * 60 * 1000], + ['-12ms', -12], + ['-12.5ms', -12.5], + ['-45s', -45 * 1000], + ['-45.1s', -45.1 * 1000], + ['-78m', -78 * 60 * 1000], + ['-78.2m', -78.2 * 60 * 1000], + ]; + foreach ($predefinedValidValues as $rawAndParsedValuesPair) { + if ($this->auxFloatValuesGenerator->isInValidRange($rawAndParsedValuesPair[1])) { + yield new OptionTestValidValue($rawAndParsedValuesPair[0], new Duration(floatval($rawAndParsedValuesPair[1]), DurationUnit::ms)); + } + } + + /** @var OptionTestValidValue $validNoUnitsValue */ + foreach ($this->auxFloatValuesGenerator->validValues() as $validNoUnitsValue) { + foreach (DurationUnit::cases() as $unit) { + $unitsSuffixes = [$unit->name, ' ' . $unit->name]; + if ($unit === $this->optionParser->defaultUnits) { + $unitsSuffixes[] = ''; + } + foreach ($unitsSuffixes as $unitsSuffix) { + $valueInUnits = self::convertFromMilliseconds($validNoUnitsValue->parsedValue, $unit); + + // For float keep only 3 digits after the floating point + // for tolerance to error in reverse conversion + $roundedValueInUnits = round($valueInUnits, 3); + + yield from $this->createIfValidValue(strval($roundedValueInUnits), $unit, $unitsSuffix); + + foreach ([ceil($roundedValueInUnits), floor($roundedValueInUnits)] as $intValueInUnits) { + if (FloatOptionTestValuesGenerator::isInIntRange($intValueInUnits)) { + $intValueInUnitsAsString = strval(intval($intValueInUnits)); + yield from $this->createIfValidValue($intValueInUnitsAsString, $unit, $unitsSuffix); + + yield from $this->createIfValidValue(strval($intValueInUnits), $unit, $unitsSuffix); + } + } + } + } + } + } + + public function invalidRawValues(): iterable + { + yield from [ + '', + ' ', + '\t', + '\r\n', + 'a', + 'abc', + '123abc', + 'abc123', + 'a_123_b', + '1a', + '1sm', + '1m2', + '1s2', + '1ms2', + '3a2m', + 'a32m', + '3a2s', + 'a32s', + '3a2ms', + 'a32ms', + ]; + + foreach ($this->auxFloatValuesGenerator->invalidRawValues() as $invalidRawValue) { + if (!FloatOptionParser::isValidFormat($invalidRawValue)) { + yield $invalidRawValue; + continue; + } + + $invalidValueInMilliseconds = floatval($invalidRawValue); + if (!$this->auxFloatValuesGenerator->isInValidRange($invalidValueInMilliseconds)) { + foreach (DurationUnit::cases() as $unit) { + $valueInUnits = self::convertFromMilliseconds($invalidValueInMilliseconds, $unit); + yield $valueInUnits . $unit->name; + if ($this->optionParser->defaultUnits === $unit) { + yield strval($valueInUnits); + } + } + } + } + + /** @var OptionTestValidValue $validValue */ + foreach ($this->validValues() as $validValue) { + foreach (['a', 'z'] as $invalidDurationUnitsSuffix) { + yield $validValue->rawValue . $invalidDurationUnitsSuffix; + } + } + } + + public static function convertFromMilliseconds(float $valueInMilliseconds, DurationUnit $dstUnit): float + { + return match ($dstUnit) { + DurationUnit::ms => $valueInMilliseconds, + DurationUnit::s => $valueInMilliseconds / 1000, + DurationUnit::m => $valueInMilliseconds / (60 * 1000), + }; + } + + private static function buildAuxFloatValuesGenerator(DurationOptionParser $optionParser): FloatOptionTestValuesGenerator + { + $floatOptionParser = new FloatOptionParser($optionParser->minValidValue?->value, $optionParser->maxValidValue?->value); + + return new class ($floatOptionParser) extends FloatOptionTestValuesGenerator { + /** + * @return iterable + */ + protected function autoGeneratedInterestingValuesToDiff(): iterable + { + foreach (parent::autoGeneratedInterestingValuesToDiff() as $interestingValuesToDiff) { + foreach (DurationUnit::cases() as $unit) { + yield DurationOptionTestValuesGenerator::convertFromMilliseconds($interestingValuesToDiff, $unit); + } + } + } + }; + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionTestValuesGenerator.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionTestValuesGenerator.php new file mode 100644 index 0000000..2e45769 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionTestValuesGenerator.php @@ -0,0 +1,229 @@ + + */ +final class EnumOptionTestValuesGenerator implements OptionTestValuesGeneratorInterface +{ + /** @var EnumOptionParser */ + private EnumOptionParser $optionParser; + + /** @var array> */ + private array $additionalValidValues; + + /** @var array */ + private array $additionalInvalidRawValues; + + /** + * EnumOptionTestValuesGenerator constructor. + * + * @param EnumOptionParser $optionParser + * @param array> $additionalValidValues + * @param array $additionalInvalidRawValues + */ + public function __construct( + EnumOptionParser $optionParser, + array $additionalValidValues = [], + array $additionalInvalidRawValues = [] + ) { + $this->optionParser = $optionParser; + $this->additionalValidValues = $additionalValidValues; + $this->additionalInvalidRawValues = $additionalInvalidRawValues; + } + + private static function flipRandomLetters(string $srcStr, int $numberOfLettersToFlip): string + { + if ($numberOfLettersToFlip === 0) { + return $srcStr; + } + + /** @var int[] $letterIndexes */ + $letterIndexes = []; + foreach (RangeUtil::generateUpTo(strlen($srcStr)) as $charIndex) { + if (TextUtil::isLetter(ord($srcStr[$charIndex]))) { + $letterIndexes[] = $charIndex; + } + } + + $actualNumberOfLettersToFlip = min($numberOfLettersToFlip, count($letterIndexes)); + $letterToFlipIndexes = RandomUtil::arrayRandValues($letterIndexes, $actualNumberOfLettersToFlip); + + $result = ''; + $remainderStartIndex = 0; + foreach ($letterToFlipIndexes as $letterToFlipIndex) { + $result .= substr($srcStr, $remainderStartIndex, $letterToFlipIndex - $remainderStartIndex); + $result .= chr(TextUtil::flipLetterCase(ord($srcStr[$letterToFlipIndex]))); + $remainderStartIndex = $letterToFlipIndex + 1; + } + $result .= substr($srcStr, $remainderStartIndex); + + return $result; + } + + /** + * @param string $enumEntryName + * + * @return iterable + */ + private function genCaseVariations(string $enumEntryName): iterable + { + $maxNumberOfLettersToFlip = $this->optionParser->isCaseSensitive() ? 0 : 2; + foreach (RangeUtil::generateFromToIncluding(0, $maxNumberOfLettersToFlip) as $numberOfLettersToFlip) { + yield self::flipRandomLetters($enumEntryName, $numberOfLettersToFlip); + } + } + + private function isUnambiguousPrefix(string $prefix): bool + { + $foundMatchingEntry = false; + foreach ($this->optionParser->nameValuePairs() as $enumEntryNameValuePair) { + if (TextUtil::isPrefixOf($prefix, $enumEntryNameValuePair[0], $this->optionParser->isCaseSensitive())) { + if ($foundMatchingEntry) { + return false; + } + $foundMatchingEntry = true; + } + } + return $foundMatchingEntry; + } + + /** + * @param string $enumEntryName + * + * @return iterable + */ + private function genPrefixVariations(string $enumEntryName): iterable + { + yield $enumEntryName; + + if (!$this->optionParser->isUnambiguousPrefixAllowed()) { + return; + } + + foreach (RangeUtil::generateFromToIncluding(1, strlen($enumEntryName) - 1) as $lengthToCutOff) { + $prefix = substr($enumEntryName, 0, -$lengthToCutOff); + if ($this->isUnambiguousPrefix($prefix)) { + yield $prefix; + } else { + break; + } + } + } + + public function validValues(): iterable + { + yield from $this->additionalValidValues; + + foreach ($this->optionParser->nameValuePairs() as $enumEntryNameAndValue) { + foreach ($this->genPrefixVariations($enumEntryNameAndValue[0]) as $enumEntryNamePrefix) { + foreach ($this->genCaseVariations($enumEntryNamePrefix) as $manipulatedEnumEntryName) { + yield new OptionTestValidValue($manipulatedEnumEntryName, $enumEntryNameAndValue[1]); + } + } + } + } + + private function isValidRawValue(string $rawValue): bool + { + foreach ($this->additionalValidValues as $additionalValidValue) { + $trimmedRawValue = trim($rawValue); + if ($trimmedRawValue === $additionalValidValue->rawValue) { + return true; + } + } + + $foundAsPrefix = false; + foreach ($this->optionParser->nameValuePairs() as $enumEntryNameAndValue) { + if (TextUtil::isPrefixOf($rawValue, $enumEntryNameAndValue[0], $this->optionParser->isCaseSensitive())) { + if (strlen($rawValue) === strlen($enumEntryNameAndValue[0])) { + return true; + } + if ($foundAsPrefix) { + return false; + } + $foundAsPrefix = true; + } + } + return $foundAsPrefix; + } + + /** + * @return iterable + */ + private function invalidRawValuesImpl(): iterable + { + /** + * @param string $rawValue + * + * @return iterable + */ + $genIfNotValidRawValue = function (string $rawValue): iterable { + if (!$this->isValidRawValue($rawValue)) { + yield $rawValue; + } + }; + + yield from $this->additionalInvalidRawValues; + + yield from ['', ' ', '\t', '\r\n']; + + /** @var OptionTestValidValue $validValueData */ + foreach (StringOptionTestValuesGenerator::singletonInstance()->validValues() as $validValueData) { + yield from $genIfNotValidRawValue($validValueData->parsedValue); + } + + foreach ($this->optionParser->nameValuePairs() as $enumEntryNameAndValue) { + $lengthsToCutOffVars = RangeUtil::generateFromToIncluding(0, strlen($enumEntryNameAndValue[0]) - 1); + foreach ($lengthsToCutOffVars as $lengthToCutOff) { + $prefixBeforeCaseVariations = substr($enumEntryNameAndValue[0], 0, -$lengthToCutOff); + foreach ($this->genCaseVariations($prefixBeforeCaseVariations) as $prefix) { + yield from $genIfNotValidRawValue($prefix); + yield from $genIfNotValidRawValue($prefix . '_X'); + yield from $genIfNotValidRawValue('X_' . $prefix); + } + } + } + } + + /** @inheritDoc */ + #[Override] + public function invalidRawValues(): iterable + { + foreach ($this->invalidRawValuesImpl() as $invalidRawValue) { + if (!$this->isValidRawValue($invalidRawValue)) { + yield $invalidRawValue; + } + } + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTest.php new file mode 100644 index 0000000..e4d0d94 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTest.php @@ -0,0 +1,121 @@ +, list>}> + */ + public static function dataProviderForTestEnumWithSomeEntriesArePrefixOfOtherOnes(): array + { + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $testArgsTuples = [ + [ + EnumOptionParser::useEnumCasesNames(EnumOptionsParsingTestDummyEnum::class, isCaseSensitive: true, isUnambiguousPrefixAllowed: true), + [ + new OptionTestValidValue(" anotherEnumEntry\t\n", EnumOptionsParsingTestDummyEnum::anotherEnumEntry), + new OptionTestValidValue("anotherEnumEnt \n ", EnumOptionsParsingTestDummyEnum::anotherEnumEntry), + new OptionTestValidValue("another \n ", EnumOptionsParsingTestDummyEnum::anotherEnumEntry), + new OptionTestValidValue('a', EnumOptionsParsingTestDummyEnum::anotherEnumEntry), + new OptionTestValidValue(' enumEntry', EnumOptionsParsingTestDummyEnum::enumEntry), + new OptionTestValidValue("\t enumEntryWithSuffix\n ", EnumOptionsParsingTestDummyEnum::enumEntryWithSuffix), + new OptionTestValidValue('enumEntryWithSuffix2', EnumOptionsParsingTestDummyEnum::enumEntryWithSuffix2), + ] + ], + [ + EnumOptionParser::useEnumCasesValues(EnumOptionsParsingTestDummyBackedEnum::class, isCaseSensitive: true, isUnambiguousPrefixAllowed: true), + [ + new OptionTestValidValue(" anotherEnumEntry_value\t\n", EnumOptionsParsingTestDummyBackedEnum::anotherEnumEntry), + new OptionTestValidValue("anotherEnumEnt \n ", EnumOptionsParsingTestDummyBackedEnum::anotherEnumEntry), + new OptionTestValidValue("another \n ", EnumOptionsParsingTestDummyBackedEnum::anotherEnumEntry), + new OptionTestValidValue('a', EnumOptionsParsingTestDummyBackedEnum::anotherEnumEntry), + new OptionTestValidValue(' enumEntry_value', EnumOptionsParsingTestDummyBackedEnum::enumEntry), + new OptionTestValidValue("\t enumEntryWithSuffix_value\n ", EnumOptionsParsingTestDummyBackedEnum::enumEntryWithSuffix), + new OptionTestValidValue('enumEntryWithSuffix2_value', EnumOptionsParsingTestDummyBackedEnum::enumEntryWithSuffix2), + ] + ], + [ + new EnumOptionParser( + dbgDesc: '', + nameValuePairs: [ + ['enumEntry', 'enumEntry_value'], + ['enumEntryWithSuffix', 'enumEntryWithSuffix_value'], + ['enumEntryWithSuffix2', 'enumEntryWithSuffix2_value'], + ['anotherEnumEntry', 'anotherEnumEntry_value'], + ], + isCaseSensitive: true, + isUnambiguousPrefixAllowed: true + ), + [ + new OptionTestValidValue(" anotherEnumEntry\t\n", 'anotherEnumEntry_value'), + new OptionTestValidValue("anotherEnumEnt \n ", 'anotherEnumEntry_value'), + new OptionTestValidValue("another \n ", 'anotherEnumEntry_value'), + new OptionTestValidValue('a', 'anotherEnumEntry_value'), + new OptionTestValidValue(' enumEntry', 'enumEntry_value'), + new OptionTestValidValue("\t enumEntryWithSuffix\n ", 'enumEntryWithSuffix_value'), + new OptionTestValidValue('enumEntryWithSuffix2', 'enumEntryWithSuffix2_value'), + ], + ], + ]; + + return $testArgsTuples; // @phpstan-ignore return.type + } + + /** + * @template T + * + * @dataProvider dataProviderForTestEnumWithSomeEntriesArePrefixOfOtherOnes + * + * @param EnumOptionParser $optionParser + * @param list> $additionalValidValues + */ + public function testEnumWithSomeEntriesArePrefixOfOtherOnes(EnumOptionParser $optionParser, array $additionalValidValues): void + { + /** @noinspection SpellCheckingInspection */ + /** @var list $additionalInvalidRawValues */ + static $additionalInvalidRawValues = [ + 'e', + 'enum', + 'enumEnt', + 'enumEntr', + 'enumEntryWithSuffi', + 'enumEntryWithSuffix2_', + 'ENUMENTRY', + 'enumEntryWithSUFFIX', + 'ENUMEntryWithSuffix2', + 'anotherenumentry', + 'Another', + 'A', + ]; + + $testValuesGenerator = new EnumOptionTestValuesGenerator($optionParser, $additionalValidValues, $additionalInvalidRawValues); + + VariousOptionsParsingTest::parseValidValueTestImpl($testValuesGenerator, $optionParser); + VariousOptionsParsingTest::parseInvalidValueTestImpl($testValuesGenerator, $optionParser); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTestDummyBackedEnum.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTestDummyBackedEnum.php new file mode 100644 index 0000000..a6e4473 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/EnumOptionsParsingTestDummyBackedEnum.php @@ -0,0 +1,32 @@ + + */ +class FloatOptionTestValuesGenerator extends NumericOptionTestValuesGeneratorBase +{ + public function __construct( + protected FloatOptionParser $optionParser + ) { + } + + /** + * @return FloatOptionParser + */ + protected function optionParser(): NumericOptionParser + { + return $this->optionParser; + } + + /** + * @return float + */ + protected static function maxValueSupportedByType(): float + { + return FloatLimits::MAX; + } + + /** + * @return float + */ + protected static function minValueSupportedByType(): float + { + return FloatLimits::MIN; + } + + /** + * @return iterable> + */ + protected function manualInterestingValues(): iterable + { + /** @var OptionTestValidValue $intManualInterestingValue */ + foreach (self::intManualInterestingValues() as $intManualInterestingValue) { + yield new OptionTestValidValue( + $intManualInterestingValue->rawValue, + floatval($intManualInterestingValue->parsedValue) + ); + } + + yield new OptionTestValidValue('0.0', 0.0); + yield new OptionTestValidValue('-0.0', 0.0); + yield new OptionTestValidValue('+0.0', 0.0); + yield new OptionTestValidValue('0.0e0', 0.0); + yield new OptionTestValidValue('-0.0E-0', 0.0); + yield new OptionTestValidValue('+0.0e+0', 0.0); + + yield new OptionTestValidValue('1.0', 1.0); + yield new OptionTestValidValue('+1.0', 1.0); + yield new OptionTestValidValue('-1.0', -1.0); + yield new OptionTestValidValue('1.0E0', 1.0); + yield new OptionTestValidValue('+1.0e0', 1.0); + yield new OptionTestValidValue('-1.0E0', -1.0); + + yield new OptionTestValidValue('01.5e1', 15.0); + yield new OptionTestValidValue('+5.1E2', 510.0); + yield new OptionTestValidValue('-2.5e-3', -0.0025); + } + + /** + * @return iterable + */ + protected function autoGeneratedInterestingValuesToDiff(): iterable + { + /** @var Set $result */ + $result = new Set(); + + /** @var int $intInterestingValue */ + foreach ($this->intInterestingValuesToDiff() as $intInterestingValue) { + $result->add(floatval($intInterestingValue)); + } + + if ($this->optionParser()->minValidValue() !== null) { + $result->add( + $this->optionParser()->minValidValue(), + $this->optionParser()->minValidValue() / 2 + ); + } + if ($this->optionParser()->maxValidValue() !== null) { + $result->add( + $this->optionParser()->maxValidValue(), + $this->optionParser()->maxValidValue() / 2 + ); + } + $result->add( + FloatLimits::MIN, + FloatLimits::MIN / 2, + FloatLimits::MAX / 2, + FloatLimits::MAX + ); + + return new IteratorIterator($result); + } + + /** + * @return iterable + */ + protected function autoGeneratedInterestingValueDiffs(): iterable + { + foreach (self::intInterestingDiffs() as $intDiff) { + foreach (self::fractionInterestingDiffs() as $fractionDiff) { + yield $intDiff + $fractionDiff; + if ($intDiff > $fractionDiff) { + yield $intDiff - $fractionDiff; + } + } + } + } + + /** + * @return iterable + */ + protected static function fractionInterestingDiffs(): iterable + { + yield from [0.0, 0.001, 0.01, 0.1, 0.5, 0.9]; + } + + /** + * @param float $min + * @param float $max + */ + protected static function randomValue($min, $max): float + { + return RandomUtil::generateFloatInRange($min, $max); + } + + public static function isInIntRange(float $value): bool + { + return NumericUtil::isInClosedInterval(PHP_INT_MIN, $value, PHP_INT_MAX); + } + + /** + * @param float $value + * + * @return OptionTestValidValue + */ + protected static function createOptionTestValidValue($value): OptionTestValidValue + { + $valueAsString = strval($value); + return new OptionTestValidValue($valueAsString, floatval($valueAsString)); + } + + /** + * @return iterable> + */ + public function validValues(): iterable + { + /** @var OptionTestValidValue $value */ + foreach (parent::validValues() as $value) { + yield $value; + + /** @var float $roundedValue */ + foreach ([ceil($value->parsedValue), floor($value->parsedValue)] as $roundedValue) { + if (self::isInIntRange($roundedValue)) { + yield static::createOptionTestValidValue($roundedValue); + $valueAsString = strval(intval($roundedValue)); + yield new OptionTestValidValue($valueAsString, floatval($valueAsString)); + } + } + } + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/IntOptionTestValuesGenerator.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/IntOptionTestValuesGenerator.php new file mode 100644 index 0000000..feab961 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/IntOptionTestValuesGenerator.php @@ -0,0 +1,115 @@ + + */ +class IntOptionTestValuesGenerator extends NumericOptionTestValuesGeneratorBase +{ + public function __construct( + protected IntOptionParser $optionParser + ) { + } + + /** + * @return IntOptionParser + */ + protected function optionParser(): NumericOptionParser + { + return $this->optionParser; + } + + /** + * @return int + */ + final protected static function maxValueSupportedByType(): int + { + return PHP_INT_MAX; + } + + /** + * @return int + */ + final protected static function minValueSupportedByType(): int + { + return PHP_INT_MIN; + } + + /** + * @return iterable> + */ + protected function manualInterestingValues(): iterable + { + return self::intManualInterestingValues(); + } + + /** + * @return iterable + */ + protected function autoGeneratedInterestingValuesToDiff(): iterable + { + return self::intInterestingValuesToDiff(); + } + + /** + * @return iterable + */ + protected function autoGeneratedInterestingValueDiffs(): iterable + { + return self::intInterestingDiffs(); + } + + /** + * @param int $min + * @param int $max + */ + protected static function randomValue($min, $max): int + { + return RandomUtil::generateIntInRange($min, $max); + } + + /** + * @param int $value + * + * @return OptionTestValidValue + */ + protected static function createOptionTestValidValue($value): OptionTestValidValue + { + return new OptionTestValidValue(strval($value), $value); + } + + /** @inheritDoc */ + #[Override] + public function invalidRawValues(): iterable + { + yield from ['0.0', '1.0', '-1.0', '1.5', '-1.5', '20.2', '-30.3']; + yield from parent::invalidRawValues(); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/NumericOptionTestValuesGeneratorBase.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/NumericOptionTestValuesGeneratorBase.php new file mode 100644 index 0000000..657f1ed --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/NumericOptionTestValuesGeneratorBase.php @@ -0,0 +1,274 @@ + + */ +abstract class NumericOptionTestValuesGeneratorBase implements OptionTestValuesGeneratorInterface +{ + /** + * @return NumericOptionParser + */ + abstract protected function optionParser(): NumericOptionParser; + + /** + * @return T + */ + abstract protected static function maxValueSupportedByType(); + + /** + * @return T + */ + abstract protected static function minValueSupportedByType(); + + /** + * @return T + */ + protected function effectiveMaxValidValue() + { + return $this->optionParser()->maxValidValue() ?? static::maxValueSupportedByType(); + } + + /** + * @return T + */ + protected function effectiveMinValidValue() + { + return $this->optionParser()->minValidValue() ?? static::minValueSupportedByType(); + } + + /** + * @phpstan-param T $value + */ + public function isInValidRange(float|int $value): bool + { + return NumericUtil::isInClosedInterval( + $this->effectiveMinValidValue(), + $value, + $this->effectiveMaxValidValue() + ); + } + + /** + * @return iterable> + */ + protected function intManualInterestingValues(): iterable + { + yield new OptionTestValidValue('0', 0); + yield new OptionTestValidValue('-0', 0); + yield new OptionTestValidValue('+0', 0); + + yield new OptionTestValidValue('1', 1); + yield new OptionTestValidValue('+1', 1); + yield new OptionTestValidValue('-1', -1); + + yield new OptionTestValidValue('1234', 1234); + yield new OptionTestValidValue('-56789', -56789); + } + + /** + * @return iterable> + * @phpstan-return iterable> + */ + abstract protected function manualInterestingValues(): iterable; + + /** + * @return iterable> + * @phpstan-return iterable> + */ + private function autoGeneratedInterestingValues(): iterable + { + /** @var Set $values */ + $values = new Set(); + + foreach ($this->autoGeneratedInterestingValuesToDiff() as $interestingValue) { + foreach ($this->autoGeneratedInterestingValueDiffs() as $interestingDiff) { + if ($interestingValue <= (static::maxValueSupportedByType() - $interestingDiff)) { + /** + * @var float|int $value + * @phpstan-var T $value + */ + $value = $interestingValue + $interestingDiff; + $values->add($value); + } + if ($interestingValue >= (static::minValueSupportedByType() + $interestingDiff)) { + /** + * @var float|int $value + * @phpstan-var T $value + */ + $value = $interestingValue - $interestingDiff; + $values->add($value); + } + } + } + + foreach ($values as $value) { + yield static::createOptionTestValidValue($value); + } + } + + /** + * @return iterable> + * @phpstan-return iterable> + */ + private function interestingValues(): iterable + { + yield from $this->manualInterestingValues(); + yield from $this->autoGeneratedInterestingValues(); + } + + /** + * @phpstan-param T $min + * @phpstan-param T $max + * + * @return T + */ + abstract protected static function randomValue(float|int $min, float|int $max); + + /** + * @phpstan-param T $value + * + * @return OptionTestValidValue + */ + abstract protected static function createOptionTestValidValue(float|int $value): OptionTestValidValue; + + public function validValues(): iterable + { + /** + * @var OptionTestValidValue $candidate + * @phpstan-var OptionTestValidValue $candidate + */ + foreach ($this->interestingValues() as $candidate) { + if ($this->isInValidRange($candidate->parsedValue)) { + yield $candidate; + } + } + + /** @noinspection PhpUnusedLocalVariableInspection */ + foreach (RangeUtil::generateUpTo(self::NUMBER_OF_RANDOM_VALUES_TO_TEST) as $_) { + $value = static::randomValue($this->effectiveMinValidValue(), $this->effectiveMaxValidValue()); + yield static::createOptionTestValidValue($value); + } + } + + /** @inheritDoc */ + #[Override] + public function invalidRawValues(): iterable + { + yield from [ + '', + ' ', + '\t', + '\r\n', + 'a', + 'abc', + '123abc', + 'abc123', + 'a_123_b', + '1a', + '12.3abc', + 'abc1.23', + 'a_12.3_b', + 'a_12.3E+1', + '12.3E+1_b', + ]; + + /** + * @var OptionTestValidValue $interestingValue + * @phpstan-var OptionTestValidValue $interestingValue + */ + foreach ($this->autoGeneratedInterestingValues() as $interestingValue) { + if (!$this->isInValidRange($interestingValue->parsedValue)) { + yield $interestingValue->rawValue; + } + } + + if (static::minValueSupportedByType() < $this->effectiveMinValidValue()) { + /** @noinspection PhpUnusedLocalVariableInspection */ + foreach (RangeUtil::generateUpTo(self::NUMBER_OF_RANDOM_VALUES_TO_TEST) as $_) { + yield strval(static::randomValue(static::minValueSupportedByType(), $this->effectiveMinValidValue())); + } + } + if ($this->effectiveMaxValidValue() < static::maxValueSupportedByType()) { + /** @noinspection PhpUnusedLocalVariableInspection */ + foreach (RangeUtil::generateUpTo(self::NUMBER_OF_RANDOM_VALUES_TO_TEST) as $_) { + yield strval(static::randomValue($this->effectiveMaxValidValue(), static::maxValueSupportedByType())); + } + } + } + + /** + * @return iterable + * @phpstan-return iterable + */ + abstract protected function autoGeneratedInterestingValuesToDiff(): iterable; + + /** + * @return iterable + */ + abstract protected function autoGeneratedInterestingValueDiffs(): iterable; + + /** + * @return iterable + */ + protected function intInterestingValuesToDiff(): iterable + { + /** @var Set $result */ + $result = new Set(); + $result->add(0); + + if (is_int($this->optionParser()->minValidValue())) { + $result->add($this->optionParser()->minValidValue(), intdiv($this->optionParser()->minValidValue(), 2)); + } + if (is_int($this->optionParser()->maxValidValue())) { + $result->add($this->optionParser()->maxValidValue(), intdiv($this->optionParser()->maxValidValue(), 2)); + } + $result->add( + PHP_INT_MIN, + intdiv(PHP_INT_MIN, 2), + intdiv(PHP_INT_MAX, 2), + PHP_INT_MAX + ); + + return new IteratorIterator($result); + } + + /** + * @return iterable + */ + protected static function intInterestingDiffs(): iterable + { + yield from [0, 1, 2, 5, 10, 11, 100, 101, 123, 200, 202, 500, 1000, 9876]; + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionNamesAndSnapshotPropertiesTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionNamesAndSnapshotPropertiesTest.php new file mode 100644 index 0000000..fb47993 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionNamesAndSnapshotPropertiesTest.php @@ -0,0 +1,138 @@ +> $optMetas + */ + $impl = function (array $optNameCases, array $optMetas): void { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $optNamesFromCases = array_map(fn($optNameCase) => $optNameCase->name, $optNameCases); // @phpstan-ignore property.nonObject + sort(/* ref */ $optNamesFromCases); + $optNamesFromMetas = array_keys($optMetas); + sort(/* ref */ $optNamesFromMetas); + $dbgCtx->add(compact('optNamesFromCases', 'optNamesFromMetas')); + AssertEx::arraysHaveTheSameContent($optNamesFromCases, $optNamesFromMetas); + }; + + $impl(OptionForProdName::cases(), OptionsForProdMetadata::get()); + $impl(OptionForTestsName::cases(), OptionsForTestsMetadata::get()); + } + + /** + * @return iterable + */ + public static function dataProviderForTestOptionNamesAndSnapshotPropertiesMatch(): iterable + { + return [ + [OptionForProdName::cases(), ConfigSnapshotForProd::propertyNamesForOptions()], + [OptionForTestsName::cases(), ConfigSnapshotForTests::propertyNamesForOptions()], + ]; + } + + /** + * @dataProvider dataProviderForTestOptionNamesAndSnapshotPropertiesMatch + * + * @param UnitEnum[] $optNameCases + * @param string[] $propertyNamesForOptions + */ + public function testOptionNamesAndSnapshotPropertiesMatch(array $optNameCases, array $propertyNamesForOptions): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $remainingSnapPropNames = $propertyNamesForOptions; + foreach ($optNameCases as $optNameCase) { + $dbgCtx->add(compact('optNameCase', 'remainingSnapPropNames')); + self::assertTrue(ArrayUtilForTests::removeFirstByValue(/* in,out */ $remainingSnapPropNames, TextUtil::snakeToCamelCase($optNameCase->name))); + } + + self::assertEmpty($remainingSnapPropNames); + } + + public function testOptionNameToEnvVarName(): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + /** @var class-string $optNameEnumClass */ + foreach ([OptionForProdName::class, OptionForTestsName::class] as $optNameEnumClass) { + $dbgCtx->add(compact('optNameEnumClass')); + foreach ($optNameEnumClass::cases() as $optName) { + $dbgCtx->add(compact('optName')); + $envVarName = $optName->toEnvVarName(); + $dbgCtx->add(compact('envVarName')); + self::assertTrue(TextUtil::isSuffixOf(strtoupper($optName->name), $envVarName)); + } + } + } + + public function testLogRelated(): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + foreach (OptionForProdName::getAllLogLevelRelated() as $optName) { + $dbgCtx->add(compact('optName')); + self::assertTrue($optName->isLogLevelRelated()); + } + + foreach (OptionForProdName::cases() as $optName) { + $dbgCtx->add(compact('optName')); + if (TextUtil::isPrefixOf('log_level_', $optName->name)) { + self::assertTrue($optName->isLogLevelRelated()); + } + } + } + + public function testProdOptionNameToEnvVar(): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + foreach (OptionForProdName::cases() as $optName) { + $dbgCtx->add(compact('optName')); + $envVarNamePrefix = $optName->getEnvVarNamePrefix(); + $dbgCtx->add(compact('envVarNamePrefix')); + $envVarName = $optName->toEnvVarName(); + $dbgCtx->add(compact('envVarName')); + self::assertStringStartsWith($envVarNamePrefix, $envVarName); + } + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValidValue.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValidValue.php new file mode 100644 index 0000000..80cd332 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValidValue.php @@ -0,0 +1,49 @@ +rawValue = $rawValue; + $this->parsedValue = $parsedValue; + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValuesGeneratorInterface.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValuesGeneratorInterface.php new file mode 100644 index 0000000..2880df9 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/OptionTestValuesGeneratorInterface.php @@ -0,0 +1,42 @@ +> + */ + public function validValues(): iterable; + + /** + * @return iterable + */ + public function invalidRawValues(): iterable; +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/StringOptionTestValuesGenerator.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/StringOptionTestValuesGenerator.php new file mode 100644 index 0000000..16c1a13 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/StringOptionTestValuesGenerator.php @@ -0,0 +1,123 @@ + + */ +final class StringOptionTestValuesGenerator implements OptionTestValuesGeneratorInterface +{ + use SingletonInstanceTrait; + + /** + * @return iterable + */ + private static function charsToUse(): iterable + { + // latin letters + foreach (RangeUtil::generateFromToIncluding(ord('A'), ord('Z')) as $charAsInt) { + yield $charAsInt; + yield TextUtil::flipLetterCase($charAsInt); + } + + // digits + foreach (RangeUtil::generateFromToIncluding(ord('0'), ord('9')) as $charAsInt) { + yield $charAsInt; + } + + // punctuation + yield from TextUtilForTests::iterateOverChars(',:;.!?'); + + yield from TextUtilForTests::iterateOverChars('@#$%&*()<>{}[]+-=_~^'); + yield ord('/'); + yield ord('|'); + yield ord('\\'); + yield ord('`'); + yield ord('\''); + yield ord('"'); + + // whitespace + yield from TextUtilForTests::iterateOverChars(" \t\r\n"); + } + + /** + * @return iterable + */ + private function validStrings(): iterable + { + yield ''; + yield 'A'; + yield 'abc'; + yield 'abC 123 Xyz'; + + /** @var array $charsToUse */ + $charsToUse = IterableUtil::toList(self::charsToUse()); + + $stringFromAllCharsToUse = ''; + foreach ($charsToUse as $charToUse) { + $stringFromAllCharsToUse .= chr($charToUse); + } + yield $stringFromAllCharsToUse; + + // any two chars (even the same one twice) + foreach (RangeUtil::generateUpTo(count($charsToUse)) as $i) { + foreach (RangeUtil::generateUpTo(count($charsToUse)) as $j) { + yield chr($charsToUse[$i]) . chr($charsToUse[$j]); + } + } + + /** @noinspection PhpUnusedLocalVariableInspection */ + foreach (RangeUtil::generateUpTo(self::NUMBER_OF_RANDOM_VALUES_TO_TEST) as $_) { + $numberOfChars = RandomUtil::generateIntInRange(1, count($charsToUse)); + $randString = ''; + /** @noinspection PhpUnusedLocalVariableInspection */ + foreach (RangeUtil::generateUpTo($numberOfChars) as $__) { + $randString .= chr(RandomUtil::generateIntInRange(0, count($charsToUse) - 1)); + } + yield $randString; + } + } + + /** + * @return iterable> + */ + public function validValues(): iterable + { + foreach ($this->validStrings() as $validString) { + yield new OptionTestValidValue($validString, trim($validString)); + } + } + + public function invalidRawValues(): iterable + { + return []; + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/VariousOptionsParsingTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/VariousOptionsParsingTest.php new file mode 100644 index 0000000..3541dc4 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/VariousOptionsParsingTest.php @@ -0,0 +1,280 @@ + $optMeta + * + * @return OptionTestValuesGeneratorInterface + */ + private static function selectTestValuesGenerator(OptionMetadata $optMeta): OptionTestValuesGeneratorInterface + { + $optionParser = $optMeta->parser(); + + if ($optionParser instanceof BoolOptionParser) { + return new EnumOptionTestValuesGenerator($optionParser, additionalValidValues: [new OptionTestValidValue('', false)]); /** @phpstan-ignore return.type */ + } + if ($optionParser instanceof DurationOptionParser) { + return new DurationOptionTestValuesGenerator($optionParser); /** @phpstan-ignore return.type */ + } + if ($optionParser instanceof EnumOptionParser) { + return new EnumOptionTestValuesGenerator($optionParser); + } + if ($optionParser instanceof FloatOptionParser) { + return new FloatOptionTestValuesGenerator($optionParser); /** @phpstan-ignore return.type */ + } + if ($optionParser instanceof IntOptionParser) { + return new IntOptionTestValuesGenerator($optionParser); /** @phpstan-ignore return.type */ + } + if ($optionParser instanceof StringOptionParser) { + return StringOptionTestValuesGenerator::singletonInstance(); /** @phpstan-ignore return.type */ + } + if ($optionParser instanceof WildcardListOptionParser) { + return WildcardListOptionTestValuesGenerator::singletonInstance(); /** @phpstan-ignore return.type */ + } + + self::fail('Unknown option metadata type: ' . get_debug_type($optMeta)); + } + + /** + * @return array> + */ + private static function additionalOptionMetas(): array + { + $result = []; + + $result['Duration s units'] = new DurationOptionMetadata( + new Duration(10, DurationUnit::ms) /* minValidValue */, + new Duration(20, DurationUnit::ms) /* maxValidValue */, + DurationUnit::s /* <- defaultUnits */, + new Duration(15, DurationUnit::ms) /* <- defaultValue */ + ); + + $result['Duration m units'] = new DurationOptionMetadata( + null /* minValidValue */, + null /* maxValidValue */, + DurationUnit::m /* <- defaultUnits */, + new Duration(123, DurationUnit::m) /* <- defaultValue */ + ); + + $result['Float without constrains'] = new FloatOptionMetadata( + null /* minValidValue */, + null /* maxValidValue */, + 123.321 /* defaultValue */ + ); + + $result['Float only with min constrain'] = new FloatOptionMetadata( + -1.0 /* minValidValue */, + null /* maxValidValue */, + 456.789 /* defaultValue */ + ); + + $result['Float only with max constrain'] = new FloatOptionMetadata( + null /* minValidValue */, + 1.0 /* maxValidValue */, + -987.654 /* defaultValue */ + ); + + /** @var array> $result */ + return $result; // @phpstan-ignore varTag.nativeType + } + + /** + * @return array>> + */ + private static function snapshotClassToOptionsMeta(): array + { + return [ + ConfigSnapshotForTests::class => OptionsForTestsMetadata::get(), + null => self::additionalOptionMetas(), + ]; + } + + /** + * @return iterable}> + */ + public static function allOptionsMetadataProvider(): iterable + { + foreach (self::snapshotClassToOptionsMeta() as $optionsMeta) { + foreach ($optionsMeta as $optMeta) { + if (!$optMeta->parser() instanceof CustomOptionParser) { + yield [LoggableToString::convert($optMeta), $optMeta]; + } + } + } + } + + /** + * @return iterable}> + */ + public static function allOptionsMetadataWithPossibleInvalidRawValuesProvider(): iterable + { + foreach (self::allOptionsMetadataProvider() as $optMetaDescAndDataPair) { + /** @var OptionMetadata $optMeta */ + $optMeta = $optMetaDescAndDataPair[1]; + if (!IterableUtil::isEmpty(self::selectTestValuesGenerator($optMeta)->invalidRawValues())) { + yield $optMetaDescAndDataPair; + } + } + } + + public function testIntOptionParserIsValidFormat(): void + { + self::assertTrue(IntOptionParser::isValidFormat('0')); + self::assertFalse(IntOptionParser::isValidFormat('0.0')); + self::assertTrue(IntOptionParser::isValidFormat('+0')); + self::assertFalse(IntOptionParser::isValidFormat('+0.0')); + self::assertTrue(IntOptionParser::isValidFormat('-0')); + self::assertFalse(IntOptionParser::isValidFormat('-0.0')); + + self::assertTrue(IntOptionParser::isValidFormat('1')); + self::assertFalse(IntOptionParser::isValidFormat('1.0')); + self::assertTrue(IntOptionParser::isValidFormat('+1')); + self::assertFalse(IntOptionParser::isValidFormat('+1.0')); + self::assertTrue(IntOptionParser::isValidFormat('-1')); + self::assertFalse(IntOptionParser::isValidFormat('-1.0')); + } + + /** + * @param OptionTestValuesGeneratorInterface $testValuesGenerator + * @param OptionParser $optParser + */ + public static function parseInvalidValueTestImpl(OptionTestValuesGeneratorInterface $testValuesGenerator, OptionParser $optParser): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $invalidRawValues = $testValuesGenerator->invalidRawValues(); + if (IterableUtil::isEmpty($invalidRawValues)) { + self::dummyAssert(); + return; + } + $dbgCtx->add(compact('invalidRawValues')); + + foreach ($invalidRawValues as $invalidRawValue) { + $invalidRawValue = self::genOptionalWhitespace() . $invalidRawValue . self::genOptionalWhitespace(); + $dbgCtx->add([...compact('invalidRawValue'), 'strlen($invalidRawValue)' => strlen($invalidRawValue)]); + if (!TextUtil::isEmptyString($invalidRawValue)) { + $dbgCtx->add(['ord($invalidRawValue[0])' => ord($invalidRawValue[0])]); + } + AssertEx::throws( + ParseException::class, + function () use ($optParser, $invalidRawValue): void { + Parser::parseOptionRawValue($invalidRawValue, $optParser); + } + ); + } + } + + /** + * @dataProvider allOptionsMetadataWithPossibleInvalidRawValuesProvider + * + * @param OptionMetadata $optMeta + */ + public function testParseInvalidValue(string $optMetaDbgDesc, OptionMetadata $optMeta): void + { + self::parseInvalidValueTestImpl(self::selectTestValuesGenerator($optMeta), $optMeta->parser()); + } + + private static function genOptionalWhitespace(): string + { + $whiteSpaceChars = [' ', "\t"]; + $result = ''; + foreach (RangeUtil::generateUpTo(3) as $ignored) { + $result .= RandomUtil::getRandomValueFromArray($whiteSpaceChars); + } + return $result; + } + + /** + * @param OptionTestValuesGeneratorInterface $testValuesGenerator + * @param OptionParser $optParser + */ + public static function parseValidValueTestImpl(OptionTestValuesGeneratorInterface $testValuesGenerator, OptionParser $optParser): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $validValues = $testValuesGenerator->validValues(); + if (IterableUtil::isEmpty($validValues)) { + self::dummyAssert(); + return; + } + $dbgCtx->add(['validValues' => $validValues]); + + $valueWithDetails = function (mixed $value): mixed { + if (!is_float($value)) { + return $value; + } + + return ['$value' => $value, 'number_format($value)' => number_format($value)]; + }; + + /** @var OptionTestValidValue $validValueData */ + foreach ($validValues as $validValueData) { + $dbgCtx->add(['validValueData' => $validValueData, '$validValueData->parsedValue' => $valueWithDetails($validValueData->parsedValue)]); + $validValueData->rawValue = self::genOptionalWhitespace() . $validValueData->rawValue . self::genOptionalWhitespace(); + $actualParsedValue = Parser::parseOptionRawValue($validValueData->rawValue, $optParser); + $dbgCtx->add(['actualParsedValue' => $valueWithDetails($actualParsedValue)]); + AssertEx::equalsEx($validValueData->parsedValue, $actualParsedValue); + } + } + + /** + * @dataProvider allOptionsMetadataProvider + * + * @param OptionMetadata $optMeta + */ + public function testParseValidValue(string $optMetaDbgDesc, OptionMetadata $optMeta): void + { + self::parseValidValueTestImpl(self::selectTestValuesGenerator($optMeta), $optMeta->parser()); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/WildcardListOptionTestValuesGenerator.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/WildcardListOptionTestValuesGenerator.php new file mode 100644 index 0000000..23b5a33 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ConfigTests/WildcardListOptionTestValuesGenerator.php @@ -0,0 +1,48 @@ + + */ +final class WildcardListOptionTestValuesGenerator implements OptionTestValuesGeneratorInterface +{ + use SingletonInstanceTrait; + + /** + * @return iterable> + */ + public function validValues(): iterable + { + return []; + } + + public function invalidRawValues(): iterable + { + return []; + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/DataProviderForTestBuilderTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/DataProviderForTestBuilderTest.php new file mode 100644 index 0000000..ed3f4e4 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/DataProviderForTestBuilderTest.php @@ -0,0 +1,485 @@ +> $callToGetActualDataSets + */ + public function assertCombinationWithHelperDimension(array $testDimensionValues, bool $onlyFirstValueCombinable, callable $callToGetActualDataSets): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $actualDataSets = IterableUtil::toMap($callToGetActualDataSets()); + $dbgCtx->add(compact('actualDataSets')); + + $expectedDataSets = []; + if (!(ArrayUtilForTests::isEmpty($testDimensionValues) || ArrayUtilForTests::isEmpty(self::HELPER_DIMENSION_VALUES))) { + if ($onlyFirstValueCombinable) { + foreach ($testDimensionValues as $testDimensionValue) { + $expectedDataSets[] = [self::TEST_DIMENSION_KEY => $testDimensionValue, self::HELPER_DIMENSION_KEY => self::HELPER_DIMENSION_VALUES[0]]; + } + $firstSkipped = false; + foreach (self::HELPER_DIMENSION_VALUES as $helperDimensionValue) { + if (!$firstSkipped) { + $firstSkipped = true; + continue; + } + $expectedDataSets[] = [self::TEST_DIMENSION_KEY => $testDimensionValues[0], self::HELPER_DIMENSION_KEY => $helperDimensionValue]; + } + } else { + foreach ($testDimensionValues as $testDimensionValue) { + foreach (self::HELPER_DIMENSION_VALUES as $helperDimensionValue) { + $expectedDataSets[] = [self::TEST_DIMENSION_KEY => $testDimensionValue, self::HELPER_DIMENSION_KEY => $helperDimensionValue]; + } + } + } + } + $dbgCtx->add(compact('expectedDataSets')); + + $expectedIterator = IterableUtil::iterableToIterator($expectedDataSets); + $expectedIterator->rewind(); + $actualIterator = IterableUtil::iterableToIterator($actualDataSets); + $actualIterator->rewind(); + + while (true) { + if (!$expectedIterator->valid()) { + self::assertFalse($actualIterator->valid()); + break; + } + $expectedDataSet = $expectedIterator->current(); + $expectedIterator->next(); + $actualDataSetDesc = $actualIterator->key(); + $actualDataSet = $actualIterator->current(); + $actualIterator->next(); + $dbgCtx->add(compact('expectedDataSet', 'actualDataSetDesc', 'actualDataSet')); + AssertEx::arrayHasKeyWithSameValue(self::TEST_DIMENSION_KEY, $expectedDataSet[self::TEST_DIMENSION_KEY], $actualDataSet); + AssertEx::arrayHasKeyWithSameValue(self::HELPER_DIMENSION_KEY, $expectedDataSet[self::HELPER_DIMENSION_KEY], $actualDataSet); + } + } + + /** + * @param array|callable(): iterable $values + * + * @noinspection PhpSameParameterValueInspection + */ + private static function addKeyedDimension(DataProviderForTestBuilder $builder, string $dimensionKey, bool $onlyFirstValueCombinable, array|callable $values): void + { + if ($onlyFirstValueCombinable) { + $builder->addKeyedDimensionOnlyFirstValueCombinable($dimensionKey, $values); + } else { + $builder->addKeyedDimensionAllValuesCombinable($dimensionKey, $values); + } + } + + private static function addHelperKeyedDimension(DataProviderForTestBuilder $builder, bool $onlyFirstValueCombinable): void + { + self::addKeyedDimension($builder, self::HELPER_DIMENSION_KEY, $onlyFirstValueCombinable, self::HELPER_DIMENSION_VALUES); + } + + /** + * @noinspection PhpSameParameterValueInspection + */ + private static function addNullableBoolKeyedDimension(DataProviderForTestBuilder $builder, string $dimensionKey, bool $onlyFirstValueCombinable): void + { + if ($onlyFirstValueCombinable) { + $builder->addNullableBoolKeyedDimensionOnlyFirstValueCombinable($dimensionKey); + } else { + $builder->addNullableBoolKeyedDimensionAllValuesCombinable($dimensionKey); + } + } + + /** + * @dataProvider dataProviderOneBoolArg + */ + public function testAddNullableBoolKeyedDimension(bool $onlyFirstValueCombinable): void + { + $builder = new DataProviderForTestBuilder(); + + self::addNullableBoolKeyedDimension($builder, self::TEST_DIMENSION_KEY, $onlyFirstValueCombinable); + self::addHelperKeyedDimension($builder, $onlyFirstValueCombinable); + $actual = $builder->buildAsMultiUse(); + + self::assertCombinationWithHelperDimension(BoolUtil::ALL_NULLABLE_VALUES, $onlyFirstValueCombinable, $actual); + } + + public function testOneList(): void + { + $inputList = ['a', 'b', 'c']; + $expected = IterableUtil::toList(CombinatorialUtil::cartesianProduct([$inputList])); + AssertEx::equalAsSets([['a'], ['b'], ['c']], $expected); + foreach (BoolUtil::ALL_VALUES as $onlyFirstValueCombinable) { + $actual = IterableUtil::toList( + $onlyFirstValueCombinable + ? (new DataProviderForTestBuilder()) + ->addDimensionOnlyFirstValueCombinable($inputList) + ->build() + : (new DataProviderForTestBuilder()) + ->addDimensionAllValuesCombinable($inputList) + ->build() + ); + AssertEx::equalAsSets($expected, $actual); + } + } + + /** + * @return iterable + */ + public static function dataProviderForTwoBoolArgs(): iterable + { + foreach (BoolUtil::ALL_VALUES as $onlyFirstValueCombinable1) { + foreach (BoolUtil::ALL_VALUES as $onlyFirstValueCombinable2) { + yield [$onlyFirstValueCombinable1, $onlyFirstValueCombinable2]; + } + } + } + + /** + * @dataProvider dataProviderForTwoBoolArgs + * + * @param bool $onlyFirstValueCombinable1 + * @param bool $onlyFirstValueCombinable2 + */ + public function testTwoLists(bool $onlyFirstValueCombinable1, bool $onlyFirstValueCombinable2): void + { + $inputList1 = ['a', 'b']; + $inputList2 = [1, 2, 3]; + $actual = IterableUtil::toList( + (new DataProviderForTestBuilder()) + ->addDimension($onlyFirstValueCombinable1, $inputList1) + ->addDimension($onlyFirstValueCombinable2, $inputList2) + ->build() + ); + if ($onlyFirstValueCombinable1 && $onlyFirstValueCombinable2) { + $expected = [ + ['a', 1], + ['b', 1], + ['a', 2], + ['a', 3], + ]; + } else { + $expected = IterableUtil::toList( + CombinatorialUtil::cartesianProduct([$inputList1, $inputList2]) + ); + } + AssertEx::equalAsSets( + $expected, + $actual, + LoggableToString::convert( + [ + 'onlyFirstValueCombinable1' => $onlyFirstValueCombinable1, + 'onlyFirstValueCombinable2' => $onlyFirstValueCombinable2, + '$expected' => $expected, + 'actual' => $actual, + ] + ) + ); + } + + /** + * @dataProvider dataProviderForTwoBoolArgs + * + * @param bool $disableInstrumentationsOnlyFirstValueCombinable + * @param bool $dbNameOnlyFirstValueCombinable + */ + public function testOneGeneratorAddsMultipleDimensions( + bool $disableInstrumentationsOnlyFirstValueCombinable, + bool $dbNameOnlyFirstValueCombinable + ): void { + $disableInstrumentationsVariants = [ + '' => true, + 'pdo' => false, + 'db' => false, + ]; + $dbNameVariants = ['memory', '/tmp/file']; + $actual = IterableUtil::toList( + (new DataProviderForTestBuilder()) + ->addGenerator( + $disableInstrumentationsOnlyFirstValueCombinable, + /** + * @param array $resultSoFar + * + * @return iterable> + */ + function (array $resultSoFar) use ($disableInstrumentationsVariants): iterable { + foreach ($disableInstrumentationsVariants as $optVal => $isInstrumentationEnabled) { + yield array_merge($resultSoFar, [$optVal, $isInstrumentationEnabled]); + } + } + ) + ->addDimension($dbNameOnlyFirstValueCombinable, $dbNameVariants) + ->build() + ); + + $disableInstrumentationsVariantsPairs = []; + foreach ($disableInstrumentationsVariants as $optVal => $isInstrumentationEnabled) { + $disableInstrumentationsVariantsPairs[] = [$optVal, $isInstrumentationEnabled]; + } + $cartesianProductPacked = IterableUtil::toList( + CombinatorialUtil::cartesianProduct([$disableInstrumentationsVariantsPairs, $dbNameVariants]) + ); + // Unpack $disableInstrumentationsVariants pair in each row + $cartesianProduct = []; + foreach ($cartesianProductPacked as $cartesianProductPackedRow) { + self::assertIsArray($cartesianProductPackedRow); // @phpstan-ignore staticMethod.alreadyNarrowedType + self::assertCount(2, $cartesianProductPackedRow); + $pair = $cartesianProductPackedRow[0]; + self::assertIsArray($pair); + self::assertCount(2, $pair); + $cartesianProductRow = []; + $cartesianProductRow[] = $pair[0]; + $cartesianProductRow[] = $pair[1]; + $cartesianProductRow[] = $cartesianProductPackedRow[1]; + $cartesianProduct[] = $cartesianProductRow; + } + + if ($disableInstrumentationsOnlyFirstValueCombinable && $dbNameOnlyFirstValueCombinable) { + $expected = [ + ['', true, 'memory'], + ['pdo', false, 'memory'], + ['db', false, 'memory'], + ['', true, '/tmp/file'], + ]; + } else { + $expected = $cartesianProduct; + } + + AssertEx::equalAsSets( + $expected, + $actual, + LoggableToString::convert( + [ + 'disableInstrumentationsOnlyFirstValueCombinable' + => $disableInstrumentationsOnlyFirstValueCombinable, + 'dbNameOnlyFirstValueCombinable' => $dbNameOnlyFirstValueCombinable, + '$expected' => $expected, + 'actual' => $actual, + ] + ) + ); + } + + /** + * @dataProvider dataProviderForTwoBoolArgs + * + * @param bool $onlyFirstValueCombinable1 + * @param bool $onlyFirstValueCombinable2 + */ + public function testTwoKeyedDimensions(bool $onlyFirstValueCombinable1, bool $onlyFirstValueCombinable2): void + { + $inputList1 = ['a', 'b']; + $inputList2 = [1, 2, 3]; + $actual = IterableUtil::toList( + (new DataProviderForTestBuilder()) + ->addKeyedDimension('letter', $onlyFirstValueCombinable1, $inputList1) + ->addKeyedDimension('digit', $onlyFirstValueCombinable2, $inputList2) + ->build() + ); + if ($onlyFirstValueCombinable1 && $onlyFirstValueCombinable2) { + $expected = [ + ['letter' => 'a', 'digit' => 1], + ['letter' => 'b', 'digit' => 1], + ['letter' => 'a', 'digit' => 2], + ['letter' => 'a', 'digit' => 3], + ]; + } else { + $expected = IterableUtil::toList( + CombinatorialUtil::cartesianProduct([$inputList1, $inputList2]) + ); + } + AssertEx::equalAsSets( + $expected, + $actual, + LoggableToString::convert( + [ + 'onlyFirstValueCombinable1' => $onlyFirstValueCombinable1, + 'onlyFirstValueCombinable2' => $onlyFirstValueCombinable2, + '$expected' => $expected, + 'actual' => $actual, + ] + ) + ); + } + + /** + * @dataProvider dataProviderOneBoolArg + */ + public function testCartesianProductKeyed(bool $dimAOnlyFirstValueCombinable): void + { + $actual = IterableUtil::toList( + (new DataProviderForTestBuilder()) + ->addKeyedDimension('dimA', $dimAOnlyFirstValueCombinable, [1.23, 4.56]) + ->addCartesianProductOnlyFirstValueCombinable(['dimB' => [1, 2, 3], 'dimC' => ['a', 'b']]) + ->build() + ); + $expected = $dimAOnlyFirstValueCombinable + ? + [ + ['dimA' => 1.23, 'dimB' => 1, 'dimC' => 'a'], + ['dimA' => 4.56, 'dimB' => 1, 'dimC' => 'a'], + ['dimA' => 1.23, 'dimB' => 1, 'dimC' => 'b'], + ['dimA' => 1.23, 'dimB' => 2, 'dimC' => 'a'], + ['dimA' => 1.23, 'dimB' => 2, 'dimC' => 'b'], + ['dimA' => 1.23, 'dimB' => 3, 'dimC' => 'a'], + ['dimA' => 1.23, 'dimB' => 3, 'dimC' => 'b'], + ] + : + [ + ['dimA' => 1.23, 'dimB' => 1, 'dimC' => 'a'], + ['dimA' => 4.56, 'dimB' => 1, 'dimC' => 'a'], + ['dimA' => 1.23, 'dimB' => 1, 'dimC' => 'b'], + ['dimA' => 4.56, 'dimB' => 1, 'dimC' => 'b'], + ['dimA' => 1.23, 'dimB' => 2, 'dimC' => 'a'], + ['dimA' => 4.56, 'dimB' => 2, 'dimC' => 'a'], + ['dimA' => 1.23, 'dimB' => 2, 'dimC' => 'b'], + ['dimA' => 4.56, 'dimB' => 2, 'dimC' => 'b'], + ['dimA' => 1.23, 'dimB' => 3, 'dimC' => 'a'], + ['dimA' => 4.56, 'dimB' => 3, 'dimC' => 'a'], + ['dimA' => 1.23, 'dimB' => 3, 'dimC' => 'b'], + ['dimA' => 4.56, 'dimB' => 3, 'dimC' => 'b'], + ]; + AssertEx::equalAsSets( + $expected, + $actual, + LoggableToString::convert(['$expected' => $expected, 'actual' => $actual]) + ); + } + + /** + * @dataProvider dataProviderOneBoolArg + */ + public function testCartesianProduct(bool $dimAOnlyFirstValueCombinable): void + { + $actual = IterableUtil::toList( + (new DataProviderForTestBuilder()) + ->addDimension($dimAOnlyFirstValueCombinable, [1.23, 4.56]) + ->addCartesianProductOnlyFirstValueCombinable([[1, 2, 3], ['a', 'b']]) + ->build() + ); + $expected = $dimAOnlyFirstValueCombinable + ? + [ + [1.23, 1, 'a'], + [4.56, 1, 'a'], + [1.23, 1, 'b'], + [1.23, 2, 'a'], + [1.23, 2, 'b'], + [1.23, 3, 'a'], + [1.23, 3, 'b'], + ] + : + [ + [1.23, 1, 'a'], + [4.56, 1, 'a'], + [1.23, 1, 'b'], + [4.56, 1, 'b'], + [1.23, 2, 'a'], + [4.56, 2, 'a'], + [1.23, 2, 'b'], + [4.56, 2, 'b'], + [1.23, 3, 'a'], + [4.56, 3, 'a'], + [1.23, 3, 'b'], + [4.56, 3, 'b'], + ]; + AssertEx::equalAsSets( + $expected, + $actual, + LoggableToString::convert(['$expected' => $expected, 'actual' => $actual]) + ); + } + + public function testConditional(): void + { + $actual = IterableUtil::toList( + (new DataProviderForTestBuilder()) + ->addKeyedDimensionAllValuesCombinable('1st_dim_key', [1, 2]) + ->addConditionalKeyedDimensionAllValueCombinable( + '2ns_dim_key' /* <- new dimension key */, + '1st_dim_key' /* <- depends on dimension key */, + 1 /* <- depends on dimension true value */, + ['a'] /* <- new dimension variants for true case */, + ['b', 'c'] /* <- new dimension variants for false case */ + ) + ->build() + ); + $expected = + [ + ['1st_dim_key' => 1, '2ns_dim_key' => 'a'], + ['1st_dim_key' => 2, '2ns_dim_key' => 'b'], + ['1st_dim_key' => 2, '2ns_dim_key' => 'c'], + ]; + AssertEx::equalAsSets($expected, $actual); + } + + /** + * @dataProvider dataProviderOneBoolArg + */ + public function testUsingRangeForDimensionValues(bool $dimAOnlyFirstValueCombinable): void + { + $actual = IterableUtil::toList( + (new DataProviderForTestBuilder()) + ->addKeyedDimension('1st_dim_key', $dimAOnlyFirstValueCombinable, DataProviderForTestBuilder::rangeUpTo(2)) + ->addKeyedDimension('2nd_dim_key', $dimAOnlyFirstValueCombinable, DataProviderForTestBuilder::rangeUpTo(2)) + ->addKeyedDimension('3rd_dim_key', $dimAOnlyFirstValueCombinable, DataProviderForTestBuilder::rangeUpTo(2)) + ->build() + ); + $expected = $dimAOnlyFirstValueCombinable + ? + [ + ['1st_dim_key' => 0, '2ns_dim_key' => 0, '3rd_dim_key' => 0], + ['1st_dim_key' => 0, '2ns_dim_key' => 0, '3rd_dim_key' => 1], + ['1st_dim_key' => 0, '2ns_dim_key' => 1, '3rd_dim_key' => 0], + ['1st_dim_key' => 1, '2ns_dim_key' => 0, '3rd_dim_key' => 0], + ] + : + [ + ['1st_dim_key' => 0, '2ns_dim_key' => 0, '3rd_dim_key' => 0], + ['1st_dim_key' => 0, '2ns_dim_key' => 0, '3rd_dim_key' => 1], + ['1st_dim_key' => 0, '2ns_dim_key' => 1, '3rd_dim_key' => 0], + ['1st_dim_key' => 0, '2ns_dim_key' => 1, '3rd_dim_key' => 1], + ['1st_dim_key' => 1, '2ns_dim_key' => 0, '3rd_dim_key' => 0], + ['1st_dim_key' => 1, '2ns_dim_key' => 0, '3rd_dim_key' => 1], + ['1st_dim_key' => 1, '2ns_dim_key' => 1, '3rd_dim_key' => 0], + ['1st_dim_key' => 1, '2ns_dim_key' => 1, '3rd_dim_key' => 1], + ]; + AssertEx::equalAsSets($expected, $actual); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/DebugContextTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/DebugContextTest.php new file mode 100644 index 0000000..4ba75e9 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/DebugContextTest.php @@ -0,0 +1,1173 @@ +>> + */ +class DebugContextTest extends TestCaseBase +{ + private const NON_VENDOR_CALLS_DEPTH_KEY = 'non_vendor_calls_depth'; + private const USE_FAIL_TO_TRIGGER_ASSERTION_KEY = 'use_fail_to_trigger_assertion'; + private const SHOULD_TOP_NON_VENDOR_CALL_ADD_CONTEXT_KEY = 'add_context_top_non_vendor_call'; + private const SHOULD_MIDDLE_NON_VENDOR_CALL_ADD_CONTEXT_KEY = 'add_context_middle_non_vendor_call'; + private const SHOULD_BOTTOM_NON_VENDOR_CALL_ADD_CONTEXT_KEY = 'add_context_bottom_non_vendor_call'; + + #[Override] + public function setUp(): void + { + parent::setUp(); + + DebugContextConfig::addToAssertionMessage(false); + } + + private static function shortcutTestIfDebugContextIsDisabledByDefault(): bool + { + // Use BoolUtil::toString to avoid error "boolean expression is always false/true" from static analysis + if (BoolUtil::toString(DebugContextConfig::ENABLED_DEFAULT_VALUE) === 'false') { + self::dummyAssert(); + return true; + } + + return false; + } + + /** + * @param ?positive-int $line + */ + private static function assertScopeDesc(string $actualScopeDesc, ?string $class = __CLASS__, ?string $function = null, ?string $file = __FILE__, ?int $line = null, string $message = ''): void + { + $classFuncPart = ($class ?? '') . ($class !== null && $function !== null ? '::' : '') . ($function ?? ''); + if ($classFuncPart !== '') { + self::assertStringContainsString($classFuncPart, $actualScopeDesc, $message); + } + + if ($file !== null) { + self::assertStringContainsString($file . ($line === null ? '' : (':' . $line)), $actualScopeDesc, $message); + } + } + + /** + * @phpstan-param Context $actualCtx + * @phpstan-param Context $args + * @phpstan-param Context $addedCtx + */ + private static function assertScopeContext(array $actualCtx, ?object $thisObj, array $args, array $addedCtx): void + { + $index = 0; + if (DebugContextConfig::autoCaptureThis() && ($thisObj !== null)) { + self::assertTrue(IterableUtil::getNthKey($actualCtx, $index, /* out */ $actualCtxKey)); + self::assertSame(DebugContext::THIS_CONTEXT_KEY, $actualCtxKey); + self::assertTrue(IterableUtil::getNthValue($actualCtx, $index, /* out */ $actualCtxValue)); + self::assertSame(LoggableToString::convert($thisObj), LoggableToString::convert($actualCtxValue)); + ++$index; + } else { + self::assertArrayNotHasKey(DebugContext::THIS_CONTEXT_KEY, $actualCtx); + } + + if (DebugContextConfig::autoCaptureArgs()) { + foreach ($args as $argName => $argValue) { + self::assertTrue(IterableUtil::getNthKey($actualCtx, $index, /* out */ $actualCtxKey)); + self::assertSame($argName, $actualCtxKey); + self::assertTrue(IterableUtil::getNthValue($actualCtx, $index, /* out */ $actualCtxValue)); + self::assertSame(LoggableToString::convert($argValue), LoggableToString::convert($actualCtxValue)); + ++$index; + } + } else { + foreach (IterableUtil::keys($args) as $argName) { + self::assertArrayNotHasKey($argName, $actualCtx); + } + } + + foreach ($addedCtx as $addedCtxKey => $addedCtxValue) { + self::assertTrue(IterableUtil::getNthKey($actualCtx, $index, /* out */ $actualCtxKey)); + self::assertSame($addedCtxKey, $actualCtxKey); + self::assertTrue(IterableUtil::getNthValue($actualCtx, $index, /* out */ $actualCtxValue)); + self::assertSame(LoggableToString::convert($addedCtxValue), LoggableToString::convert($actualCtxValue)); + ++$index; + } + + self::assertCount($index, $actualCtx); + } + + /** + * @param ExpectedContextsStack $expected + */ + private static function assertCurrentContextsStack(array $expected): void + { + $actual = DebugContext::getContextsStack(); + + if (!DebugContextConfig::enabled()) { + self::assertEmpty($actual); + return; + } + + /** @var array $dbgCtx */ + $dbgCtx = []; + $dbgCtx['count(expected)'] = count($expected); + $dbgCtx['count(actual)'] = count($actual); + ArrayUtilForTests::append(compact('expected', 'actual'), /* in,out */ $dbgCtx); + self::assertSame(count($expected), count($actual), LoggableToString::convert($dbgCtx)); + foreach (IterableUtil::zip(IterableUtil::keys($expected), IterableUtil::keys($actual)) as [$expectedCtxIndex, $actualCtxDesc]) { + /** @var int $expectedCtxIndex */ + /** @var string $actualCtxDesc */ + $dbgCtx = array_merge($dbgCtx, compact('expectedCtxIndex', 'actualCtxDesc')); + /** @var array{string, array} $expectedCtx */ + $expectedCtxFuncName = $expected[$expectedCtxIndex]->first; + $expectedCtx = $expected[$expectedCtxIndex]->second; + $actualCtx = $actual[$actualCtxDesc]; + $dbgCtx = array_merge($dbgCtx, compact('expectedCtxFuncName', 'expectedCtx', 'actualCtx')); + $dbgCtxStr = LoggableToString::convert($dbgCtx); + self::assertScopeDesc($actualCtxDesc, function: $expectedCtxFuncName, message: $dbgCtxStr); + self::assertStringContainsString($expectedCtxFuncName, $actualCtxDesc, $dbgCtxStr); + self::assertStringContainsString(basename(__FILE__) . ':', $actualCtxDesc, $dbgCtxStr); + self::assertStringContainsString(__CLASS__, $actualCtxDesc, $dbgCtxStr); + self::assertSame(count($expectedCtx), count($actualCtx), $dbgCtxStr); + foreach (IterableUtil::zip(IterableUtil::keys($expectedCtx), IterableUtil::keys($actualCtx)) as [$expectedKey, $actualKey]) { + $dbgCtx = array_merge($dbgCtx, compact('expectedKey', 'actualKey')); + $dbgCtxStr = LoggableToString::convert($dbgCtx); + self::assertSame($expectedKey, $actualKey, $dbgCtxStr); + self::assertSame($expectedCtx[$expectedKey], $actualCtx[$actualKey], $dbgCtxStr); + } + } + } + + /** + * @phpstan-param Context $initialCtx + * @phpstan-param ExpectedContextsStack $expectedContextsStackFromCaller + * + * @return ExpectedContextsStack + */ + private static function newExpectedScope(string $funcName, array $initialCtx = [], array $expectedContextsStackFromCaller = []): array + { + $expectedContextsStack = $expectedContextsStackFromCaller; + $newCount = array_unshift(/* ref */ $expectedContextsStack, new Pair($funcName, $initialCtx)); + self::assertSame(count($expectedContextsStackFromCaller) + 1, $newCount); + self::assertCurrentContextsStack($expectedContextsStack); + return $expectedContextsStack; + } + + /** + * @phpstan-param ExpectedContextsStack $expectedContextsStack + * @phpstan-param Context $ctx + */ + private static function addToTopExpectedScope(/* ref */ array $expectedContextsStack, array $ctx): void + { + self::assertNotEmpty($expectedContextsStack); + DebugContextScope::appendContext(from: $ctx, to: $expectedContextsStack[0]->second); + self::assertCurrentContextsStack($expectedContextsStack); + } + + private static function assertActualTopScopeHasKeyWithSameValue(string|int $expectedKey, mixed $expectedValue): void + { + $actualContextsStack = DebugContext::getContextsStack(); + if (!DebugContextConfig::enabled()) { + self::assertEmpty($actualContextsStack); + return; + } + + self::assertNotEmpty($actualContextsStack); + $actualTopScope = ArrayUtilForTests::getFirstValue($actualContextsStack); + AssertEx::arrayHasKeyWithSameValue($expectedKey, $expectedValue, $actualTopScope); + } + + /** @noinspection PhpSameParameterValueInspection */ + private static function assertActualTopScopeNotHasKey(string|int $expectedKey): void + { + $actualContextsStack = DebugContext::getContextsStack(); + if (!DebugContextConfig::enabled()) { + self::assertEmpty($actualContextsStack); + return; + } + + self::assertNotEmpty($actualContextsStack); + $actualTopScope = ArrayUtilForTests::getFirstValue($actualContextsStack); + self::assertArrayNotHasKey($expectedKey, $actualTopScope); + } + + /** + * @param array $optionsToVariate + */ + private static function dataProviderBuilderForDebugContextConfig(array $optionsToVariate): DataProviderForTestBuilder + { + $builder = new DataProviderForTestBuilder(); + foreach ($optionsToVariate as $optionName) { + $builder->addKeyedDimensionAllValuesCombinable($optionName, BoolUtil::allValuesStartingFrom(DebugContextConfig::DEFAULT_VALUES[$optionName])); + } + return $builder; + } + + private static function setDebugContextConfigFromTestArgs(MixedMap $testArgs): void + { + $config = DebugContextConfig::getCopy(); + foreach ($testArgs as $testArgName => $testArgValue) { + if (array_key_exists($testArgName, DebugContextConfig::DEFAULT_VALUES)) { + self::assertIsBool($testArgValue); + $config[$testArgName] = $testArgValue; + } + } + DebugContextConfig::set($config); + } + + private static function thisTestAssumesOnlyAddedContext(): void + { + // DebugContext by default should use all scopes not just the ones with added context + self::assertFalse(DebugContextConfig::onlyAddedContext()); + // This test assumes that only scopes with added context are used + DebugContextConfig::onlyAddedContext(true); + } + + /** + * @return iterable + */ + public static function dataProviderForBasicDebugContextConfig(): iterable + { + return self::dataProviderBuilderForDebugContextConfig( + [ + DebugContextConfig::ENABLED_OPTION_NAME, + DebugContextConfig::USE_DESTRUCTORS_OPTION_NAME, + ] + )->buildAsMixedMaps(); + } + + /** + * @dataProvider dataProviderForBasicDebugContextConfig + */ + public function testOneFunctionOnlyAddedContext(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + self::thisTestAssumesOnlyAddedContext(); + + DebugContext::getCurrentScope(/* out */ $dbgCtx, ['my_key' => 1]); + $expectedContextsStack = self::newExpectedScope(__FUNCTION__, ['my_key' => 1]); + self::assertActualTopScopeHasKeyWithSameValue('my_key', 1); + $dbgCtx->add(['my_key' => '2']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my_key' => '2']); + self::assertActualTopScopeHasKeyWithSameValue('my_key', '2'); + $dbgCtx->add(['my_other_key' => 3.5]); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my_other_key' => 3.5]); + self::assertActualTopScopeHasKeyWithSameValue('my_other_key', 3.5); + } + + /** + * @dataProvider dataProviderForBasicDebugContextConfig + */ + public function testTwoFunctionsOnlyAddedContext(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + self::thisTestAssumesOnlyAddedContext(); + + DebugContext::getCurrentScope(/* out */ $dbgCtx, ['my context' => 'before func']); + $expectedContextsStack = self::newExpectedScope(__FUNCTION__, ['my context' => 'before func']); + + /** + * @param ExpectedContextsStack $expectedContextsStackFromCaller + */ + $secondFunc = static function (array $expectedContextsStackFromCaller): void { + /** @var ExpectedContextsStack $expectedContextsStackFromCaller */ + DebugContext::getCurrentScope(/* out */ $dbgCtx, ['my context' => 'func entry']); + $expectedContextsStack = self::newExpectedScope(__FUNCTION__, ['my context' => 'func entry'], $expectedContextsStackFromCaller); + self::assertActualTopScopeHasKeyWithSameValue('my context', 'func entry'); + + $dbgCtx->add(['some_other_key' => 'inside func']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['some_other_key' => 'inside func']); + self::assertActualTopScopeHasKeyWithSameValue('some_other_key', 'inside func'); + }; + + $secondFunc($expectedContextsStack); + self::assertActualTopScopeHasKeyWithSameValue('my context', 'before func'); + self::assertActualTopScopeNotHasKey('some_other_key'); + + $dbgCtx->add(['my context' => 'after func']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'after func']); + self::assertActualTopScopeHasKeyWithSameValue('my context', 'after func'); + } + + /** + * @dataProvider dataProviderForBasicDebugContextConfig + */ + public function testSubScopeSimple(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + self::thisTestAssumesOnlyAddedContext(); + + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $expectedContextsStack = self::newExpectedScope(__FUNCTION__); + $dbgCtx->add(['my context' => 'before sub-scope']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'before sub-scope']); + + { + $dbgCtx->add(['my context' => 'inside sub-scope']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'inside sub-scope']); + self::assertActualTopScopeHasKeyWithSameValue('my context', 'inside sub-scope'); + } + self::assertCurrentContextsStack($expectedContextsStack); + self::assertActualTopScopeHasKeyWithSameValue('my context', 'inside sub-scope'); + + $dbgCtx->add(['my context' => 'after sub-scope']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'after sub-scope']); + self::assertActualTopScopeHasKeyWithSameValue('my context', 'after sub-scope'); + } + + /** + * @dataProvider dataProviderForBasicDebugContextConfig + */ + public function testWithLoop(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + self::thisTestAssumesOnlyAddedContext(); + + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $expectedContextsStack = self::newExpectedScope(__FUNCTION__); + $dbgCtx->add(['my context' => 'before loop']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'before loop']); + + foreach (RangeUtil::generateUpTo(2) as $index) { + $dbgCtx->add(compact('index')); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, compact('index')); + $dbgCtx->add(['key_with_index_' . $index => 'value_with_index_' . $index]); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['key_with_index_' . $index => 'value_with_index_' . $index]); + } + + $dbgCtx->add(['my context' => 'after loop']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'after loop']); + } + + /** + * @dataProvider dataProviderForBasicDebugContextConfig + */ + public function testSubScopeForLoopWithContinue(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + self::thisTestAssumesOnlyAddedContext(); + + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $expectedContextsStack = self::newExpectedScope(__FUNCTION__); + $dbgCtx->add(['my context' => 'before 1st loop']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'before 1st loop']); + + foreach (RangeUtil::generateUpTo(3) as $index1stLoop) { + $dbgCtx->add(compact('index1stLoop')); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, compact('index1stLoop')); + + $dbgCtx->add(['my context' => 'before 1st loop']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'before 1st loop']); + + $dbgCtx->add(['1st loop key with index ' . $index1stLoop => '1st loop value with index ' . $index1stLoop]); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['1st loop key with index ' . $index1stLoop => '1st loop value with index ' . $index1stLoop]); + + foreach (RangeUtil::generateUpTo(5) as $index2ndLoop) { + $dbgCtx->add(compact('index2ndLoop')); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, compact('index2ndLoop')); + + if ($index2ndLoop > 2) { + continue; + } + + $dbgCtx->add(['2nd loop key with index ' . $index2ndLoop => '2nd loop value with index ' . $index2ndLoop]); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['2nd loop key with index ' . $index2ndLoop => '2nd loop value with index ' . $index2ndLoop]); + } + + if ($index1stLoop > 1) { + continue; + } + + $dbgCtx->add(['my context' => 'after 2nd loop']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'after 2nd loop']); + } + + $dbgCtx->add(['my context' => 'after 1st loop']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'after 1st loop']); + } + + /** + * @param ExpectedContextsStack $expectedContextsStackFromCaller + */ + private static function recursiveFunc(int $currentDepth, array $expectedContextsStackFromCaller): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx, ['my context' => 'inside recursive func before recursive call']); + $expectedContextsStack = self::newExpectedScope(__FUNCTION__, ['my context' => 'inside recursive func before recursive call'], $expectedContextsStackFromCaller); + + $dbgCtx->add(['key for depth ' . $currentDepth => 'value for depth ' . $currentDepth]); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['key for depth ' . $currentDepth => 'value for depth ' . $currentDepth]); + + if ($currentDepth < 3) { + self::recursiveFunc($currentDepth + 1, $expectedContextsStack); + } + + $assertMsgCtx = compact('currentDepth', 'expectedContextsStack'); + $depth = $currentDepth; + foreach (DebugContext::getContextsStack() as $actualCtxDesc => $actualCtx) { + $assertMsgCtx = array_merge($assertMsgCtx, compact('depth', 'actualCtxDesc', 'actualCtx')); + self::assertStringContainsString(__FUNCTION__, $actualCtxDesc, LoggableToString::convert($assertMsgCtx)); + self::assertStringContainsString(basename(__FILE__), $actualCtxDesc, LoggableToString::convert($assertMsgCtx)); + self::assertStringContainsString(__CLASS__, $actualCtxDesc, LoggableToString::convert($assertMsgCtx)); + + self::assertSame('inside recursive func before recursive call', $actualCtx['my context'], LoggableToString::convert($assertMsgCtx)); + self::assertSame('value for depth ' . $depth, $actualCtx['key for depth ' . $depth], LoggableToString::convert($assertMsgCtx)); + + if ($depth === 1) { + break; + } + --$depth; + } + + $dbgCtx->add(['my context' => 'inside recursive func after recursive call']); + self::addToTopExpectedScope(/* ref */ $expectedContextsStack, ['my context' => 'inside recursive func after recursive call']); + } + + /** + * @dataProvider dataProviderForBasicDebugContextConfig + */ + public function testRecursiveFunc(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + self::thisTestAssumesOnlyAddedContext(); + + DebugContext::getCurrentScope(/* out */ $dbgCtx, ['my context' => 'before recursive func']); + $expectedContextsStack = self::newExpectedScope(__FUNCTION__, ['my context' => 'before recursive func']); + + self::recursiveFunc(1, $expectedContextsStack); + } + + /** + * @dataProvider dataProviderForBasicDebugContextConfig + */ + public function testCaptureVarByRef(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + self::thisTestAssumesOnlyAddedContext(); + + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $localVar = 1; + // NOTE: $localVar is added to debug context by reference below + $dbgCtx->add(['localVar' => &$localVar]); + + $localVar = 2; + + $capturedCtxStack = DebugContext::getContextsStack(); + if (DebugContextConfig::enabled()) { + self::assertCount(1, $capturedCtxStack); + $thisFuncCtx = ArrayUtilForTests::getFirstValue($capturedCtxStack); + self::assertArrayHasKey('localVar', $thisFuncCtx); + self::assertSame(2, $thisFuncCtx['localVar']); + } else { + self::assertEmpty($capturedCtxStack); + } + } + + /** + * @return iterable + */ + public static function dataProviderForTestFailedAssertionMessageContainsDebugContext(): iterable + { + return self::dataProviderBuilderForDebugContextConfig( + [ + DebugContextConfig::ENABLED_OPTION_NAME, + DebugContextConfig::USE_DESTRUCTORS_OPTION_NAME, + DebugContextConfig::ADD_TO_ASSERTION_MESSAGE_OPTION_NAME, + ] + )->buildAsMixedMaps(); + } + + /** + * @dataProvider dataProviderForTestFailedAssertionMessageContainsDebugContext + */ + public function testFailedAssertionMessageContainsDebugContext(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + self::thisTestAssumesOnlyAddedContext(); + + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $dbgCtx->add(['myLocalVar' => 'my localVar value']); + + $exceptionMsg = null; + try { + self::fail(); + } catch (AssertionFailedError $ex) { + $exceptionMsg = $ex->getMessage(); + } + + $expectedMessageContains = DebugContextConfig::enabled() && DebugContextConfig::addToAssertionMessage(); + $helperCtxStr = LoggableToString::convert(compact('expectedMessageContains', 'exceptionMsg')); + self::assertSame($expectedMessageContains, str_contains($exceptionMsg, 'myLocalVar'), $helperCtxStr); + self::assertSame($expectedMessageContains, str_contains($exceptionMsg, 'my localVar value'), $helperCtxStr); + } + + /** + * @dataProvider dataProviderForBasicDebugContextConfig + */ + public function testLineInScopeDescUpdated(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + // Add dummy context entry to ensure this scope is included + $dbgCtx->add(['dummy key' => 'dummy value']); + + /** + * @phpstan-param positive-int $expectedLine + * @phpstan-param ContextsStack $contextsStack + */ + $assertTopScopeLine = function (int $expectedLine): void { + $contextsStack = DebugContext::getContextsStack(); + if (DebugContextConfig::enabled()) { + if (DebugContextConfig::onlyAddedContext()) { + $testFuncScopeDesc = array_key_first($contextsStack); + } else { + self::assertTrue(IterableUtil::getNthKey($contextsStack, 1, /* out */ $testFuncScopeDesc)); + } + self::assertIsString($testFuncScopeDesc); + self::assertStringContainsString(__FILE__ . ':' . $expectedLine, $testFuncScopeDesc); + } else { + self::assertEmpty($contextsStack); + } + }; + + $assertTopScopeLine(__LINE__); + $assertTopScopeLine(__LINE__); + } + + /** + * @param int $intParam + * @param ?string $nullableStringParam + * + * @return ContextsStack + * + * @noinspection PhpUnusedParameterInspection + */ + private static function helperFuncForTestAutoCaptureArgs(int $intParam, ?string $nullableStringParam): array + { + return DebugContext::getContextsStack(); + } + + /** + * @return iterable + */ + public static function dataProviderForTestAutoCaptureArgs(): iterable + { + $dummyArgsTuples = [ + ['intParam' => 1, 'nullableStringParam' => 'abc'], + ['intParam' => 2, 'nullableStringParam' => null], + ]; + + /** + * @param array $resultSoFar + * + * @return iterable> + */ + $addDummyArgs = function (array $resultSoFar) use ($dummyArgsTuples): iterable { + foreach ($dummyArgsTuples as $dummyArgsTuple) { + yield array_merge($resultSoFar, $dummyArgsTuple); + } + }; + + $optionsToVariate = [ + DebugContextConfig::ENABLED_OPTION_NAME, + DebugContextConfig::USE_DESTRUCTORS_OPTION_NAME, + DebugContextConfig::ONLY_ADDED_CONTEXT_OPTION_NAME, + DebugContextConfig::AUTO_CAPTURE_THIS_OPTION_NAME, + DebugContextConfig::AUTO_CAPTURE_ARGS_OPTION_NAME, + ]; + return self::dataProviderBuilderForDebugContextConfig($optionsToVariate) + ->addGeneratorOnlyFirstValueCombinable($addDummyArgs) + ->buildAsMixedMaps(); + } + + /** + * @dataProvider dataProviderForTestAutoCaptureArgs + */ + public function testAutoCaptureArgs(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $myDummyLocalVar = 'my dummy local var value'; + $dbgCtx->add(compact('myDummyLocalVar')); + + $actualContextsStack = self::helperFuncForTestAutoCaptureArgs($testArgs->getInt('intParam'), $testArgs->getNullableString('nullableStringParam')); + + if (!DebugContextConfig::enabled()) { + self::assertEmpty($actualContextsStack); + return; + } + AssertEx::countAtLeast(DebugContextConfig::onlyAddedContext() ? 1 : 2, $actualContextsStack); + + if (!DebugContextConfig::onlyAddedContext()) { + $helperFuncCtx = ArrayUtilForTests::getFirstValue($actualContextsStack); + // helperFuncForTestAutoCaptureArgs is static so there is no $this + self::assertArrayNotHasKey(DebugContext::THIS_CONTEXT_KEY, $helperFuncCtx); + foreach (['intParam', 'nullableStringParam'] as $argName) { + if (DebugContextConfig::autoCaptureArgs()) { + AssertEx::arrayHasKeyWithSameValue($argName, $testArgs->get($argName), $helperFuncCtx); + } else { + self::assertArrayNotHasKey($argName, $helperFuncCtx); + } + } + } + + self::assertTrue(IterableUtil::getNthValue($actualContextsStack, DebugContextConfig::onlyAddedContext() ? 0 : 1, /* out */ $thisFuncCtx)); + self::assertIsArray($thisFuncCtx); + AssertEx::arrayHasKeyWithSameValue('myDummyLocalVar', $myDummyLocalVar, $thisFuncCtx); + if (!DebugContextConfig::onlyAddedContext() && DebugContextConfig::autoCaptureThis()) { + AssertEx::arrayHasKeyWithSameValue(DebugContext::THIS_CONTEXT_KEY, $this, $thisFuncCtx); + } else { + self::assertArrayNotHasKey(DebugContext::THIS_CONTEXT_KEY, $thisFuncCtx); + } + if (!DebugContextConfig::onlyAddedContext() && DebugContextConfig::autoCaptureArgs()) { + AssertEx::arrayHasKeyWithSameValue('testArgs', $testArgs, $thisFuncCtx); + } else { + self::assertArrayNotHasKey('testArgs', $thisFuncCtx); + } + } + + /** + * @dataProvider dataProviderForBasicDebugContextConfig + */ + public function testTheSameClosureCalledTwiceOnTheSameLine(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + self::setDebugContextConfigFromTestArgs($testArgs); + + $capturedVar = 0; + /** @var list $contextsStacks */ + $contextsStacks = []; + $closure = function () use (&$capturedVar, &$contextsStacks): int { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $dbgCtx->add(['ctx_' . $capturedVar => 'value for ctx_' . $capturedVar]); + $contextsStacks[] = DebugContext::getContextsStack(); + return $capturedVar++; + }; + + $thisFuncLine = __LINE__ + 1; + self::assertSame(1, $closure() + $closure()); + + self::assertCount(2, $contextsStacks); + foreach ([0, 1] as $capturedVarVal) { + $contextsStack = $contextsStacks[$capturedVarVal]; + + if (!DebugContextConfig::enabled()) { + self::assertEmpty($contextsStack); + continue; + } + + self::assertTrue(IterableUtil::getNthKey($contextsStack, 0, /* out */ $closureCtxDesc)); + /** @var string $closureCtxDesc */ + self::assertStringContainsString(__FILE__ . ':', $closureCtxDesc); + $closureCtx = ArrayUtilForTests::getFirstValue($contextsStack); + self::assertCount(2, $closureCtx); + AssertEx::arrayHasKeyWithSameValue(DebugContext::THIS_CONTEXT_KEY, $this, $closureCtx); + AssertEx::arrayHasKeyWithSameValue('ctx_' . $capturedVarVal, 'value for ctx_' . $capturedVarVal, $closureCtx); + + self::assertTrue(IterableUtil::getNthKey($contextsStack, 1, /* out */ $thisFuncCtxDesc)); + /** @var string $thisFuncCtxDesc */ + self::assertScopeDesc($thisFuncCtxDesc, function: __FUNCTION__, line: $thisFuncLine); + self::assertTrue(IterableUtil::getNthValue($contextsStack, 1, /* out */ $thisFuncCtx)); + /** @var Context $thisFuncCtx */ + self::assertCount(2, $thisFuncCtx); + AssertEx::arrayHasKeyWithSameValue(DebugContext::THIS_CONTEXT_KEY, $this, $thisFuncCtx); + AssertEx::arrayHasKeyWithSameValue('testArgs', $testArgs, $thisFuncCtx); + } + } + + private static function extractTextAddedToAssertionMessage(string $exceptionMsg): string + { + $prefixPos = strpos($exceptionMsg, DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_PREFIX); + self::assertNotFalse($prefixPos); + $afterPrefixPos = $prefixPos + strlen(DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_PREFIX); + $suffixPos = strpos($exceptionMsg, DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_SUFFIX, offset: $afterPrefixPos); + self::assertNotFalse($suffixPos); + return trim(substr($exceptionMsg, $afterPrefixPos, $suffixPos - $afterPrefixPos)); + } + + /** + * @return iterable + */ + public static function dataProviderForTestTrimVendorFrames(): iterable + { + $optionsToVariate = [ + DebugContextConfig::TRIM_VENDOR_FRAMES_OPTION_NAME, + ]; + return self::dataProviderBuilderForDebugContextConfig($optionsToVariate) + ->addKeyedDimensionAllValuesCombinable(self::NON_VENDOR_CALLS_DEPTH_KEY, DataProviderForTestBuilder::rangeFromToIncluding(1, 6)) + ->addBoolKeyedDimensionAllValuesCombinable(self::SHOULD_TOP_NON_VENDOR_CALL_ADD_CONTEXT_KEY) + ->addBoolKeyedDimensionOnlyFirstValueCombinable(self::SHOULD_MIDDLE_NON_VENDOR_CALL_ADD_CONTEXT_KEY) + ->addBoolKeyedDimensionOnlyFirstValueCombinable(self::SHOULD_BOTTOM_NON_VENDOR_CALL_ADD_CONTEXT_KEY) + // Use Assert::assertXYZ with all the combinations in other dimensions and Assert::fail only with the first tuple of the combinations in other dimensions + ->addKeyedDimensionOnlyFirstValueCombinable(self::USE_FAIL_TO_TRIGGER_ASSERTION_KEY, BoolUtil::allValuesStartingFrom(false)) + ->buildAsMixedMaps(); + } + + private const HELPER_FUNC_FOR_TEST_TRIM_VENDOR_FRAMES_NAME = 'helperFuncForTestTrimVendorFrames'; + + /** + * @param positive-int &$actualNonVendorCallDepth + * @param ?positive-int &$lineNumber + * + * @param-out positive-int $actualNonVendorCallDepth + * @param-out positive-int $lineNumber + */ + private static function helperFuncForTestTrimVendorFrames(MixedMap $testArgs, /* out */ ?int &$lineNumber, /* in,out */ int &$actualNonVendorCallDepth): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + self::assertNull($lineNumber); + self::assertGreaterThanOrEqual(1, $actualNonVendorCallDepth); + self::assertSame(self::HELPER_FUNC_FOR_TEST_TRIM_VENDOR_FRAMES_NAME, __FUNCTION__); // @phpstan-ignore staticMethod.alreadyNarrowedType + + ++$actualNonVendorCallDepth; + + $shouldTopNonVendorCallAddContext = $testArgs->getBool(self::SHOULD_TOP_NON_VENDOR_CALL_ADD_CONTEXT_KEY); + $useFailToTriggerAssertion = $testArgs->getBool(self::USE_FAIL_TO_TRIGGER_ASSERTION_KEY); + + if ($shouldTopNonVendorCallAddContext) { + $dbgCtx->add(['dummy ctx in top non-vendor call' => '[value for dummy ctx in top non-vendor call]']); + } + + /** @noinspection PhpIfWithCommonPartsInspection */ + if ($useFailToTriggerAssertion) { + $lineNumber = __LINE__ + 1; + self::fail('Dummy message'); + } else { + $lineNumber = __LINE__ + 1; + self::assertSame(1, 2); // @phpstan-ignore staticMethod.impossibleType + } + } + + /** + * @dataProvider dataProviderForTestTrimVendorFrames + */ + public function testTrimVendorFrames(MixedMap $testArgs): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + self::setDebugContextConfigFromTestArgs($testArgs); + + // This test extracts debug context from text added to assertion message + DebugContextConfig::addToAssertionMessage(true); + + $nonVendorCallsDepth = $testArgs->getInt(self::NON_VENDOR_CALLS_DEPTH_KEY); + AssertEx::inClosedRange(1, $nonVendorCallsDepth, 6); + /** @var int<1, 6> $nonVendorCallsDepth */ + $shouldTopNonVendorCallAddContext = $testArgs->getBool(self::SHOULD_TOP_NON_VENDOR_CALL_ADD_CONTEXT_KEY); + $shouldMiddleNonVendorCallAddContext = $testArgs->getBool(self::SHOULD_MIDDLE_NON_VENDOR_CALL_ADD_CONTEXT_KEY); + $shouldBottomNonVendorCallAddContext = $testArgs->getBool(self::SHOULD_BOTTOM_NON_VENDOR_CALL_ADD_CONTEXT_KEY); + $useFailToTriggerAssertion = $testArgs->getBool(self::USE_FAIL_TO_TRIGGER_ASSERTION_KEY); + + if ($shouldBottomNonVendorCallAddContext) { + $dbgCtx->add(['dummy ctx in bottom non-vendor call' => '[value for dummy ctx in bottom non-vendor call]']); + } + + $actualNonVendorCallDepth = 1; + + /** @var ?positive-int $helperFuncLine */ + $helperFuncLine = null; + /** @var ?positive-int $closureCallingHelperFuncLine */ + $closureCallingHelperFuncLine = null; + $closureCallingHelperFunc = static function () use ($testArgs, /* out */ &$helperFuncLine, /* out */ &$closureCallingHelperFuncLine, /* in,out */ &$actualNonVendorCallDepth): void { + ++$actualNonVendorCallDepth; + self::assertNull($closureCallingHelperFuncLine); + $closureCallingHelperFuncLine = __LINE__ + 1; + self::helperFuncForTestTrimVendorFrames($testArgs, /* out */ $helperFuncLine, /* in,out */ $actualNonVendorCallDepth); + }; + + /** @var ?positive-int $closureCallingDummyFuncWithoutNamespaceLine */ + $closureCallingDummyFuncWithoutNamespaceLine = null; + $closureCallingDummyFuncWithoutNamespace = function () use ( + $closureCallingHelperFunc, + $shouldMiddleNonVendorCallAddContext, + &$closureCallingDummyFuncWithoutNamespaceLine, + &$actualNonVendorCallDepth, + ): void { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + if ($shouldMiddleNonVendorCallAddContext) { + $dbgCtx->add(['dummy ctx in middle non-vendor call' => '[value for dummy ctx in middle non-vendor call]']); + } + + // Add 2 because it's this closure and dummyFuncForTestsWithoutNamespace + $actualNonVendorCallDepth += 2; + self::assertNull($closureCallingDummyFuncWithoutNamespaceLine); + $closureCallingDummyFuncWithoutNamespaceLine = __LINE__ + 1; + dummyFuncForTestsWithoutNamespace($closureCallingHelperFunc); + }; + + /** @var ?string $assertionMsg */ + $assertionMsg = null; + /** @var ?positive-int $testFuncLine */ + $testFuncLine = null; + try { + switch ($nonVendorCallsDepth) { + case 1: + // [testFunc] + /** @noinspection PhpIfWithCommonPartsInspection */ + if ($useFailToTriggerAssertion) { + $testFuncLine = __LINE__ + 1; + self::fail('Dummy message'); + } else { + $testFuncLine = __LINE__ + 1; + self::assertSame(1, 2); // @phpstan-ignore staticMethod.impossibleType + } + break; + case 2: + // [testFunc, helperFunc] + $testFuncLine = __LINE__ + 1; + self::helperFuncForTestTrimVendorFrames($testArgs, /* out */ $helperFuncLine, /* in,out */ $actualNonVendorCallDepth); + break; + case 3: + // [testFunc, closureCallingHelperFunc, helperFunc] + $testFuncLine = __LINE__ + 1; + $closureCallingHelperFunc(); + break; + case 4: + ++$actualNonVendorCallDepth; // to account for dummyFuncForTestsWithoutNamespace call + // [testFunc, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc] + $testFuncLine = __LINE__ + 1; + dummyFuncForTestsWithoutNamespace($closureCallingHelperFunc); + break; + case 5: + // [testFunc, closureCallingDummyFuncWithoutNamespace, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc] + $testFuncLine = __LINE__ + 1; + $closureCallingDummyFuncWithoutNamespace(); + break; + case 6: + // [testFunc, dummyFuncForTestsWithNamespace, closureCallingDummyFuncWithoutNamespace, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc] + ++$actualNonVendorCallDepth; // to account for dummyFuncForTestsWithNamespace call + $testFuncLine = __LINE__ + 1; + dummyFuncForTestsWithNamespace($closureCallingDummyFuncWithoutNamespace); + break; + default: + throw new RuntimeException('Unexpected nonVendorCallsDepth value: ' . $nonVendorCallsDepth); + } + } catch (AssertionFailedError $ex) { + $assertionMsg = $ex->getMessage(); + } + $dbgCtx->add(compact('assertionMsg')); + self::assertNotNull($assertionMsg); + self::assertNotNull($testFuncLine); + self::assertSame($nonVendorCallsDepth, $actualNonVendorCallDepth); + + $addedText = self::extractTextAddedToAssertionMessage($assertionMsg); + + if (!DebugContextConfig::enabled()) { + self::assertSame(DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_WHEN_DISABLED, $addedText); + return; + } + + $decodedContextsStack = JsonUtil::decode($addedText, asAssocArray: true); + self::assertIsArray($decodedContextsStack); + + // Contexts in $decodedContextsStack is the order of the top call being at index 0 + if (DebugContextConfig::trimVendorFrames()) { + self::assertCount($nonVendorCallsDepth + 1, $decodedContextsStack); + $testFuncCtxIndex = $nonVendorCallsDepth; + } else { + // ... calls ... ... calls ... Assert::xyz() + AssertEx::countAtLeast($nonVendorCallsDepth + 2, $decodedContextsStack); + $testFuncCtxIndex = null; + /** @var non-negative-int $scopeIndex */ + /** @var string $scopeDesc */ + foreach (IterableUtil::zipWithIndex(IterableUtil::keys($decodedContextsStack)) as [$scopeIndex, $scopeDesc]) { + if (str_contains(haystack: $scopeDesc, needle: __FUNCTION__)) { + $testFuncCtxIndex = $scopeIndex; + } + } + self::assertIsInt($testFuncCtxIndex); + } + self::assertGreaterThanOrEqual($nonVendorCallsDepth, $testFuncCtxIndex); + + // 1: [testFunc] + // 2: [testFunc, helperFunc] + // 3: [testFunc, closureCallingHelperFunc, helperFunc] + // 4: [testFunc, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc] + // 5: [testFunc, closureCallingDummyFuncWithoutNamespace, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc] + // 6: [testFunc, dummyFuncForTestsWithNamespace, closureCallingDummyFuncWithoutNamespace, dummyFuncForTestsWithoutNamespace, closureCallingHelperFunc, helperFunc] + + $scopeIndex = $testFuncCtxIndex; + + self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $testFuncScopeDesc)); + self::assertIsString($testFuncScopeDesc); + self::assertScopeDesc($testFuncScopeDesc, function: __FUNCTION__, line: $testFuncLine); + self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $testFuncScopeCtx)); + self::assertIsArray($testFuncScopeCtx); + /** @var Context $testFuncScopeCtx */ + $addedCtx = $shouldBottomNonVendorCallAddContext ? ['dummy ctx in bottom non-vendor call' => '[value for dummy ctx in bottom non-vendor call]'] : []; + self::assertScopeContext($testFuncScopeCtx, thisObj: $this, args: compact('testArgs'), addedCtx: $addedCtx); + --$scopeIndex; + + if ($nonVendorCallsDepth >= 6) { + self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $dummyFuncForTestsWithNamespaceScopeDesc)); + self::assertIsString($dummyFuncForTestsWithNamespaceScopeDesc); + self::assertScopeDesc( + actualScopeDesc: $dummyFuncForTestsWithNamespaceScopeDesc, + class: null, + function: DUMMY_FUNC_FOR_TESTS_WITH_NAMESPACE_FUNCTION, + file: DUMMY_FUNC_FOR_TESTS_WITH_NAMESPACE_FILE, + line: DUMMY_FUNC_FOR_TESTS_WITH_NAMESPACE_CONTINUATION_CALL_LINE, + ); + + self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $dummyFuncForTestsWithNamespaceScopeCtx)); + self::assertIsArray($dummyFuncForTestsWithNamespaceScopeCtx); + /** @var Context $dummyFuncForTestsWithNamespaceScopeCtx */ + // dummyFuncForTestsWithNamespace is freestanding function (i.e., not a function that is a method in a class) + self::assertScopeContext($dummyFuncForTestsWithNamespaceScopeCtx, thisObj: null, args: ['continuation' => $closureCallingDummyFuncWithoutNamespace], addedCtx: []); + --$scopeIndex; + } + + if ($nonVendorCallsDepth >= 5) { + self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $closureCallingDummyFuncWithoutNamespaceScopeDesc)); + self::assertIsString($closureCallingDummyFuncWithoutNamespaceScopeDesc); + self::assertNotNull($closureCallingDummyFuncWithoutNamespaceLine); + self::assertScopeDesc($closureCallingDummyFuncWithoutNamespaceScopeDesc, line: $closureCallingDummyFuncWithoutNamespaceLine); + + self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $closureCallingDummyFuncWithoutNamespaceScopeCtx)); + self::assertIsArray($closureCallingDummyFuncWithoutNamespaceScopeCtx); + /** @var Context $closureCallingDummyFuncWithoutNamespaceScopeCtx */ + $addedCtx = $shouldMiddleNonVendorCallAddContext ? ['dummy ctx in middle non-vendor call' => '[value for dummy ctx in middle non-vendor call]'] : []; + self::assertScopeContext($closureCallingDummyFuncWithoutNamespaceScopeCtx, thisObj: $this, args: [], addedCtx: $addedCtx); + --$scopeIndex; + } + + if ($nonVendorCallsDepth >= 4) { + self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $dummyFuncForTestsWithoutNamespaceScopeDesc)); + self::assertIsString($dummyFuncForTestsWithoutNamespaceScopeDesc); + self::assertScopeDesc( + actualScopeDesc: $dummyFuncForTestsWithoutNamespaceScopeDesc, + class: null, + function: ELASTIC_OTEL_TESTS_DUMMY_FUNC_FOR_TESTS_WITHOUT_NAMESPACE_FUNCTION, + file: ELASTIC_OTEL_TESTS_DUMMY_FUNC_FOR_TESTS_WITHOUT_NAMESPACE_FILE, + line: ELASTIC_OTEL_TESTS_DUMMY_FUNC_FOR_TESTS_WITHOUT_NAMESPACE_CONTINUATION_CALL_LINE, + ); + + self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $dummyFuncForTestsWithoutNamespaceScopeCtx)); + self::assertIsArray($dummyFuncForTestsWithoutNamespaceScopeCtx); + /** @var Context $dummyFuncForTestsWithoutNamespaceScopeCtx */ + // dummyFuncForTestsWithoutNamespace is freestanding function (i.e., not a function that is a method in a class) + self::assertScopeContext($dummyFuncForTestsWithoutNamespaceScopeCtx, thisObj: null, args: ['continuation' => $closureCallingHelperFunc], addedCtx: []); + --$scopeIndex; + } + + if ($nonVendorCallsDepth >= 3) { + self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $closureCallingHelperFuncScopeDesc)); + self::assertIsString($closureCallingHelperFuncScopeDesc); + self::assertNotNull($closureCallingHelperFuncLine); + self::assertScopeDesc($closureCallingHelperFuncScopeDesc, line: $closureCallingHelperFuncLine); + + self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $closureCallingHelperFuncScopeCtx)); + self::assertIsArray($closureCallingHelperFuncScopeCtx); + /** @var Context $closureCallingHelperFuncScopeCtx */ + // $closureCallingHelperFunc is static so there is no $this + self::assertScopeContext($closureCallingHelperFuncScopeCtx, thisObj: null, args: [], addedCtx: []); + --$scopeIndex; + } + + if ($nonVendorCallsDepth >= 2) { + self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $helperFuncScopeDesc)); + self::assertIsString($helperFuncScopeDesc); + self::assertNotNull($helperFuncLine); + self::assertScopeDesc($helperFuncScopeDesc, function: self::HELPER_FUNC_FOR_TEST_TRIM_VENDOR_FRAMES_NAME, line: $helperFuncLine); + + self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $helperFuncScopeCtx)); + self::assertIsArray($helperFuncScopeCtx); + /** @var Context $helperFuncScopeCtx */ + $helperFuncArgs = compact('testArgs'); + $helperFuncArgs['lineNumber'] = $helperFuncLine; + $helperFuncArgs['actualNonVendorCallDepth'] = $nonVendorCallsDepth; + $addedCtx = $shouldTopNonVendorCallAddContext ? ['dummy ctx in top non-vendor call' => '[value for dummy ctx in top non-vendor call]'] : []; + // helperFuncForTestTrimVendorFrames is static so there is no $this + self::assertScopeContext($helperFuncScopeCtx, thisObj:null, args: $helperFuncArgs, addedCtx: $addedCtx); + --$scopeIndex; + } + + self::assertSame($nonVendorCallsDepth, $testFuncCtxIndex - $scopeIndex); + + self::assertTrue(IterableUtil::getNthKey($decodedContextsStack, $scopeIndex, /* out */ $assertionFuncScopeDesc)); + self::assertIsString($assertionFuncScopeDesc); + self::assertScopeDesc($assertionFuncScopeDesc, class: Assert::class, function: $useFailToTriggerAssertion ? 'fail' : 'assertSame', file: null); + + self::assertTrue(IterableUtil::getNthValue($decodedContextsStack, $scopeIndex, /* out */ $assertionFuncScopeCtx)); + self::assertIsArray($assertionFuncScopeCtx); + /** @var Context $assertionFuncScopeCtx */ + $assertionFuncArgs = $useFailToTriggerAssertion ? ['message' => 'Dummy message'] : ['expected' => 1, 'actual' => 2]; + // Assert::assertXyz()/fail() is static method so there is no $this + self::assertScopeContext($assertionFuncScopeCtx, thisObj: null, args: $assertionFuncArgs, addedCtx: []); + } + + private const HELPER_FUNC_FOR_TEST_ADDED_TEXT_FORMAT = 'helperFuncForTestAddedTextFormat'; + + /** + * @param list $listArg + * @param array $mapArg + * @param ?positive-int &$lineNumber + * + * @param-out positive-int $lineNumber + * + * @noinspection PhpUnusedParameterInspection + */ + private static function helperFuncForTestAddedTextFormat(array $listArg, array $mapArg, /* out */ ?int &$lineNumber): void + { + self::assertNull($lineNumber); + self::assertSame(self::HELPER_FUNC_FOR_TEST_ADDED_TEXT_FORMAT, __FUNCTION__); // @phpstan-ignore staticMethod.alreadyNarrowedType + + $lineNumber = __LINE__ + 1; + self::fail('Dummy message'); + } + + public static function testAddedTextFormat(): void + { + if (self::shortcutTestIfDebugContextIsDisabledByDefault()) { + return; + } + + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + // This test extracts debug context from text added to assertion message + DebugContextConfig::addToAssertionMessage(true); + + $listArg = ['a', 1, null, 4.5]; + $mapArg = ['list key' => $listArg, 'int key' => 2, 'null key' => null]; + $helperFuncLine = null; + $assertionMsg = null; + try { + $thisFuncLine = __LINE__ + 1; + self::helperFuncForTestAddedTextFormat(listArg: $listArg, mapArg: $mapArg, /* out */ lineNumber: $helperFuncLine); + } catch (AssertionFailedError $ex) { + $assertionMsg = $ex->getMessage(); + } + $dbgCtx->add(compact('assertionMsg')); + self::assertNotNull($assertionMsg); + self::assertNotNull($helperFuncLine); // @phpstan-ignore staticMethod.impossibleType + $actualAddedText = self::extractTextAddedToAssertionMessage($assertionMsg); + + $phpUnitFrameworkAssertPhpFileFullPath = FileUtil::normalizePath(VendorDir::get() . FileUtil::adaptUnixDirectorySeparators('/phpunit/phpunit/src/Framework/Assert.php')); + + // Extract line number in Framework/Assert.php + $strBeforeAssertPhpLine = JsonUtil::adaptStringToSearchInJson($phpUnitFrameworkAssertPhpFileFullPath . ':'); + $dbgCtx->add(compact('strBeforeAssertPhpLine')); + self::assertNotFalse($strBeforeAssertPhpLinePos = strpos($actualAddedText, $strBeforeAssertPhpLine)); + $assertPhpLineStrPos = $strBeforeAssertPhpLinePos + strlen($strBeforeAssertPhpLine); + self::assertGreaterThan(0, $assertPhpLineNumberStrLen = strspn($actualAddedText, '0123456789', $assertPhpLineStrPos)); + $phpUnitFrameworkAssertPhpLine = substr($actualAddedText, $assertPhpLineStrPos, $assertPhpLineNumberStrLen); + + $thisFileFullPath = __FILE__; + $thisClass = __CLASS__; + $thisFuncName = __FUNCTION__; + $helperFuncName = self::HELPER_FUNC_FOR_TEST_ADDED_TEXT_FORMAT; + $expectedAddedText = << + */ + public static function dataProviderForTestIsValidHexNumberString(): array + { + /** @noinspection SpellCheckingInspection */ + return [ + ['1234', 2, true], + ['1234', 3, false], + ['1234', 1, false], + ['abcdef', 3, true], + ['AbCdEf', 3, true], + ['0123456789AbCdEf', 8, true], + ['abcd', 2, true], + ['zabc', 2, false], + ['abcz', 2, false], + ]; + } + + /** + * @dataProvider dataProviderForTestIsValidHexNumberString + */ + public function testIsValidHexNumberString( + string $numberAsString, + int $expectedSizeInBytes, + bool $expectedResult + ): void { + $actualResult = IdValidationUtil::isValidHexNumberString($numberAsString, $expectedSizeInBytes); + self::assertSame( + $expectedResult, + $actualResult, + LoggableToString::convert( + [ + 'numberAsString' => $numberAsString, + 'expectedSizeInBytes' => $expectedSizeInBytes, + 'expectedResult' => $expectedResult, + '$actualResult' => $actualResult, + ] + ) + ); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/IterableUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/IterableUtilTest.php new file mode 100644 index 0000000..9744e2a --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/IterableUtilTest.php @@ -0,0 +1,222 @@ +, 'upTo': non-negative-int} + * @phpstan-type TestTakeUpToArgs array{'input': TestTakeUpToInput, 'expectedOutput': array} + */ +final class IterableUtilTest extends TestCaseBase +{ + public static function testPrepend(): void + { + AssertEx::equalLists([1, 2], IterableUtil::toList(IterableUtil::prepend(1, [2]))); + AssertEx::equalLists([1, 2, 3], IterableUtil::toList(IterableUtil::prepend(1, [2, 3]))); + AssertEx::equalLists([1], IterableUtil::toList(IterableUtil::prepend(1, []))); + } + + public static function testArraySuffix(): void + { + AssertEx::equalLists([1, 2], IterableUtil::toList(IterableUtil::arraySuffix([1, 2], 0))); + AssertEx::equalLists([2], IterableUtil::toList(IterableUtil::arraySuffix([1, 2], 1))); + AssertEx::equalLists([], IterableUtil::toList(IterableUtil::arraySuffix([1, 2], 2))); + AssertEx::equalLists([], IterableUtil::toList(IterableUtil::arraySuffix([1, 2], 3))); + AssertEx::equalLists([], IterableUtil::toList(IterableUtil::arraySuffix([], 0))); + AssertEx::equalLists([], IterableUtil::toList(IterableUtil::arraySuffix([], 1))); + } + + /** + * @return iterable + */ + public static function dataProviderForTestZip(): iterable + { + yield [[[]], []]; + yield [[[], []], []]; + yield [[[], [], []], []]; + + yield [[['a'], [1]], [['a', 1]]]; + yield [[['a', 'b'], [1, 2]], [['a', 1], ['b', 2]]]; + yield [[['a', 'b', 'c'], [1, 2, 3], [4.4, 5.5, 6.6]], [['a', 1, 4.4], ['b', 2, 5.5], ['c', 3, 6.6]]]; + } + + /** + * @param iterable[] $inputIterables + * @param mixed[][] $expectedOutput + */ + private static function helperForTestZip(bool $withIndex, array $inputIterables, array $expectedOutput): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $dbgCtx->add(['count($inputIterables)' => count($inputIterables)]); + $i = 0; + foreach (($withIndex ? IterableUtil::zipWithIndex(...$inputIterables) : IterableUtil::zip(...$inputIterables)) as $actualTuple) { + $dbgCtx->add(compact('i', 'actualTuple')); + self::assertLessThan(count($expectedOutput), $i); + $expectedTuple = $expectedOutput[$i]; + AssertEx::equalLists($expectedTuple, $actualTuple); + ++$i; + } + self::assertSame(count($expectedOutput), $i); + } + + /** + * @dataProvider dataProviderForTestZip + * + * @param mixed[][] $inputArrays + * @param mixed[][] $expectedOutput + */ + public static function testZip(array $inputArrays, array $expectedOutput): void + { + self::helperForTestZip(/* withIndex */ false, $inputArrays, $expectedOutput); + + /** + * @param mixed[] $inputArray + * + * @return Generator + */ + $arrayToGenerator = function (array $inputArray): Generator { + foreach ($inputArray as $val) { + yield $val; + } + }; + + self::helperForTestZip(/* withIndex */ false, array_map($arrayToGenerator(...), $inputArrays), $expectedOutput); + } + + /** + * @dataProvider dataProviderForTestZip + * + * @param mixed[][] $inputArrays + * @param mixed[][] $expectedOutputWithoutIndex + */ + public static function testZipWithIndex(array $inputArrays, array $expectedOutputWithoutIndex): void + { + $expectedOutput = []; + $index = 0; + foreach ($expectedOutputWithoutIndex as $expectedTupleWithoutIndex) { + $expectedOutput[] = [$index++, ...$expectedTupleWithoutIndex]; + } + + self::helperForTestZip(/* withIndex */ true, $inputArrays, $expectedOutput); + + /** + * @param mixed[] $inputArray + * + * @return Generator + */ + $arrayToGenerator = function (array $inputArray): Generator { + foreach ($inputArray as $val) { + yield $val; + } + }; + + self::helperForTestZip(/* withIndex */ true, array_map($arrayToGenerator(...), $inputArrays), $expectedOutput); + } + + public static function testMap(): void + { + /** + * @template TInputValue + * @template TOutputValue + * + * @param iterable $inputRange + * @param callable(TInputValue): TOutputValue $mapFunc + * @param iterable $expectedOutputRange + */ + $impl = function (iterable $inputRange, callable $mapFunc, iterable $expectedOutputRange): void { + $actualOutputRange = IterableUtil::map($inputRange, $mapFunc); + foreach (IterableUtil::zip($expectedOutputRange, $actualOutputRange) as $expectedActualOutputValues) { + self::assertCount(2, $expectedActualOutputValues); + self::assertSame($expectedActualOutputValues[0], $expectedActualOutputValues[1]); + } + }; + + /** + * @template T of number + * + * @phpstan-param T $x + * + * @phpstan-return T + */ + $x2Func = fn (int|float $x) => $x * 2; + + $impl([], $x2Func, []); + $impl([1], $x2Func, [2]); + $impl([1, 2], $x2Func, [2, 4]); + $impl([1.2, 3, 4.6], $x2Func, [2.4, 6, 9.2]); + } + + public static function testMax(): void + { + /** + * @template T of number + * @param iterable $range + * @param T $expectedResult + */ + $impl = function (iterable $range, mixed $expectedResult): void { + /** @var iterable $range */ + self::assertSame($expectedResult, IterableUtil::max($range)); + }; + + $impl([1], 1); + $impl([1.2], 1.2); + $impl([1, 1.2], 1.2); + $impl([1.2, 2], 2); + $impl([1, 3.4, 2], 3.4); + $impl([5, 1.2, 3.4], 5); + $impl([7, 7.7, 7.8], 7.8); + } + + /** + * @return iterable + */ + public static function dataProviderForTestTakeUpTo(): iterable + { + /** + * @return iterable + */ + $genDataSets = function (): iterable { + yield ['input' => ['iterable' => [], 'upTo' => 0], 'expectedOutput' => []]; + }; + + return DataProviderForTestBuilder::keyEachDataSetWithDbgDesc($genDataSets); + } + + /** + * @dataProvider dataProviderForTestTakeUpTo + * + * @param TestTakeUpToInput $input + * @param iterable $expectedOutput + */ + public static function testTakeUpTo(array $input, iterable $expectedOutput): void + { + AssertEx::sameValuesListIterables($expectedOutput, IterableUtil::takeUpTo($input['iterable'], $input['upTo'])); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/JsonUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/JsonUtilTest.php new file mode 100644 index 0000000..599c0d7 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/JsonUtilTest.php @@ -0,0 +1,55 @@ + 0]; + $serialized = JsonUtil::encode((object)$original); + self::assertSame(1, preg_match('/^\s*{\s*"0"\s*:\s*0\s*}\s*$/', $serialized)); + $decodedJson = self::decode($serialized, asAssocArray: true); + self::assertIsArray($decodedJson); + AssertEx::equalMaps($original, $decodedJson); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/ListSliceTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/ListSliceTest.php new file mode 100644 index 0000000..82d0ac8 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/ListSliceTest.php @@ -0,0 +1,157 @@ +, 'offset'?: non-negative-int, 'length'?: ?non-negative-int} + * @phpstan-type TestConstructorArgs array{'input': TestConstructorInput, 'expectedOutput': iterable} + */ +final class ListSliceTest extends TestCaseBase +{ + use DisableDebugContextTestTrait; + + /** + * @return iterable, iterable}> + */ + public static function dataProviderForTestConstructorValidInput(): iterable + { + /** + * @return iterable + */ + $genDataSets = function (): iterable { + yield ['input' => ['base' => [], 'offset' => 0], 'expectedOutput' => []]; + + yield ['input' => ['base' => [], 'offset' => 0, 0], 'expectedOutput' => []]; + + yield ['input' => ['base' => ['a'], 'offset' => 1], 'expectedOutput' => []]; + + yield ['input' => ['base' => ['a'], 'offset' => 0], 'expectedOutput' => ['a']]; + yield ['input' => ['base' => ['a', 'b'], 'offset' => 0], 'expectedOutput' => ['a', 'b']]; + yield ['input' => ['base' => ['a', 'b'], 'offset' => 1], 'expectedOutput' => ['b']]; + + yield ['input' => ['base' => ['a', 'b', 'c'],'offset' => 0], 'expectedOutput' => ['a', 'b', 'c']]; + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 0, 'length' => 0], 'expectedOutput' => []]; + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 0, 'length' => 1], 'expectedOutput' => ['a']]; + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 0, 'length' => 2], 'expectedOutput' => ['a', 'b']]; + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 0, 'length' => 3], 'expectedOutput' => ['a', 'b', 'c']]; + + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 1], 'expectedOutput' => ['b', 'c']]; + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 1, 'length' => 0], 'expectedOutput' => []]; + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 1, 'length' => 1], 'expectedOutput' => ['b']]; + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 1, 'length' => 2], 'expectedOutput' => ['b', 'c']]; + + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 2], 'expectedOutput' => ['c']]; + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 2, 'length' => 0], 'expectedOutput' => []]; + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 2, 'length' => 1], 'expectedOutput' => ['c']]; + + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 3], 'expectedOutput' => []]; + yield ['input' => ['base' => ['a', 'b', 'c'], 'offset' => 3, 'length' => 0], 'expectedOutput' => []]; + }; + + return DataProviderForTestBuilder::keyEachDataSetWithDbgDesc($genDataSets); + } + + /** + * @dataProvider dataProviderForTestConstructorValidInput + * + * @param TestConstructorInput $input + * @param iterable $expectedOutput + */ + public static function testConstructorValidInput(array $input, iterable $expectedOutput): void + { + if (array_key_exists('offset', $input)) { + if (array_key_exists('length', $input)) { + $actualOutput = new ListSlice($input['base'], $input['offset'], $input['length']); + } else { + $actualOutput = new ListSlice($input['base'], $input['offset']); + } + } else { + $actualOutput = new ListSlice($input['base']); + } + AssertEx::sameValuesListIterables($expectedOutput, $actualOutput); + } + + /** + * @return iterable + */ + public static function dataProviderForTestInvalidInput(): iterable + { + yield [fn() => new ListSlice([], 1)]; + yield [fn() => new ListSlice(['a'], 2)]; + yield [fn() => new ListSlice(['a', 'b'], 3)]; + yield [fn() => new ListSlice(['a', 'b', 'b'], 4)]; + + yield [fn() => new ListSlice([], 0, 1)]; + yield [fn() => new ListSlice(['a'], 1, 1)]; + yield [fn() => new ListSlice(['a', 'b'], 2, 1)]; + yield [fn() => new ListSlice(['a', 'b', 'b'], 1, 3)]; + yield [fn() => new ListSlice(['a', 'b', 'b'], 2, 2)]; + yield [fn() => new ListSlice(['a', 'b', 'b'], 3, 1)]; + } + + /** + * @dataProvider dataProviderForTestInvalidInput + * + * @param callable(): mixed $throwingCall + */ + public static function testInvalidInput(callable $throwingCall): void + { + AssertEx::throws(OutOfBoundsException::class, $throwingCall); + } + + public static function testWithoutPrefix(): void + { + AssertEx::sameValuesListIterables([], (new ListSlice([]))->withoutPrefix(0)); + AssertEx::throws(OutOfBoundsException::class, fn() => (new ListSlice([]))->withoutPrefix(1)); + + AssertEx::sameValuesListIterables(['a'], (new ListSlice(['a']))->withoutPrefix(0)); + AssertEx::sameValuesListIterables([], (new ListSlice(['a']))->withoutPrefix(1)); + + AssertEx::sameValuesListIterables(['a', 'b', 'c'], (new ListSlice(['a', 'b', 'c']))->withoutPrefix(0)); + AssertEx::sameValuesListIterables(['b', 'c'], (new ListSlice(['a', 'b', 'c']))->withoutPrefix(1)); + AssertEx::sameValuesListIterables(['c'], (new ListSlice(['a', 'b', 'c']))->withoutPrefix(2)); + AssertEx::sameValuesListIterables([], (new ListSlice(['a', 'b', 'c']))->withoutPrefix(3)); + } + + public static function testWithoutSuffix(): void + { + AssertEx::sameValuesListIterables([], (new ListSlice([]))->withoutSuffix(0)); + AssertEx::throws(OutOfBoundsException::class, fn() => (new ListSlice([]))->withoutSuffix(1)); + + AssertEx::sameValuesListIterables(['a'], (new ListSlice(['a']))->withoutSuffix(0)); + AssertEx::sameValuesListIterables([], (new ListSlice(['a']))->withoutSuffix(1)); + + AssertEx::sameValuesListIterables(['a', 'b', 'c'], (new ListSlice(['a', 'b', 'c']))->withoutSuffix(0)); + AssertEx::sameValuesListIterables(['a', 'b'], (new ListSlice(['a', 'b', 'c']))->withoutSuffix(1)); + AssertEx::sameValuesListIterables(['a'], (new ListSlice(['a', 'b', 'c']))->withoutSuffix(2)); + AssertEx::sameValuesListIterables([], (new ListSlice(['a', 'b', 'c']))->withoutSuffix(3)); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DerivedObjectForLoggableTraitTests.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DerivedObjectForLoggableTraitTests.php new file mode 100644 index 0000000..df58e7f --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DerivedObjectForLoggableTraitTests.php @@ -0,0 +1,40 @@ + + */ + protected static function propertiesExcludedFromLogImpl(): array + { + return array_merge(parent::propertiesExcludedFromLogImpl(), ['anotherExcludedProp']); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DummyBackedEnum.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DummyBackedEnum.php new file mode 100644 index 0000000..6fb0dda --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/DummyBackedEnum.php @@ -0,0 +1,32 @@ +loggerForClass(LogCategoryForTests::TEST, __NAMESPACE__, __CLASS__, __FILE__); + } + + /** + * @param Logger $logger + * + * @return array + */ + private static function includeStackTraceHelperFunc(Logger $logger): array + { + ($lgrPxy = $logger->ifTraceLevelEnabled(__LINE__, __FUNCTION__)) && $lgrPxy->includeStackTrace()->log(''); + $expectedSrcCodeLine = __LINE__ - 1; + return [ + StackTraceUtil::FUNCTION_KEY => __FUNCTION__, + StackTraceUtil::LINE_KEY => $expectedSrcCodeLine, + ]; + } + + /** + * @param array $expectedSrcCodeData + * @param array $actualFrame + * + * @return void + */ + public static function verifyStackFrame(array $expectedSrcCodeData, array $actualFrame): void + { + $ctx = LoggableToString::convert(['$actualFrame' => $actualFrame]); + self::assertCount(4, $actualFrame, $ctx); + + self::assertArrayHasKey(StackTraceUtil::FILE_KEY, $actualFrame, $ctx); + $actualFilePath = $actualFrame[StackTraceUtil::FILE_KEY]; + self::assertIsString($actualFilePath, $ctx); + self::assertSame(basename(__FILE__), basename($actualFilePath), $ctx); + + $expectedSrcCodeLine = $expectedSrcCodeData[StackTraceUtil::LINE_KEY]; + AssertEx::arrayHasKeyWithSameValue(StackTraceUtil::LINE_KEY, $expectedSrcCodeLine, $actualFrame, $ctx); + + self::assertArrayHasKey(StackTraceUtil::CLASS_KEY, $actualFrame, $ctx); + $thisClassShortName = ClassNameUtil::fqToShort(__CLASS__); + $actualClass = $actualFrame[StackTraceUtil::CLASS_KEY]; + self::assertIsString($actualClass, $ctx); + /** @var class-string $actualClass */ + $actualClassShortName = ClassNameUtil::fqToShort($actualClass); + self::assertSame($thisClassShortName, $actualClassShortName, $ctx); + + $expectedSrcCodeFunc = $expectedSrcCodeData[StackTraceUtil::FUNCTION_KEY]; + AssertEx::arrayHasKeyWithSameValue(StackTraceUtil::FUNCTION_KEY, $expectedSrcCodeFunc, $actualFrame, $ctx); + } + + public function testIncludeStackTrace(): void + { + $mockLogSink = new MockLogPreformattedSink(); + $logger = self::buildLogger($mockLogSink); + + $expectedSrcCodeLineForThisFrame = __LINE__ + 1; + $expectedSrcCodeDataForTopFrame = self::includeStackTraceHelperFunc($logger); + + self::assertCount(1, $mockLogSink->consumed); + $actualLogStatement = $mockLogSink->consumed[0]; + self::assertSame(LogLevel::trace, $actualLogStatement->statementLevel); + self::assertSame(LogCategoryForTests::TEST, $actualLogStatement->category); + self::assertSame(__FILE__, $actualLogStatement->srcCodeFile); + self::assertSame($expectedSrcCodeDataForTopFrame[StackTraceUtil::LINE_KEY], $actualLogStatement->srcCodeLine); + self::assertSame( + $expectedSrcCodeDataForTopFrame[StackTraceUtil::FUNCTION_KEY], + $actualLogStatement->srcCodeFunc + ); + + $actualCtx = JsonUtil::decode($actualLogStatement->messageWithContext, asAssocArray: true); + self::assertIsArray($actualCtx); + /** @var array $actualCtx */ + AssertEx::arrayHasKeyWithSameValue(LogBackend::NAMESPACE_KEY, __NAMESPACE__, $actualCtx); + self::assertArrayHasKey(LogBackend::CLASS_KEY, $actualCtx); + $thisClassShortName = ClassNameUtil::fqToShort(__CLASS__); + $actualFqClassName = $actualCtx[LogBackend::CLASS_KEY]; + self::assertSame($thisClassShortName, ClassNameUtil::fqToShort($actualFqClassName)); // @phpstan-ignore argument.type + + self::assertArrayHasKey(LoggableStackTrace::STACK_TRACE_KEY, $actualCtx); + $actualStackTrace = $actualCtx[LoggableStackTrace::STACK_TRACE_KEY]; + self::assertIsArray($actualStackTrace); + /** @var array[] $actualStackTrace */ + AssertEx::countAtLeast(2, $actualStackTrace); + self::verifyStackFrame($expectedSrcCodeDataForTopFrame, $actualStackTrace[0]); + $expectedSrcCodeDataForThisFrame = [ + StackTraceUtil::FUNCTION_KEY => __FUNCTION__, + StackTraceUtil::LINE_KEY => $expectedSrcCodeLineForThisFrame, + ]; + self::verifyStackFrame($expectedSrcCodeDataForThisFrame, $actualStackTrace[1]); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogContextMapTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogContextMapTest.php new file mode 100644 index 0000000..258d01e --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogContextMapTest.php @@ -0,0 +1,102 @@ +loggerForClass(LogCategoryForTests::TEST, __NAMESPACE__, __CLASS__, __FILE__); + } + + public function testMergingContexts(): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $mockLogSink = new MockLogPreformattedSink(); + $dbgCtx->add(compact('mockLogSink')); + $level1Ctx = ['level_1_key_1' => 'level_1_key_1 value', 'level_1_key_2' => 'level_1_key_2 value', 'some_key' => 'some_key level_1 value']; + $loggerA = self::buildLogger($mockLogSink)->addAllContext($level1Ctx); + $level2Ctx = ['level_2_key_1' => 'level_2_key_1 value', 'level_2_key_2' => 'level_2_key_2 value']; + $loggerB = $loggerA->inherit()->addAllContext($level2Ctx); + + $loggerProxyDebug = $loggerB->ifDebugLevelEnabledNoLine(__FUNCTION__); + + $level3Ctx = ['level_3_key_1' => 'level_3_key_1 value', 'level_3_key_2' => 'level_3_key_2 value', 'some_key' => 'some_key level_3 value']; + $loggerB->addAllContext($level3Ctx); + + $stmtMsg = 'Some message'; + $stmtCtx = ['stmt_key_1' => 'stmt_key_1 value', 'stmt_key_2' => 'stmt_key_2 value']; + $stmtLine = __LINE__ + 1; + $loggerProxyDebug && $loggerProxyDebug->log(__LINE__, $stmtMsg, $stmtCtx); + + $actualStmt = ArrayUtilForTests::getSingleValue($mockLogSink->consumed); + + self::assertSame(LogLevel::debug, $actualStmt->statementLevel); + self::assertSame(LogCategoryForTests::TEST, $actualStmt->category); + self::assertSame(__FILE__, $actualStmt->srcCodeFile); + self::assertSame($stmtLine, $actualStmt->srcCodeLine); + self::assertSame(__FUNCTION__, $actualStmt->srcCodeFunc); + + self::assertStringStartsWith($stmtMsg, $actualStmt->messageWithContext); + $actualCtxEncodedAsJson = trim(substr($actualStmt->messageWithContext, strlen($stmtMsg))); + $dbgCtx->add(compact('actualCtxEncodedAsJson')); + + $actualCtx = JsonUtil::decode($actualCtxEncodedAsJson, asAssocArray: true); + self::assertIsArray($actualCtx); + $expectedCtx = [ + 'stmt_key_1' => 'stmt_key_1 value', 'stmt_key_2' => 'stmt_key_2 value', + 'level_3_key_1' => 'level_3_key_1 value', 'level_3_key_2' => 'level_3_key_2 value', 'some_key' => 'some_key level_3 value', + 'level_2_key_1' => 'level_2_key_1 value', 'level_2_key_2' => 'level_2_key_2 value', + 'level_1_key_1' => 'level_1_key_1 value', 'level_1_key_2' => 'level_1_key_2 value', + LogBackend::NAMESPACE_KEY => __NAMESPACE__, + LogBackend::CLASS_KEY => ClassNameUtil::fqToShort(__CLASS__), + ]; + self::assertCount(count($expectedCtx), $actualCtx); + foreach (IterableUtil::zip(IterableUtil::keys($expectedCtx), IterableUtil::keys($actualCtx)) as [$expectedKey, $actualKey]) { + $dbgCtx->add(compact('expectedKey', 'actualKey')); + self::assertSame($expectedKey, $actualKey); + self::assertSame($expectedCtx[$expectedKey], $actualCtx[$actualKey]); + } + + $expectedCtxEncodedAsJson = JsonUtil::encode($expectedCtx); + $dbgCtx->add(compact('expectedCtxEncodedAsJson')); + self::assertSame($expectedCtxEncodedAsJson, $actualCtxEncodedAsJson); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogLevelTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogLevelTest.php new file mode 100644 index 0000000..7e88e46 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/LogLevelTest.php @@ -0,0 +1,36 @@ +{}[]+-=_~^ \t\r\n,:;.!?"]; + foreach ($valuesToTest as $value) { + self::logValueAndVerify($value, $value); + } + } + + public function testEnum(): void + { + /** @var list $enumObjAndLoggedStringPairs */ + $enumObjAndLoggedStringPairs = [ + [DummyEnum::small, __NAMESPACE__ . '\\DummyEnum(small)'] + ]; + foreach ($enumObjAndLoggedStringPairs as $enumObjAndLoggedStringPair) { + self::logValueAndVerify($enumObjAndLoggedStringPair[0], $enumObjAndLoggedStringPair[1]); + } + } + + // public function testResource(): void + // { + // // $tmpFile = tmpfile() + // // self::logValueAndVerify(null, null); + // } + // + // public function testListArray(): void + // { + // // new SimpleObjectForTests() + // } + // + // public function testMapArray(): void + // { + // // new SimpleObjectForTests() + // } + + /** + * @return array + */ + private static function expectedSimpleObject(?string $className = null, bool $isPropExcluded = true, mixed $lateInitPropVal = LogConsts::UNINITIALIZED_PROPERTY_SUBSTITUTE): array + { + return ($className === null ? [] : [LogConsts::TYPE_KEY => $className]) + + [ + 'intProp' => 123, + 'stringProp' => 'Abc', + 'nullableStringProp' => null, + 'lateInitProp' => $lateInitPropVal, + 'recursiveProp' => null, + ] + + ($isPropExcluded ? [] : ['excludedProp' => 'excludedProp value']); + } + + /** + * @param string|null $className + * @param bool $isPropExcluded + * + * @return array + */ + private static function expectedDerivedSimpleObject(?string $className = null, bool $isPropExcluded = true): array + { + return self::expectedSimpleObject($className, $isPropExcluded) + + ['derivedFloatProp' => 1.5] + + ($isPropExcluded ? [] : ['anotherExcludedProp' => 'anotherExcludedProp value']); + } + + public function tearDown(): void + { + ObjectForLoggableTraitTests::logWithoutClassName(); + ObjectForLoggableTraitTests::shouldExcludeProp(); + DerivedObjectForLoggableTraitTests::logWithoutClassName(); + DerivedObjectForLoggableTraitTests::shouldExcludeProp(); + + parent::tearDown(); + } + + public function testObject(): void + { + self::logValueAndVerify(new ObjectForLoggableTraitTests(), self::expectedSimpleObject()); + + ObjectForLoggableTraitTests::logWithShortClassName(); + + self::logValueAndVerify(new ObjectForLoggableTraitTests(), self::expectedSimpleObject(className: 'ObjectForLoggableTraitTests')); + + ObjectForLoggableTraitTests::logWithCustomClassName('My-custom-type'); + + self::logValueAndVerify( + new ObjectForLoggableTraitTests(), + self::expectedSimpleObject(className: 'My-custom-type') + ); + + ObjectForLoggableTraitTests::logWithoutClassName(); + ObjectForLoggableTraitTests::shouldExcludeProp(false); + + self::logValueAndVerify( + new ObjectForLoggableTraitTests(), + self::expectedSimpleObject(isPropExcluded: false) + ); + } + + public function testDerivedObject(): void + { + self::logValueAndVerify(new DerivedObjectForLoggableTraitTests(), self::expectedDerivedSimpleObject()); + + DerivedObjectForLoggableTraitTests::logWithShortClassName(); + + self::logValueAndVerify( + new DerivedObjectForLoggableTraitTests(), + self::expectedDerivedSimpleObject(className: 'DerivedObjectForLoggableTraitTests') + ); + + DerivedObjectForLoggableTraitTests::logWithCustomClassName('My-custom-type'); + + self::logValueAndVerify( + new DerivedObjectForLoggableTraitTests(), + self::expectedDerivedSimpleObject(className: 'My-custom-type') + ); + + DerivedObjectForLoggableTraitTests::logWithoutClassName(); + DerivedObjectForLoggableTraitTests::shouldExcludeProp(false); + + self::logValueAndVerify( + new DerivedObjectForLoggableTraitTests(), + self::expectedDerivedSimpleObject(isPropExcluded: false) + ); + } + + // public function testObjectWithThrowingToString(): void + // { + // // new SimpleObjectForTests() + // } + // + // public function testObjectWithDebugInfo(): void + // { + // // new SimpleObjectForTests() + // } + // + // public function testThrowable(): void + // { + // // new SimpleObjectForTests() + // } + + public function testNoopObject(): void + { + self::logValueAndVerify(NoopLogSink::singletonInstance(), [LogConsts::TYPE_KEY => 'NoopLogSink']); + } + + public function testLogBackend(): void + { + self::logValueAndVerify( + new LogBackend(LogLevel::warning, NoopLogSink::singletonInstance()), + [ + 'maxEnabledLevel' => 'WARNING', + 'logSink' => NoopLogSink::class, + ] + ); + } + + public function testLogger(): void + { + $loggerFactory = new LoggerFactory(new LogBackend(LogLevel::debug, NoopLogSink::singletonInstance())); + $category = 'test category'; + $namespace = 'test namespace'; + $fqClassName = __CLASS__; + $srcCodeFile = 'test source code file'; + self::logValueAndVerify( + $loggerFactory->loggerForClass($category, $namespace, $fqClassName, $srcCodeFile), + [ + 'category' => $category, + 'count(context)' => 0, + 'fqClassName' => $fqClassName, + 'inheritedData' => null, + 'namespace' => $namespace, + 'srcCodeFile' => $srcCodeFile, + 'backend' => ['maxEnabledLevel' => 'DEBUG', 'logSink' => NoopLogSink::class], + ] + ); + } + + public function testLateInit(): void + { + $obj = new ObjectForLoggableTraitTests(); + self::logValueAndVerify($obj, self::expectedSimpleObject()); + $lateInitPropVal = 'late inited value'; + $obj->lateInitProp = $lateInitPropVal; + self::logValueAndVerify($obj, self::expectedSimpleObject(lateInitPropVal: 'late inited value')); + } + + private const MAX_DEPTH_KEY = 'max_depth'; + + /** + * @return iterable + */ + public static function dataProviderForTestMaxDepth(): iterable + { + /** + * @return iterable> + */ + $generateDataSets = function (): iterable { + $maxDepthVariants = [0, 1, 2, 3, 10, 15, 20]; + $maxDepthVariants[] = LoggableToJsonEncodable::MAX_DEPTH_IN_PROD_MODE; + $maxDepthVariants[] = BootstrapTests::LOG_COMPOSITE_DATA_MAX_DEPTH_IN_TEST_MODE; + $maxDepthVariants = array_unique($maxDepthVariants, SORT_NUMERIC); + asort(/* ref */ $maxDepthVariants, SORT_NUMERIC); + foreach ($maxDepthVariants as $maxDepth) { + yield [self::MAX_DEPTH_KEY => $maxDepth]; + } + }; + + return DataProviderForTestBuilder::convertEachDataSetToMixedMapAndAddDesc($generateDataSets); + } + + /** + * @dataProvider dataProviderForTestMaxDepth + */ + public function testMaxDepthForScalar(MixedMap $testArgs): void + { + $maxDepth = $testArgs->getInt(self::MAX_DEPTH_KEY); + $savedMaxDepth = LoggableToJsonEncodable::$maxDepth; + try { + LoggableToJsonEncodable::$maxDepth = $maxDepth; + self::assertSame(null, LoggableToJsonEncodable::convert(null, 0)); + self::assertSame(123, LoggableToJsonEncodable::convert(123, 0)); + self::assertSame(654.5, LoggableToJsonEncodable::convert(654.5, 0)); + self::assertSame('my string', LoggableToJsonEncodable::convert('my string', 0)); + } finally { + LoggableToJsonEncodable::$maxDepth = $savedMaxDepth; + } + } + + /** + * @dataProvider dataProviderForTestMaxDepth + */ + public function testMaxDepthForArray(MixedMap $testArgs): void + { + $maxDepth = $testArgs->getInt(self::MAX_DEPTH_KEY); + $savedMaxDepth = LoggableToJsonEncodable::$maxDepth; + try { + LoggableToJsonEncodable::$maxDepth = $maxDepth; + self::implTestMaxDepthForArray(LoggableToJsonEncodable::$maxDepth); + } finally { + LoggableToJsonEncodable::$maxDepth = $savedMaxDepth; + } + } + + private static function implTestMaxDepthForArray(int $maxDepth): void + { + /** + * @param array $currentArray + * @param int $depth + * + * @return array + */ + $buildParentArray = function (array $currentArray, int $depth): array { + $parentArray = []; + $parentArray['depth ' . $depth . ' int'] = $depth * 10; + $parentArray['depth ' . $depth . ' string'] = strval($depth * 10); + $parentArray['depth ' . $depth . ' array'] = $currentArray; + return $parentArray; + }; + + $arrayToLog = []; + foreach (RangeUtil::generateDownFrom($maxDepth + 2) as $depth) { + $arrayToLog = $buildParentArray($arrayToLog, $depth); + } + + $decodedLoggedArray = self::logValueAndDecodeToJson($arrayToLog); + $currentLoggedArray = $decodedLoggedArray; + foreach (RangeUtil::generateUpTo($maxDepth + 2) as $depth) { + self::assertIsArray($currentLoggedArray); + self::assertLessThanOrEqual($maxDepth, $depth); + + if ($depth === $maxDepth) { + AssertEx::equalMaps(LoggableToJsonEncodable::convertArrayForMaxDepth($buildParentArray([], $maxDepth), $maxDepth), $currentLoggedArray); + break; + } + + AssertEx::arrayHasKeyWithSameValue('depth ' . $depth . ' int', $depth * 10, $currentLoggedArray); + AssertEx::arrayHasKeyWithSameValue('depth ' . $depth . ' string', strval($depth * 10), $currentLoggedArray); + + $key = 'depth ' . $depth . ' array'; + self::assertArrayHasKey($key, $currentLoggedArray); + $currentLoggedArray = $currentLoggedArray[$key]; + } + } + + /** + * @dataProvider dataProviderForTestMaxDepth + */ + public function testMaxDepthForObject(MixedMap $testArgs): void + { + $maxDepth = $testArgs->getInt(self::MAX_DEPTH_KEY); + $savedMaxDepth = LoggableToJsonEncodable::$maxDepth; + try { + LoggableToJsonEncodable::$maxDepth = $maxDepth; + self::implTestMaxDepthForObject($maxDepth); + } finally { + LoggableToJsonEncodable::$maxDepth = $savedMaxDepth; + } + } + + private static function implTestMaxDepthForObject(int $maxDepth): void + { + $buildParentObject = function (ObjectForLoggableTraitTests $currentObject, int $depth) use ($maxDepth): ObjectForLoggableTraitTests { + return new ObjectForLoggableTraitTests($depth, 'depth: ' . $depth . ', maxDepth: ' . $maxDepth, $currentObject); + }; + + $objectToLog = new ObjectForLoggableTraitTests(); + foreach (RangeUtil::generateDownFrom($maxDepth + 2) as $depth) { + $objectToLog = $buildParentObject($objectToLog, $depth); + } + + $decodedLoggedObject = self::logValueAndDecodeToJson($objectToLog); + $currentLoggedObject = $decodedLoggedObject; + foreach (RangeUtil::generateUpTo($maxDepth + 2) as $depth) { + self::assertIsArray($currentLoggedObject); + self::assertLessThanOrEqual($maxDepth, $depth); + + if ($depth === $maxDepth) { + AssertEx::equalMaps(LoggableToJsonEncodable::convertObjectForMaxDepth($buildParentObject(new ObjectForLoggableTraitTests(), $maxDepth), $maxDepth), $currentLoggedObject); + break; + } + + AssertEx::arrayHasKeyWithSameValue('intProp', $depth, $currentLoggedObject); + AssertEx::arrayHasKeyWithSameValue('stringProp', 'depth: ' . $depth . ', maxDepth: ' . $maxDepth, $currentLoggedObject); + + $key = 'recursiveProp'; + self::assertArrayHasKey($key, $currentLoggedObject); + $currentLoggedObject = $currentLoggedObject[$key]; + } + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/ObjectForLoggableTraitTests.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/ObjectForLoggableTraitTests.php new file mode 100644 index 0000000..55a6d06 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/ObjectForLoggableTraitTests.php @@ -0,0 +1,93 @@ +intProp = $intProp; + $this->stringProp = $stringProp; + $this->recursiveProp = $recursiveProp; + } + + public static function logWithoutClassName(): void + { + self::$logWithClassNameValue = null; + } + + public static function logWithCustomClassName(string $className): void + { + self::$logWithClassNameValue = $className; + } + + public static function logWithShortClassName(): void + { + self::$logWithClassNameValue = ClassNameUtil::fqToShort(static::class); + } + + protected static function classNameToLog(): ?string + { + return self::$logWithClassNameValue; + } + + public static function shouldExcludeProp(bool $shouldExcludeProp = true): void + { + self::$shouldExcludeProp = $shouldExcludeProp; + } + + /** + * @return array + */ + protected static function propertiesExcludedFromLogImpl(): array + { + return ['excludedProp']; + } + + /** + * @return array + */ + protected static function propertiesExcludedFromLog(): array + { + return self::$shouldExcludeProp ? static::propertiesExcludedFromLogImpl() : []; + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/ObjectThrowingInToLog.php b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/ObjectThrowingInToLog.php new file mode 100644 index 0000000..b28e02d --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/LogTests/ObjectThrowingInToLog.php @@ -0,0 +1,38 @@ +': + self::assertGreaterThan(0, NumericUtilForTests::compare($lhs, $rhs)); + break; + default: + self::fail('Unknown relation `' . $expectedRelation . '\''); + } + }; + + $impl(0, '=', 0); + $impl(1, '=', 1); + $impl(2.3, '=', 2.3); + + $impl(4.5, '<', 5); + $impl(6, '<', 7.8); + $impl(9.1, '<', 10.2); + + $impl(5, '>', 4.3); + $impl(7.3, '>', 6); + $impl(9.2, '>', 9.1); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/PHPUnitFrameworkAssertionFailedErrorSubstituteTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/PHPUnitFrameworkAssertionFailedErrorSubstituteTest.php new file mode 100644 index 0000000..00f5101 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/PHPUnitFrameworkAssertionFailedErrorSubstituteTest.php @@ -0,0 +1,120 @@ +getMessage(); + } + AssertionFailedError::$preprocessMessage = null; + + self::assertStringContainsString($textToAdd, $exceptionMsg); + } + + /** + * @return iterable + */ + public static function dataProviderForTestOriginalMatchesVendor(): iterable + { + $pathToOriginalDir = FileUtil::normalizePath(TestsRootDir::getFullPath() . FileUtil::adaptUnixDirectorySeparators('/substitutes/PHPUnit_Framework_AssertionFailedError/original')); + $pathToVendorDir = FileUtil::normalizePath(TestsRootDir::getFullPath() . FileUtil::adaptUnixDirectorySeparators('/../vendor/phpunit/phpunit/src')); + + yield [ + FileUtil::listToPath([$pathToOriginalDir, FileUtil::adaptUnixDirectorySeparators('AssertionFailedError.php')]), + FileUtil::listToPath([$pathToVendorDir, FileUtil::adaptUnixDirectorySeparators('Framework/Exception/AssertionFailedError.php')]), + ]; + } + + /** + * @dataProvider dataProviderForTestOriginalMatchesVendor + */ + public static function testOriginalMatchesVendor(string $pathToOriginalFile, string $pathToVendorFile): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $phpParser = (new PhpParserFactory())->createForHostVersion(); + /** + * @return array{'PHP': string, 'AST': string} + */ + $parsePhpAndDumpAst = function (string $pathToPhpFile) use ($phpParser): array { + $phpFileContent = file_get_contents($pathToPhpFile); + self::assertNotFalse($phpFileContent); + $ast = $phpParser->parse($phpFileContent); + self::assertNotNull($ast); + $dumper = new PhpParserNodeDumper(['dumpComments' => false, 'dumpPositions' => false]); + return ['PHP' => $phpFileContent, 'AST' => $dumper->dump($ast)]; + }; + + $originalPhpAst = $parsePhpAndDumpAst($pathToOriginalFile); + $dbgCtx->add(compact('originalPhpAst')); + $vendorPhpAst = $parsePhpAndDumpAst($pathToVendorFile); + $dbgCtx->add(compact('vendorPhpAst')); + self::assertSame($originalPhpAst['AST'], $vendorPhpAst['AST']); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/PHPUnitToLogConvertersTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/PHPUnitToLogConvertersTest.php new file mode 100644 index 0000000..dfb7062 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/PHPUnitToLogConvertersTest.php @@ -0,0 +1,73 @@ +test(); + $converted = LoggableToString::convert($testObj); + self::assertStringContainsString(JsonUtil::adaptStringToSearchInJson(__CLASS__), $converted); + self::assertStringContainsString(__FUNCTION__, $converted); + } + + public function testPHPUnitTelemetryInfo(): void + { + $eventObj = PHPUnitExtensionBase::$lastBeforeTestCaseEvent; + self::assertNotNull($eventObj); + $telemetryInfo = $eventObj->telemetryInfo(); + $converted = LoggableToString::convert($telemetryInfo); + self::assertStringContainsString('time', $converted); + self::assertStringContainsString('duration', $converted); + self::assertStringContainsString('memory', $converted); + self::assertStringContainsString(strval($telemetryInfo->memoryUsage()->bytes()), $converted); + } + + public function testPHPUnitTelemetryHRTime(): void + { + $eventObj = PHPUnitExtensionBase::$lastBeforeTestCaseEvent; + self::assertNotNull($eventObj); + $hrTime = $eventObj->telemetryInfo()->time(); + $converted = LoggableToString::convert($hrTime); + self::assertStringContainsString(strval($hrTime->seconds()), $converted); + self::assertStringContainsString(strval($hrTime->nanoseconds()), $converted); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/RandomUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/RandomUtilTest.php new file mode 100644 index 0000000..1d94462 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/RandomUtilTest.php @@ -0,0 +1,63 @@ + $randSelectedSubSet]) + ); + AssertEx::listIsSubsetOf($randSelectedSubSet, $totalSet); + self::assertEqualsCanonicalizing($totalSet, RandomUtil::arrayRandValues($totalSet, count($totalSet))); + + $totalSet = ['a', 'b', 'c']; + $randSelectedSubSet = RandomUtil::arrayRandValues($totalSet, 1); + self::assertCount(1, $randSelectedSubSet); + self::assertTrue( + $randSelectedSubSet == ['a'] || $randSelectedSubSet == ['b'] || $randSelectedSubSet == ['c'], + LoggableToString::convert(['$randSelectedSubSet' => $randSelectedSubSet]) + ); + AssertEx::listIsSubsetOf($randSelectedSubSet, $totalSet); + $randSelectedSubSet = RandomUtil::arrayRandValues($totalSet, 2); + self::assertCount(2, $randSelectedSubSet); + AssertEx::listIsSubsetOf($randSelectedSubSet, $totalSet); + self::assertEqualsCanonicalizing($totalSet, RandomUtil::arrayRandValues($totalSet, count($totalSet))); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/RangeUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/RangeUtilTest.php new file mode 100644 index 0000000..56c9c3d --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/RangeUtilTest.php @@ -0,0 +1,166 @@ + + */ + $generateAsArray = function (int $begin, int $end, int $step = 1): array { + return IterableUtil::toList(RangeUtil::generate($begin, $end, $step)); + }; + + self::assertEquals([], $generateAsArray(0, 0)); + self::assertEquals([], $generateAsArray(0, 0, 0)); + self::assertEquals([], $generateAsArray(0, 0, 1)); + self::assertEquals([], $generateAsArray(0, 0, -1)); + + self::assertEquals([], $generateAsArray(100, 100)); + self::assertEquals([], $generateAsArray(100, 100, 0)); + self::assertEquals([], $generateAsArray(100, 100, 1)); + self::assertEquals([], $generateAsArray(100, 100, -1)); + + self::assertEquals([0], $generateAsArray(0, 1)); + self::assertEquals([0, 1], $generateAsArray(0, 2)); + + self::assertEquals([-1], $generateAsArray(-1, 0)); + self::assertEquals([-2, -1], $generateAsArray(-2, 0)); + self::assertEquals([-2], $generateAsArray(-2, 0, 2)); + + self::assertEquals([], $generateAsArray(0, -1)); + self::assertEquals([], $generateAsArray(0, -1, 1)); + self::assertEquals([], $generateAsArray(0, -1, -1)); + + self::assertEquals([], $generateAsArray(0, -2)); + self::assertEquals([], $generateAsArray(0, -2, 1)); + self::assertEquals([], $generateAsArray(0, -2, -1)); + self::assertEquals([], $generateAsArray(0, -2, 2)); + self::assertEquals([], $generateAsArray(0, -2, -2)); + + self::assertEquals([101, 102], $generateAsArray(101, 103)); + self::assertEquals([-103, -102], $generateAsArray(-103, -101)); + self::assertEquals([], $generateAsArray(-101, -103)); + + self::assertTrue(IterableUtil::isEmpty(RangeUtil::generate(1000, 1000))); + self::assertFalse(IterableUtil::isEmpty(RangeUtil::generate(1000, 1001))); + + self::assertSame(0, IterableUtil::count(RangeUtil::generate(1000, 1000))); + self::assertSame(1, IterableUtil::count(RangeUtil::generate(1000, 1001))); + } + + public function testGenerateDown(): void + { + /** + * @param int $begin + * @param int $end + * @param int $step + * + * @return array + */ + $generateDownAsArray = function (int $begin, int $end, int $step = 1): array { + return IterableUtil::toList(RangeUtil::generateDown($begin, $end, $step)); + }; + + self::assertEquals([], $generateDownAsArray(0, 0)); + self::assertEquals([], $generateDownAsArray(0, 0, 0)); + self::assertEquals([], $generateDownAsArray(0, 0, 1)); + self::assertEquals([], $generateDownAsArray(0, 0, -1)); + + self::assertEquals([], $generateDownAsArray(100, 100)); + self::assertEquals([], $generateDownAsArray(100, 100, 0)); + self::assertEquals([], $generateDownAsArray(100, 100, 1)); + self::assertEquals([], $generateDownAsArray(100, 100, -1)); + + self::assertEquals([1], $generateDownAsArray(1, 0)); + self::assertEquals([2, 1], $generateDownAsArray(2, 0)); + + self::assertEquals([0], $generateDownAsArray(0, -1)); + self::assertEquals([0, -1], $generateDownAsArray(0, -2)); + self::assertEquals([0], $generateDownAsArray(0, -2, 2)); + + self::assertEquals([], $generateDownAsArray(-1, 0)); + self::assertEquals([], $generateDownAsArray(-1, 0, 1)); + self::assertEquals([], $generateDownAsArray(-1, 0, -1)); + + self::assertEquals([], $generateDownAsArray(-2, 0)); + self::assertEquals([], $generateDownAsArray(-2, 0, 1)); + self::assertEquals([], $generateDownAsArray(-2, 0, -1)); + self::assertEquals([], $generateDownAsArray(-2, 0, 2)); + self::assertEquals([], $generateDownAsArray(-2, 0, -2)); + + self::assertEquals([103, 102], $generateDownAsArray(103, 101)); + self::assertEquals([-101, -102], $generateDownAsArray(-101, -103)); + self::assertEquals([], $generateDownAsArray(-103, -101)); + + self::assertTrue(IterableUtil::isEmpty(RangeUtil::generateDown(1000, 1000))); + self::assertFalse(IterableUtil::isEmpty(RangeUtil::generateDown(1001, 1000))); + + self::assertSame(0, IterableUtil::count(RangeUtil::generateDown(1000, 1000))); + self::assertSame(1, IterableUtil::count(RangeUtil::generateDown(1001, 1000))); + } + + public function testGenerateUpTo(): void + { + /** + * @param int $count + * + * @return array + */ + $generateUpToAsArray = function (int $count): array { + return IterableUtil::toList(RangeUtil::generateUpTo($count)); + }; + + self::assertEquals([], $generateUpToAsArray(0)); + self::assertEquals([0], $generateUpToAsArray(1)); + self::assertEquals([0, 1], $generateUpToAsArray(2)); + } + + public function testGenerateFromToIncluding(): void + { + /** + * @param int $begin + * @param int $end + * + * @return array + */ + $generateFromToIncludingAsArray = function (int $begin, int $end): array { + return IterableUtil::toList(RangeUtil::generateFromToIncluding($begin, $end)); + }; + + self::assertEquals([0], $generateFromToIncludingAsArray(0, 0)); + self::assertEquals([0, 1], $generateFromToIncludingAsArray(0, 1)); + self::assertEquals([0, 1, 2], $generateFromToIncludingAsArray(0, 2)); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/TextUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/TextUtilTest.php new file mode 100644 index 0000000..7897441 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/TextUtilTest.php @@ -0,0 +1,320 @@ +> + */ + public static function camelToSnakeCaseTestDataProvider(): array + { + return [ + ['', ''], + ['a', 'a'], + ['B', 'b'], + ['1', '1'], + ['aB', 'a_b'], + ['AB', 'a_b'], + ['Ab', 'ab'], + ['Ab1', 'ab1'], + ['spanCount', 'span_count'], + ]; + } + + /** + * @return iterable> + */ + public static function snakeToCamelCaseTestDataProvider(): iterable + { + yield ['', '']; + yield ['a', 'a']; + yield ['1', '1']; + yield ['a_b', 'aB']; + yield ['a1_b', 'a1B']; + yield ['span_count', 'spanCount']; + yield ['_span_count', 'spanCount']; + yield ['__span__count', 'spanCount']; + yield ['_', '']; + yield ['__', '']; + yield ['_x_', 'x']; + yield ['x_y', 'xY']; + yield ['x_1y', 'x1y']; + } + + /** + * @dataProvider camelToSnakeCaseTestDataProvider + * + * @param string $inputCamelCase + * @param string $inputSnakeCase + */ + public function testCamelToSnakeCase(string $inputCamelCase, string $inputSnakeCase): void + { + self::assertSame($inputSnakeCase, TextUtil::camelToSnakeCase($inputCamelCase)); + } + + /** + * @dataProvider snakeToCamelCaseTestDataProvider + * + * @param string $inputCamelCase + * @param string $inputSnakeCase + */ + public function testSnakeToCamelCase(string $inputSnakeCase, string $inputCamelCase): void + { + self::assertSame($inputCamelCase, TextUtil::snakeToCamelCase($inputSnakeCase)); + } + + public function testIsPrefixOf(): void + { + self::assertTrue(TextUtil::isPrefixOf('', '')); + self::assertTrue(TextUtil::isPrefixOf('', '', isCaseSensitive: false)); + self::assertTrue(!TextUtil::isPrefixOf('a', '')); + self::assertTrue(!TextUtil::isPrefixOf('a', '', isCaseSensitive: false)); + + self::assertTrue(TextUtil::isPrefixOf('A', 'ABC')); + self::assertTrue(!TextUtil::isPrefixOf('a', 'ABC')); + self::assertTrue(TextUtil::isPrefixOf('a', 'ABC', isCaseSensitive: false)); + + self::assertTrue(TextUtil::isPrefixOf('AB', 'ABC')); + self::assertTrue(!TextUtil::isPrefixOf('aB', 'ABC')); + self::assertTrue(TextUtil::isPrefixOf('aB', 'ABC', isCaseSensitive: false)); + + self::assertTrue(TextUtil::isPrefixOf('ABC', 'ABC')); + self::assertTrue(!TextUtil::isPrefixOf('aBc', 'ABC')); + self::assertTrue(TextUtil::isPrefixOf('aBc', 'ABC', isCaseSensitive: false)); + } + + public function testIsSuffixOf(): void + { + self::assertTrue(TextUtil::isSuffixOf('', '')); + self::assertTrue(TextUtil::isSuffixOf('', '', isCaseSensitive: false)); + self::assertTrue(!TextUtil::isSuffixOf('a', '')); + self::assertTrue(!TextUtil::isSuffixOf('a', '', isCaseSensitive: false)); + + self::assertTrue(TextUtil::isSuffixOf('C', 'ABC')); + self::assertTrue(!TextUtil::isSuffixOf('c', 'ABC')); + self::assertTrue(TextUtil::isSuffixOf('c', 'ABC', isCaseSensitive: false)); + + self::assertTrue(TextUtil::isSuffixOf('BC', 'ABC')); + self::assertTrue(!TextUtil::isSuffixOf('Bc', 'ABC')); + self::assertTrue(TextUtil::isSuffixOf('Bc', 'ABC', isCaseSensitive: false)); + + self::assertTrue(TextUtil::isSuffixOf('ABC', 'ABC')); + self::assertTrue(!TextUtil::isSuffixOf('aBc', 'ABC')); + self::assertTrue(TextUtil::isSuffixOf('aBc', 'ABC', isCaseSensitive: false)); + } + + public function testFlipLetterCase(): void + { + $flipOneLetterString = function (string $src): string { + return chr(TextUtil::flipLetterCase(ord($src[0]))); + }; + + self::assertSame('a', $flipOneLetterString('A')); + self::assertNotEquals('A', $flipOneLetterString('A')); + self::assertSame('X', $flipOneLetterString('x')); + self::assertNotEquals('x', $flipOneLetterString('x')); + self::assertSame('0', $flipOneLetterString('0')); + self::assertSame('#', $flipOneLetterString('#')); + } + /** + * @return iterable + * ^^^^^^------- end-of-line + * ^^^^^^--------------- line text without end-of-line + * ^^^^^^----------------------------- input text + */ + public static function dataProviderForTestIterateLines(): iterable + { + yield [ + '' /* empty line without end-of-line */, + [['' /* <- empty line text */, '' /* <- no end-of-line */]] + ]; + + yield [ + 'some text without end-of-line', + [['some text without end-of-line', '' /* <- no end-of-line */]] + ]; + + yield [ + PHP_EOL /* <- empty line */ . + 'second line' . PHP_EOL . + PHP_EOL /* <- empty line */ . + 'last non-empty line' . PHP_EOL + /* empty line without end-of-line */, + [ + ['' /* <- empty line text */, PHP_EOL], + ['second line', PHP_EOL], + ['' /* <- empty line text */, PHP_EOL], + ['last non-empty line', PHP_EOL], + ['' /* <- empty line text */, '' /* <- no end-of-line */], + ], + ]; + + yield ["\n", [['' /* <- empty line text */, "\n"], ['' /* <- empty line text */, '' /* <- no end-of-line */]]]; + yield ["\r", [['' /* <- empty line text */, "\r"], ['' /* <- empty line text */, '' /* <- no end-of-line */]]]; + yield ["\r\n", [['' /* <- empty line text */, "\r\n"], ['' /* <- empty line text */, '' /* <- no end-of-line */]]]; + + // "\n\r" is not one line end-of-line but two "\n\r" + yield ["\n\r", [['' /* <- empty line text */, "\n"], ['' /* <- empty line text */, "\r"], ['' /* <- empty line text */, '']]]; + } + + /** + * @dataProvider dataProviderForTestIterateLines + * + * @param string $inputText + * @param array{string, string}[] $expectedLinesParts + * ^^^^^^------------------------------ end-of-line + * ^^^^^^-------------------------------------- line text without end-of-line + */ + public function testIterateLines(string $inputText, array $expectedLinesParts): void + { + foreach ([true, false] as $keepEndOfLine) { + $index = 0; + foreach (TextUtilForTests::iterateLines($inputText, $keepEndOfLine) as $actualLine) { + $expectedLineParts = $expectedLinesParts[ $index ]; + self::assertCount(2, $expectedLineParts); // @phpstan-ignore staticMethod.alreadyNarrowedType + $expectedLine = $expectedLineParts[0] . ($keepEndOfLine ? $expectedLineParts[1] : ''); + self::assertSame($expectedLine, $actualLine); + ++$index; + } + } + } + + /** + * @return iterable + */ + public static function dataProviderForTestPrefixEachLine(): iterable + { + yield ['', 'p_', 'p_']; + yield ["\n", 'p_', "p_\np_"]; + yield ["\r", 'p_', "p_\rp_"]; + yield ["\r\n", 'p_', "p_\r\np_"]; + yield ["\n\r", 'p_', "p_\np_\rp_"]; + } + + /** + * @dataProvider dataProviderForTestPrefixEachLine + * + * @param string $inputText + * @param string $prefix + * @param string $expectedOutputText + * + * @return void + */ + public function testPrefixEachLine(string $inputText, string $prefix, string $expectedOutputText): void + { + $actualOutputText = TextUtilForTests::prefixEachLine($inputText, $prefix); + self::assertSame($expectedOutputText, $actualOutputText); + } + + /** + * @return iterable + */ + public static function dataProviderForTestRemoveIndentationValidInput(): iterable + { + /** + * @param list $lines + * + * @return iterable + */ + $genFromOneIndentationAndLines = function (string $indentation, array $lines): iterable { + AssertEx::countAtLeast(1, $lines); + $input = ''; + $expectedOutput = ''; + foreach ([true, false] as $shouldAppendEmptyLine) { + /** @var non-negative-int $i */ + /** @var string $line */ + foreach (IterableUtil::iterateListWithIndex($lines) as [$i, $line]) { + /** @var string $line */ + $lineSuffix = ($shouldAppendEmptyLine && ($i === (count($lines) - 1))) ? PHP_EOL : ''; + $input = $indentation . $line . $lineSuffix; + $expectedOutput = $line . $lineSuffix; + } + yield [$input, $expectedOutput]; + } + }; + + yield from $genFromOneIndentationAndLines(indentation: "", lines: ['']); + yield from $genFromOneIndentationAndLines(indentation: " ", lines: ['']); + yield from $genFromOneIndentationAndLines(indentation: "\t", lines: ['']); + yield from $genFromOneIndentationAndLines(indentation: "\t ", lines: ['']); + yield from $genFromOneIndentationAndLines(indentation: " \t", lines: ['']); + yield from $genFromOneIndentationAndLines(indentation: "\t\t", lines: ['a', 'b']); + + yield [ + " " . "a" . "\r\n" . + " " . "b", + + "a" . "\r\n" . + "b", + ]; + + yield [ + "\t" . "a" . "\r\n" . + "\t" . "b", + + "a" . "\r\n" . + "b", + ]; + } + + /** + * @dataProvider dataProviderForTestRemoveIndentationValidInput + */ + public function testRemoveIndentationValidInput(string $input, string $expectedOutput): void + { + $actualOutput = TextUtilForTests::removeIndentation($input); + self::assertSame($expectedOutput, $actualOutput); + } + + /** + * @return iterable + */ + public static function dataProviderForTestRemoveIndentationInvalidInput(): iterable + { + yield [ + "\t" . "a" . "\r\n" . + "b" + ]; + yield [ + " " . "abc" . "\n" . + "abc" + ]; + } + + /** + * @dataProvider dataProviderForTestRemoveIndentationInvalidInput + */ + public function testRemoveIndentationInvalidInput(string $input): void + { + AssertEx::throws(UnexpectedValueException::class, fn() => TextUtilForTests::removeIndentation($input)); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/TimeDurationUnitsTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/TimeDurationUnitsTest.php new file mode 100644 index 0000000..eed4e77 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/TimeDurationUnitsTest.php @@ -0,0 +1,43 @@ +name); + if ($prevSuffixLength !== null) { + self::assertLessThanOrEqual($prevSuffixLength, $suffixLength); + } + $prevSuffixLength = $suffixLength; + } + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/TimeUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/TimeUtilTest.php new file mode 100644 index 0000000..ee570af --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/TimeUtilTest.php @@ -0,0 +1,87 @@ +> + */ + public static function formatDurationInMicrosecondsTestDataProvider(): array + { + $buildDurationInMicroseconds = function ( + int $days, + int $hours, + int $minutes, + int $seconds, + int $milliseconds, + float $microseconds + ): float { + $result = floatval($days * TimeUtil::NUMBER_OF_HOURS_IN_DAY); + $result = ($result + $hours) * TimeUtil::NUMBER_OF_MINUTES_IN_HOUR; + $result = ($result + $minutes) * TimeUtil::NUMBER_OF_SECONDS_IN_MINUTE; + $result = ($result + $seconds) * TimeUtil::NUMBER_OF_MILLISECONDS_IN_SECOND; + $result = ($result + $milliseconds) * TimeUtil::NUMBER_OF_MICROSECONDS_IN_MILLISECOND; + $result += $microseconds; + return $result; + }; + + return [ + [0, '0us'], + [0.1, '0.1us'], + [-0.1, '-0.1us'], + [-1, '-1us'], + [$buildDurationInMicroseconds(1, 2, 3, 4, 5, 6), '1d 2h 3m 4s 5ms 6us'], + [$buildDurationInMicroseconds(1, 2, 3, 4, 5, 6.5), '1d 2h 3m 4s 5ms 6.5us'], + [$buildDurationInMicroseconds(1, 0, 0, 0, 0, 0), '1d'], + [$buildDurationInMicroseconds(0, 1, 0, 0, 0, 0), '1h'], + [$buildDurationInMicroseconds(0, 0, 1, 0, 0, 0), '1m'], + [$buildDurationInMicroseconds(0, 0, 0, 1, 0, 0), '1s'], + [$buildDurationInMicroseconds(0, 0, 0, 0, 1, 0), '1ms'], + [$buildDurationInMicroseconds(0, -1, 0, 0, 0, -1), '-1h 1us'], + [$buildDurationInMicroseconds(0, 0, -1, 0, 0, -1.5), '-1m 1.5us'], + [$buildDurationInMicroseconds(1, 0, 5, 0, 7, 0), '1d 5m 7ms'], + [$buildDurationInMicroseconds(1, 0, 5, 0, 7, 0.5), '1d 5m 7ms 0.5us'], + [$buildDurationInMicroseconds(0, 23, 59, 59, 999, 1000), '1d'], + [$buildDurationInMicroseconds(0, -23, -59, -59, -999, -1000.5), '-1d 0.5us'], + [$buildDurationInMicroseconds(1, 0, 0, 0, 0, 999), '1d 999us'], + [$buildDurationInMicroseconds(0, 0, 0, 0, 6, 7), '6ms 7us'], + [5006007.5, '5s 6ms 7.5us'], + ]; + } + + /** + * @dataProvider formatDurationInMicrosecondsTestDataProvider + * + * @param float $durationInMicroseconds + * @param string $expectedFormatted + */ + public function testFormatDurationInMicroseconds(float $durationInMicroseconds, string $expectedFormatted): void + { + $this->assertSame($expectedFormatted, TimeUtil::formatDurationInMicroseconds($durationInMicroseconds)); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/UrlUtilTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/UrlUtilTest.php new file mode 100644 index 0000000..acbdf8f --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/UrlUtilTest.php @@ -0,0 +1,85 @@ +> + * + * @noinspection SpellCheckingInspection + */ + public static function dataProviderForSplitHostPort(): array + { + return [ + ['my_host_1', 'my_host_1', null], + ['my-host-2:2', 'my-host-2', 2], + ['my-host-3:', 'my-host-3', null], + [':3', null, 3], + ['4-my-host:65535', '4-my-host', 65535], + ['my-host-5:abc', 'my-host-5', null], + [' my-host-6 : 123 ', 'my-host-6', 123], + [' my-host-7 : abc ', 'my-host-7', null], + ['6.77.89.102', '6.77.89.102', null], + ['255.254.253.252', '255.254.253.252', null], + ['255.254.253.252:7654', '255.254.253.252', 7654], + ['255.254.253.252:', '255.254.253.252', null], + ['::1', '::1', null], + ['[::1]:88', '::1', 88], + ['[ ::1 ] : 88', '::1', 88], + ['[::1]:', '::1', null], + [' [ ::1 ] : ', '::1', null], + ['[::1]', '::1', null], + [' [ ::1 ] ', '::1', null], + [' [ ::1 ] ', '::1', null], + ['fe80::dcdf:ebd9:b60a:b3fb', 'fe80::dcdf:ebd9:b60a:b3fb', null], + ['[fe80::dcdf:ebd9:b60a:b3fb]:9999', 'fe80::dcdf:ebd9:b60a:b3fb', 9999], + ['[fe80::dcdf:ebd9:b60a:b3fb]', 'fe80::dcdf:ebd9:b60a:b3fb', null], + ['[fe80::dcdf:ebd9:b60a:b3fb]:', 'fe80::dcdf:ebd9:b60a:b3fb', null], + ['fe80::dcdf:ebd9:b60a:b3fb%3', 'fe80::dcdf:ebd9:b60a:b3fb%3', null], + ['[fe80::dcdf:ebd9:b60a:b3fb%3]:9999', 'fe80::dcdf:ebd9:b60a:b3fb%3', 9999], + ['[fe80::dcdf:ebd9:b60a:b3fb%3]:', 'fe80::dcdf:ebd9:b60a:b3fb%3', null], + ['[fe80::dcdf:ebd9:b60a:b3fb%3]', 'fe80::dcdf:ebd9:b60a:b3fb%3', null], + ]; + } + + /** + * @dataProvider dataProviderForSplitHostPort + * + * @param string $inputHostPort + * @param string|null $expectedHost + * @param int|null $expectedPort + */ + public function testSplitHostPort(string $inputHostPort, ?string $expectedHost, ?int $expectedPort): void + { + $actualHost = null; + $actualPort = null; + self::assertTrue(UrlUtil::splitHostPort($inputHostPort, /* ref */ $actualHost, /* ref */ $actualPort)); + self::assertSame($expectedHost, $actualHost); + self::assertSame($expectedPort, $actualPort); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/WildcardListMatcherTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/WildcardListMatcherTest.php new file mode 100644 index 0000000..df6b557 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/WildcardListMatcherTest.php @@ -0,0 +1,127 @@ + + */ + private static function splitExpressionListString(string $exprListAsStringAsString): array + { + return array_map(trim(...), explode(',', $exprListAsStringAsString)); + } + + private function testCaseImpl(string $exprListAsString, string $text, ?string $expectedMatchingExpr): void + { + $matcher = new WildcardListMatcher(self::splitExpressionListString($exprListAsString)); + $actualMatchingExpr = $matcher->match($text); + + $this->assertSame( + $expectedMatchingExpr, + $actualMatchingExpr, + LoggableToString::convert( + [ + 'exprList' => $exprListAsString, + 'text' => $text, + 'expectedMatchingExpr' => $expectedMatchingExpr, + 'actualMatchingExpr' => $actualMatchingExpr, + ] + ) + ); + } + + public function testMatchedInOrderConfigured(): void + { + $this->testCaseImpl('/A/*, /A/B/*, /A/B/C/*', '/A/xyz', '/A/*'); + $this->testCaseImpl('/A/*, /A/B/*, /A/B/C/*', '/A/B/xyz', '/A/*'); + $this->testCaseImpl('/A/*, /A/B/*, /A/B/C/*', '/A/B/C/xyz', '/A/*'); + + $this->testCaseImpl('/A/B/*, /A/B/C/*, /A/*', '/A/xyz', '/A/*'); + $this->testCaseImpl('/A/B/*, /A/B/C/*, /A/*', '/A/B/yz', '/A/B/*'); + $this->testCaseImpl('/A/B/*, /A/B/C/*, /A/*', '/A/B/C/xyz', '/A/B/*'); + + $this->testCaseImpl('/A/B/C/*, /A/B/*, /A/*', '/A/xyz', '/A/*'); + $this->testCaseImpl('/A/B/C/*, /A/B/*, /A/*', '/A/B/xyz', '/A/B/*'); + $this->testCaseImpl('/A/B/C/*, /A/B/*, /A/*', '/A/B/C/xyz', '/A/B/C/*'); + } + + public function testCaseSensitiveIsSeparateForEachExpression(): void + { + $this->testCaseImpl('A, B', 'A', 'A'); + $this->testCaseImpl('A, B', 'a', 'A'); + $this->testCaseImpl('A, B', 'b', 'B'); + + $this->testCaseImpl('(?-i)A, B', 'a', null); + $this->testCaseImpl('(?-i)A, B', 'A', 'A'); + $this->testCaseImpl('(?-i)A, B', 'b', 'B'); + + $this->testCaseImpl('(?-i)A, (?-i)B', 'b', null); + $this->testCaseImpl('(?-i)A, (?-i)B', 'B', 'B'); + } + + public function testWhitespaceAroundCommasIsInsignificant(): void + { + $this->testCaseImpl(' A', ' A', null); + $this->testCaseImpl(' A', 'A', 'A'); + $this->testCaseImpl(' *A', ' A', '*A'); + + $this->testCaseImpl(' A, B ', ' A', null); + $this->testCaseImpl(' A, B ', 'B ', null); + $this->testCaseImpl(' A, B ', 'A', 'A'); + $this->testCaseImpl(' A, B ', 'B', 'B'); + + $this->testCaseImpl(' *A* ', 'A', '*A*'); + + $this->testCaseImpl("\t /*/A/ /*\n, / /B/*", '/xyz/A/ /', '/*/A/ /*'); + $this->testCaseImpl("\t /*/A/ /*\r\n, / /B/*", '/xyz/A/', null); + $this->testCaseImpl("\t /*/A/ /*\t\n, / /B/*", '/ /B/xyz', '/ /B/*'); + $this->testCaseImpl("\t /*/A/ /* \n, / /B/*", '/ /B/', '/ /B/*'); + $this->testCaseImpl("\t /*/A/ /*\r\n, / /B/*", '/B/', null); + } + + public function testToString(): void + { + $impl = function (string $exprListAsString, string $expectedToStringResult): void { + $actualToStringResult = strval(new WildcardListMatcher(self::splitExpressionListString($exprListAsString))); + $this->assertSame( + $expectedToStringResult, + $actualToStringResult, + LoggableToString::convert( + [ + 'expr' => $exprListAsString, + 'expectedToStringResult' => $expectedToStringResult, + 'actualToStringResult' => $actualToStringResult, + ] + ) + ); + }; + + $impl(/* input: */ "a, (?-i) a*b \t, a**b, \n \t", /* expected: */ "a, (?-i) a*b, a*b, "); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/UtilTests/WildcardMatcherTest.php b/tests/ElasticOTelTests/UnitTests/UtilTests/WildcardMatcherTest.php new file mode 100644 index 0000000..d672de2 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/UtilTests/WildcardMatcherTest.php @@ -0,0 +1,138 @@ +match($text), LoggableToString::convert(compact('expr', 'text', 'expectedResult'))); + } + + /** + * @return iterable + */ + public static function dataProviderForTestOnExternalData(): iterable + { + $externalDataJson = ExternalTestData::readJsonSpecsFile('wildcard_matcher_tests.json'); + self::assertIsArray($externalDataJson); + foreach ($externalDataJson as $testDesc => $testCases) { + self::assertIsString($testDesc); + self::assertIsArray($testCases); + foreach ($testCases as $expr => $textToExpectedResultPairs) { + self::assertIsString($expr); + self::assertIsArray($textToExpectedResultPairs); + foreach ($textToExpectedResultPairs as $text => $expectedResult) { + self::assertIsString($text); + self::assertIsBool($expectedResult); + yield [$testDesc, $expr, $text, $expectedResult]; + } + } + } + } + + /** + * @dataProvider dataProviderForTestOnExternalData + * + * @param string $testCaseDesc + * @param string $expr + * @param string $text + * @param bool $expectedResult + */ + public function testOnExternalData(string $testCaseDesc, string $expr, string $text, bool $expectedResult): void + { + self::assertNotSame('', $testCaseDesc); + $this->testCaseImpl($expr, $text, $expectedResult); + } + + /** + * @return iterable + */ + public static function dataProviderForTestAdditionalCases(): iterable + { + // + // empty wildcard expression matches only empty text + // + yield ['', '', true]; + yield ['', '1', false]; + yield ['', '*', false]; + yield ['(?-i)', '', true]; + yield ['(?-i)', '1', false]; + yield ['(?-i)', '*', false]; + + // + // (?-i) prefix is not matched literally + // + yield ['(?-i)', '(?-i)', false]; + yield ['', '(?-i)', false]; + } + + /** + * @dataProvider dataProviderForTestAdditionalCases + * + * @param string $expr + * @param string $text + * @param bool $expectedResult + */ + public function testAdditionalCases(string $expr, string $text, bool $expectedResult): void + { + $this->testCaseImpl($expr, $text, $expectedResult); + } + + public function testToString(): void + { + $impl = function (string $expr, string $expectedToStringResult): void { + $actualToStringResult = strval((new WildcardMatcher($expr))); + self::assertSame( + $expectedToStringResult, + $actualToStringResult, + LoggableToString::convert( + [ + 'expr' => $expr, + 'expectedToStringResult' => $expectedToStringResult, + 'actualToStringResult' => $actualToStringResult, + ] + ) + ); + }; + + $impl(/* input: */ 'a', /* expected: */ 'a'); + $impl(/* input: */ 'a*b', /* expected: */ 'a*b'); + $impl(/* input: */ 'a**b', /* expected: */ 'a*b'); + $impl(/* input: */ '(?-i)a', /* expected: */ '(?-i)a'); + $impl(/* input: */ '(?-i) a', /* expected: */ '(?-i) a'); + $impl(/* input: */ '(?-i) a ', /* expected: */ '(?-i) a '); + + $impl(/* input: */ '', /* expected: */ ''); + $impl(/* input: */ '(?-i)', /* expected: */ '(?-i)'); + $impl(/* input: */ '(?-i) ', /* expected: */ '(?-i) '); + $impl(/* input: */ ' (?-i) ', /* expected: */ ' (?-i) '); + $impl(/* input: */ ' (?-i) ', /* expected: */ ' (?-i) '); + } +} diff --git a/tests/ElasticOTelTests/UnitTests/bootstrapUnitTests.php b/tests/ElasticOTelTests/UnitTests/bootstrapUnitTests.php new file mode 100644 index 0000000..1384db2 --- /dev/null +++ b/tests/ElasticOTelTests/UnitTests/bootstrapUnitTests.php @@ -0,0 +1,30 @@ +logBackend = new LogBackend($maxEnabledLogLevelBeforeRealConfig, new SinkForTests($dbgProcessName)); + self::$loggerFactory = new LoggerFactory($this->logBackend); + $this->clock = new Clock(self::$loggerFactory); + // Now that we have a logger we can read real config and see the potential issues with it logged + $this->readAndApplyConfig(); + } + + public static function init(string $dbgProcessName): void + { + ExceptionUtil::runCatchLogRethrow( + function () use ($dbgProcessName): void { + if (self::$singletonInstance !== null) { + Assert::assertSame(self::$dbgProcessName, $dbgProcessName); + return; + } + + self::$singletonInstance = new self($dbgProcessName); + } + ); + } + + public static function isInited(): bool + { + return self::$singletonInstance !== null; + } + + public static function assertIsInited(): void + { + ExceptionUtil::runCatchLogRethrow( + function (): void { + Assert::assertTrue(self::isInited(), 'Assertion that, ' . __CLASS__ . ' is initialized, failed'); + } + ); + } + + private static function getSingletonInstance(): self + { + return ExceptionUtil::runCatchLogRethrow( + function (): self { + Assert::assertNotNull(self::$singletonInstance); + return self::$singletonInstance; + } + ); + } + + public static function reconfigure(?RawSnapshotSourceInterface $additionalConfigSource = null): void + { + self::getSingletonInstance()->readAndApplyConfig($additionalConfigSource); + } + + private function readAndApplyConfig(?RawSnapshotSourceInterface $additionalConfigSource = null): void + { + $envVarConfigSource = new EnvVarsRawSnapshotSource(OptionForTestsName::ENV_VAR_NAME_PREFIX, IterableUtil::keys(OptionsForTestsMetadata::get())); + $configSource = $additionalConfigSource === null ? $envVarConfigSource : new CompositeRawSnapshotSource([$additionalConfigSource, $envVarConfigSource]); + $this->testConfig = ConfigUtilForTests::read($configSource, self::loggerFactory()); + $this->logBackend->setMaxEnabledLevel($this->testConfig->logLevel); + } + + public static function resetLogLevel(LogLevel $newVal): void + { + self::resetConfigOption(OptionForTestsName::log_level, $newVal->name); + Assert::assertSame($newVal, AmbientContextForTests::testConfig()->logLevel); + } + + public static function resetEscalatedRerunsMaxCount(int $newVal): void + { + self::resetConfigOption(OptionForTestsName::escalated_reruns_max_count, strval($newVal)); + Assert::assertSame($newVal, AmbientContextForTests::testConfig()->escalatedRerunsMaxCount); + } + + private static function resetConfigOption(OptionForTestsName $optName, string $newValAsEnvVar): void + { + $envVarName = $optName->toEnvVarName(); + EnvVarUtil::set($envVarName, $newValAsEnvVar); + AmbientContextForTests::reconfigure(); + } + + public static function testConfig(): ConfigSnapshotForTests + { + return self::getSingletonInstance()->testConfig; + } + + /** @noinspection PhpUnused */ + public static function dbgProcessName(): string + { + return ExceptionUtil::runCatchLogRethrow( + function (): string { + Assert::assertNotNull(self::$dbgProcessName); + return self::$dbgProcessName; + } + ); + } + + public static function loggerFactory(): LoggerFactory + { + return ExceptionUtil::runCatchLogRethrow( + function (): LoggerFactory { + Assert::assertNotNull(self::$loggerFactory); + return self::$loggerFactory; + } + ); + } + + public static function clock(): Clock + { + return self::getSingletonInstance()->clock; + } +} diff --git a/tests/ElasticOTelTests/Util/ArrayReadInterface.php b/tests/ElasticOTelTests/Util/ArrayReadInterface.php new file mode 100644 index 0000000..d240fc5 --- /dev/null +++ b/tests/ElasticOTelTests/Util/ArrayReadInterface.php @@ -0,0 +1,43 @@ + $array + */ + public static function isEmpty(array $array): bool + { + return count($array) === 0; + } + + /** + * @template T + * @param T[] $array + * @return T + */ + public static function getFirstValue(array $array): mixed + { + return $array[array_key_first($array)]; + } + + /** + * @template T + * @param T[] $array + * @return T + */ + public static function getSingleValue(array $array): mixed + { + Assert::assertCount(1, $array); + return self::getFirstValue($array); + } + + /** + * @template T + * + * @param array $array + * + * @return T + */ + public static function &getLastValue(array $array) + { + Assert::assertNotEmpty($array); + return $array[array_key_last($array)]; + } + + /** + * @template TKey of array-key + * @template TValue + * + * @phpstan-param TKey $key + * @phpstan-param TValue $value + * @phpstan-param array &$result + */ + public static function addAssertingKeyNew(string|int $key, mixed $value, /* in,out */ array &$result): void + { + Assert::assertArrayNotHasKey($key, $result, LoggableToString::convert(compact('key', 'value', 'result'))); + $result[$key] = $value; + } + + /** + * @template TKey of string|int + * @template TValue + * + * @param array $from + * @param array $to + */ + public static function append(array $from, /* in,out */ array &$to): void + { + $to = array_merge($to, $from); + } + + /** + * @template TKey + * @template TValue + * + * @param array $map + * @param TKey $keyToFind + * + * @return int + * + * @noinspection PhpUnused + */ + public static function getAdditionOrderIndex(array $map, $keyToFind): int + { + $additionOrderIndex = 0; + foreach ($map as $key => $ignored) { + if ($key === $keyToFind) { + return $additionOrderIndex; + } + ++$additionOrderIndex; + } + Assert::fail('Not found key in map; ' . LoggableToString::convert(['keyToFind' => $keyToFind, 'map' => $map])); + } + + /** + * @param array $argsMap + */ + public static function getFromMap(string $argKey, array $argsMap): mixed + { + Assert::assertArrayHasKey($argKey, $argsMap); + return $argsMap[$argKey]; + } + + /** + * @param string $argKey + * @param array $argsMap + * + * @return bool + * + * @noinspection PhpUnused + */ + public static function getBoolFromMap(string $argKey, array $argsMap): bool + { + $val = self::getFromMap($argKey, $argsMap); + Assert::assertIsBool($val, LoggableToString::convert(['argKey' => $argKey, 'argsMap' => $argsMap])); + return $val; + } + + /** + * @template TValue + * + * @param TValue $value + * @param TValue[] $list + * + * @noinspection PhpUnused + */ + public static function addToListIfNotAlreadyPresent($value, array &$list, bool $shouldUseStrictEq = true): void + { + if (!in_array($value, $list, $shouldUseStrictEq)) { + $list[] = $value; + } + } + + /** + * @template TValue + * * + * @param array &$removeFromArray + * @param TValue $valueToRemove + */ + public static function removeFirstByValue(/* in,out */ array &$removeFromArray, mixed $valueToRemove): bool + { + foreach ($removeFromArray as $key => $value) { + if ($value === $valueToRemove) { + unset($removeFromArray[$key]); + return true; + } + } + return false; + } + + /** + * @template TKey of array-key + * * + * @phpstan-param array &$removeFromArray + * @phpstan-param TKey $keyToRemove + * + * @noinspection PhpUnused + */ + public static function removeByKey(/* in,out */ array &$removeFromArray, string|int $keyToRemove): bool + { + if (array_key_exists($keyToRemove, $removeFromArray)) { + unset($removeFromArray[$keyToRemove]); + return true; + } + return false; + } + + /** + * @template TValue + * * + * @param array &$removeFromArray + * @param array $valuesToRemove + */ + public static function removeAllValues(/* in,out */ array &$removeFromArray, array $valuesToRemove): int + { + $countRemoved = 0; + foreach ($removeFromArray as $key => $value) { + if (in_array($value, $valuesToRemove, strict: true)) { + unset($removeFromArray[$key]); + ++$countRemoved; + } + } + return $countRemoved; + } + + /** + * @template TValue + * + * @param TValue[] $array + * + * @return iterable + */ + public static function iterateListInReverse(array $array): iterable + { + AssertEx::arrayIsList($array); + for ($currentValue = end($array); key($array) !== null; $currentValue = prev($array)) { + yield $currentValue; // @phpstan-ignore generator.valueType + } + } + + /** + * @template TKey + * @template TValue + * + * @param array $array + * + * @return iterable + */ + public static function iterateMapInReverse(array $array): iterable + { + for ($currentValue = end($array); ($currentKey = key($array)) !== null; $currentValue = prev($array)) { + yield $currentKey => $currentValue; // @phpstan-ignore generator.valueType + } + } + + /** + * @template TKey of array-key + * @template TValue + * + * @param array $lhs + * @param array $rhs + * + * @noinspection PhpUnused + */ + public static function haveTheSameContent(array $lhs, array $rhs): bool + { + if (count($lhs) !== count($rhs)) { + return false; + } + + foreach ($lhs as $lhsKey => $lhsValue) { + if (!ArrayUtil::getValueIfKeyExists($lhsKey, $rhs, /**/ $rhsValue)) { + return false; + } + if ($lhsValue !== $rhsValue) { + return false; + } + } + return true; + } + + /** + * @template T + * + * @param array $array + * @param non-negative-int $n + */ + public static function popN(/* in,out */ array &$array, int $n): void + { + if ($n > count($array)) { + throw new OutOfBoundsException( + ExceptionUtil::buildMessage('n is out of bounds', compact('n') + ['array count' => count($array)] + compact('array')) + ); + } + array_splice(/* in,out */ $array, count($array) - $n); + } + + /** + * @template T + * + * @param array $array + * @param non-negative-int $index + */ + public static function popFromIndex(/* in,out */ array &$array, int $index): void + { + if ($index >= count($array)) { + throw new OutOfBoundsException( + ExceptionUtil::buildMessage('index is out of bounds', compact('index') + ['array count' => count($array)] + compact('array')) + ); + } + + array_splice(/* in,out */ $array, $index); + } + + /** + * @param array|Countable $container + */ + public static function isValidIndexOf(int $index, array|Countable $container): bool + { + return RangeUtil::isValidIndexOfCountable($index, count($container)); + } +} diff --git a/tests/ElasticOTelTests/Util/AssertEx.php b/tests/ElasticOTelTests/Util/AssertEx.php new file mode 100644 index 0000000..6227dd9 --- /dev/null +++ b/tests/ElasticOTelTests/Util/AssertEx.php @@ -0,0 +1,436 @@ + $expected + * @param array $actual + */ + public static function equalMaps(array $expected, array $actual): void + { + self::sameCount($expected, $actual); + self::mapIsSubsetOf($expected, $actual); + } + + /** @noinspection PhpUnused */ + public static function isBool(mixed $actual, string $message = ''): bool + { + Assert::assertIsBool($actual, $message); + return $actual; + } + + public static function stringSameAsInt(int $expected, string $actual, string $message = ''): void + { + Assert::assertNotFalse(filter_var($actual, FILTER_VALIDATE_INT), $message); + $actualAsInt = intval($actual); + Assert::assertSame($expected, $actualAsInt, $message); + } + + /** @noinspection PhpUnused */ + public static function sameNullness(mixed $expected, mixed $actual): void + { + Assert::assertSame($expected === null, $actual === null); + } + + /** + * @template TKey of array-key + * @template TValue + * + * @phpstan-param TKey $expectedKey + * @phpstan-param TValue $expectedValue + * @phpstan-param array|ArrayAccess $actualArray + */ + public static function arrayHasKeyWithSameValue(string|int $expectedKey, mixed $expectedValue, array|ArrayAccess $actualArray, string $message = ''): void + { + Assert::assertArrayHasKey($expectedKey, $actualArray, $message); + Assert::assertSame($expectedValue, $actualArray[$expectedKey], $message); + } + + /** + * @template TKey of array-key + * @template TValue + * + * @phpstan-param TKey $expectedKey + * @phpstan-param TValue $expectedValue + * @phpstan-param array|ArrayAccess $actualArray + * + * @noinspection PhpUnused + */ + public static function arrayHasKeyWithEqualValue(string|int $expectedKey, mixed $expectedValue, array|ArrayAccess $actualArray): void + { + Assert::assertArrayHasKey($expectedKey, $actualArray); + Assert::assertEquals($expectedValue, $actualArray[$expectedKey]); + } + + /** + * @template T + * + * @param ?T $actual + * + * @return T + */ + public static function notNull(mixed $actual, string $message = ''): mixed + { + Assert::assertNotNull($actual, $message); + return $actual; + } + + /** + * @param Countable|array $expected + * @param Countable|array $actual + */ + public static function sameCount(Countable|array $expected, Countable|array $actual): void + { + Assert::assertSame(count($expected), count($actual)); + } + + public static function isString(mixed $actual, string $message = ''): string + { + Assert::assertIsString($actual, $message); + return $actual; + } + + public static function isInt(mixed $actual, string $message = ''): int + { + Assert::assertIsInt($actual, $message); + return $actual; + } + + public static function isFloat(mixed $actual, string $message = ''): float + { + Assert::assertIsFloat($actual, $message); + return $actual; + } + + /** + * @template TKey of array-key + * @template TValue + * + * @param array $subsetMap + * @param array $containingMap + */ + public static function mapIsSubsetOf(array $subsetMap, array $containingMap): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + Assert::assertGreaterThanOrEqual(count($subsetMap), count($containingMap)); + foreach ($subsetMap as $subsetMapKey => $subsetMapVal) { + $dbgCtx->add(compact('subsetMapKey', 'subsetMapVal')); + Assert::assertArrayHasKey($subsetMapKey, $containingMap); + Assert::assertEquals($subsetMapVal, $containingMap[$subsetMapKey]); + } + } + + public static function equalsEx(mixed $expected, mixed $actual, string $message = ''): void + { + if (is_object($expected) && method_exists($expected, 'equals')) { + Assert::assertTrue($expected->equals($actual)); + } else { + Assert::assertEquals($expected, $actual, $message); + } + } + + /** + * Asserts that the callable throws a specified throwable. + * If successful and the inspection callable is not null + * then it is called and the caught exception is passed as argument. + * + * @param callable(): mixed $execute + * @param ?callable(Throwable): void $inspect + */ + public static function throws(string $class, callable $execute, string $message = '', ?callable $inspect = null): void + { + try { + $execute(); + } catch (ExpectationFailedException $ex) { + throw $ex; + } catch (Throwable $ex) { + Assert::assertThat($ex, new ConstraintException($class), $message); + + if ($inspect === null) { + return; + } + + $inspect($ex); + } + + Assert::assertThat(null, new ConstraintException($class), $message); + } + + /** + * @param array|Countable $haystack + * + * @noinspection PhpUnused + */ + public static function countAtLeast(int $expectedMinCount, mixed $haystack): void + { + Assert::assertGreaterThanOrEqual($expectedMinCount, count($haystack)); + } + + public static function stringIsInt(string $actual, string $message = ''): int + { + Assert::assertNotFalse(filter_var($actual, FILTER_VALIDATE_INT), $message); + return intval($actual); + } + + /** + * @param array $actual + */ + public static function arrayIsList(array $actual): void + { + Assert::assertTrue(array_is_list($actual)); + } + + /** @noinspection PhpUnused */ + public static function sameEx(mixed $expected, mixed $actual, string $message = ''): void + { + $isNumeric = function (mixed $value): bool { + return is_float($value) || is_int($value); + }; + + if ($isNumeric($expected) && $isNumeric($actual) && (is_float($expected) !== is_float($actual))) { + /** @var int|float $expected */ + /** @var int|float $actual */ + Assert::assertSame(floatval($expected), floatval($actual), $message); + } else { + Assert::assertSame($expected, $actual, $message); + } + } + + /** + * @param Countable|array $haystack + * + * @noinspection PhpUnused + */ + public static function countableNotEmpty(Countable|array $haystack): void + { + self::countAtLeast(1, $haystack); + } + + /** @noinspection PhpUnused */ + public static function equalRecursively(mixed $expected, mixed $actual): void + { + if (is_array($actual)) { + Assert::assertIsArray($expected); + self::equalMaps($expected, $actual); + } else { + Assert::assertEquals($expected, $actual); + } + } + + /** + * @param mixed[] $expected + * @param mixed[] $actual + */ + public static function equalLists(array $expected, array $actual): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + Assert::assertSame(count($expected), count($actual)); + foreach (RangeUtil::generateUpTo(count($expected)) as $i) { + $dbgCtx->add(compact('i')); + Assert::assertSame($expected[$i], $actual[$i]); + } + } + + /** + * @param array $subSet + * @param array $largerSet + */ + public static function listIsSubsetOf(array $subSet, array $largerSet): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + + $subSetCount = count($subSet); + $dbgCtx->add(compact('subSetCount')); + $largerSetCount = count($largerSet); + $dbgCtx->add(compact('largerSetCount')); + $inSubSetButNotInLarger = array_diff($subSet, $largerSet); + $dbgCtx->add(compact('inSubSetButNotInLarger')); + $intersection = array_intersect($subSet, $largerSet); + $dbgCtx->add(compact('intersection')); + $intersectionCount = count($intersection); + $dbgCtx->add(compact('intersectionCount')); + + Assert::assertSame(count(array_intersect($subSet, $largerSet)), count($subSet)); + } + + /** + * @param array $subSet + * @param array $largerSet + * + * @noinspection PhpUnused + */ + public static function mapArrayIsSubsetOf(array $subSet, array $largerSet): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + foreach ($subSet as $key => $value) { + $dbgCtx->add(compact('key', 'value')); + Assert::assertArrayHasKey($key, $largerSet); + self::sameEx($value, $largerSet[$key]); + } + } + + /** + * @param array|Countable $haystack + * + * @noinspection PhpUnused + */ + public static function countAtMost(int $expectedMaxCount, mixed $haystack): void + { + Assert::assertLessThanOrEqual($expectedMaxCount, count($haystack)); + } + + /** + * @template T + * + * @param Optional $expected + * @param T $actual + * + * @noinspection PhpUnused + */ + public static function sameAsExpectedOptionalIfSet(Optional $expected, mixed $actual): void + { + if ($expected->isValueSet()) { + Assert::assertSame($expected->getValue(), $actual); + } + } + + public static function lessThanOrEqualTimestamp(float $before, float $after): void + { + DebugContext::getCurrentScope(/* out */ $dbgCtx); + $dbgCtx->add(['before' => TimeUtil::timestampToLoggable($before)]); + $dbgCtx->add(['after' => TimeUtil::timestampToLoggable($after)]); + $dbgCtx->add(['after - before' => TimeUtil::timestampToLoggable($after - $before)]); + Assert::assertThat($before, Assert::logicalOr(new IsEqual($after, /* delta: */ self::TIMESTAMP_COMPARISON_PRECISION_MICROSECONDS), new LessThan($after))); + } + + /** @noinspection PhpUnused */ + public static function timestampInRange(float $pastTimestamp, float $timestamp, float $futureTimestamp): void + { + self::lessThanOrEqualTimestamp($pastTimestamp, $timestamp); + self::lessThanOrEqualTimestamp($timestamp, $futureTimestamp); + } + + /** + * @phpstan-assert int|float $actual + * + * @noinspection PhpUnused + */ + public static function isNumber(mixed $actual): void + { + Assert::assertThat($actual, Assert::logicalOr(new IsType(IsType::TYPE_INT), new IsType(IsType::TYPE_FLOAT))); + } + + /** + * @param mixed[] $expected + * @param mixed[] $actual + */ + public static function equalAsSets(array $expected, array $actual, string $message = ''): void + { + sort(/* ref */ $expected); + sort(/* ref */ $actual); + Assert::assertEqualsCanonicalizing($expected, $actual, $message); + } + + /** + * @param array $expected + * @param array $actual + * + * @noinspection PhpUnused + */ + public static function arraysHaveTheSameContent(array $expected, array $actual): void + { + Assert::assertSame(count($expected), count($actual)); + foreach ($expected as $expectedKey => $expectedVal) { + self::arrayHasKeyWithSameValue($expectedKey, $expectedVal, $actual); + } + } + + /** + * @template T + * + * @param iterable $expected + * @param iterable $actual + */ + public static function sameValuesListIterables(iterable $expected, iterable $actual): void + { + $expectedIterator = IterableUtil::iterableToIterator($expected); + $expectedIterator->rewind(); + $actualIterator = IterableUtil::iterableToIterator($actual); + $actualIterator->rewind(); + + while ($expectedIterator->valid()) { + Assert::assertTrue($actualIterator->valid()); + Assert::assertSame($expectedIterator->current(), $actualIterator->current()); + $expectedIterator->next(); + $actualIterator->next(); + } + Assert::assertFalse($actualIterator->valid()); + } + + /** + * @template T of int|float + * + * @phpstan-param T $rangeBegin + * @phpstan-param T $actual + * @phpstan-param T $rangeInclusiveEnd + */ + public static function inClosedRange(int|float $rangeBegin, int|float $actual, int|float $rangeInclusiveEnd): void + { + Assert::assertTrue(RangeUtil::isInClosedRange($rangeBegin, $actual, $rangeInclusiveEnd)); + } + + /** + * @param array|Countable $container + * + * @phpstan-assert non-negative-int $index + */ + public static function isValidIndexOf(int $index, array|Countable $container): void + { + Assert::assertTrue(RangeUtil::isValidIndexOfCountable($index, count($container))); + } +} diff --git a/tests/ElasticOTelTests/Util/BoolUtil.php b/tests/ElasticOTelTests/Util/BoolUtil.php new file mode 100644 index 0000000..2d55487 --- /dev/null +++ b/tests/ElasticOTelTests/Util/BoolUtil.php @@ -0,0 +1,69 @@ + + */ + public static function allValuesStartingFrom(bool $startingValue): array + { + return [$startingValue, !$startingValue]; + } +} diff --git a/tests/ElasticOTelTests/Util/CallerInfo.php b/tests/ElasticOTelTests/Util/CallerInfo.php new file mode 100644 index 0000000..ea9bfce --- /dev/null +++ b/tests/ElasticOTelTests/Util/CallerInfo.php @@ -0,0 +1,51 @@ +file = LoggableStackTrace::adaptSourceCodeFilePath($file); + $this->line = $line; + $this->class = $class; + $this->function = $function; + } +} diff --git a/tests/ElasticOTelTests/Util/CharSetForTests.php b/tests/ElasticOTelTests/Util/CharSetForTests.php new file mode 100644 index 0000000..59354ea --- /dev/null +++ b/tests/ElasticOTelTests/Util/CharSetForTests.php @@ -0,0 +1,139 @@ + + * + * @noinspection PhpUnused + */ +final class CharSetForTests implements IteratorAggregate +{ + private static ?CharSetForTests $digits = null; + private static ?CharSetForTests $lowerCaseLetters = null; + private static ?CharSetForTests $lowerCaseLettersAndDigits = null; + /** @var Set */ + private Set $chars; + + public static function digits(): CharSetForTests + { + if (self::$digits === null) { + self::$digits = new CharSetForTests(); + self::$digits->addCharRange('0', '9'); + } + return self::$digits; + } + + public static function lowerCaseLetters(): CharSetForTests + { + if (self::$lowerCaseLetters === null) { + self::$lowerCaseLetters = new CharSetForTests(); + self::$lowerCaseLetters->addCharRange('a', 'z'); + } + return self::$lowerCaseLetters; + } + + /** @noinspection PhpUnused */ + public static function lowerCaseLettersAndDigits(): CharSetForTests + { + if (self::$lowerCaseLettersAndDigits === null) { + self::$lowerCaseLettersAndDigits = new CharSetForTests(); + self::$lowerCaseLettersAndDigits->addCharSet(self::digits()); + self::$lowerCaseLettersAndDigits->addCharSet(self::lowerCaseLetters()); + } + return self::$lowerCaseLettersAndDigits; + } + + public function __construct() + { + $this->chars = new Set(); + } + + public function addChar(string $char): void + { + Assert::assertSame(1, strlen($char), $char); + $this->chars->add($char); + } + + public function addCharRange(string $first, string $last): void + { + Assert::assertSame(1, strlen($first), $first); + Assert::assertSame(1, strlen($last), $last); + $firstCodePoint = ord($first); + $lastCodePoint = ord($last); + Assert::assertGreaterThanOrEqual($firstCodePoint, $lastCodePoint); + foreach (RangeUtil::generateFromToIncluding($firstCodePoint, $lastCodePoint) as $codepoint) { + $this->addChar(chr($codepoint)); + } + } + + public function addCharSet(CharSetForTests $charSet): void + { + foreach ($charSet as $char) { + $this->addChar($char); + } + } + + /** + * @return Traversable + */ + #[Override] + public function getIterator(): Traversable + { + return $this->chars; + } + + /** @noinspection PhpUnused */ + public function getRandom(): string + { + Assert::assertGreaterThan(0, $this->chars->count()); + $randomIndex = mt_rand(0, $this->chars->count() - 1); + return $this->chars->get($randomIndex); + } + + /** + * @param non-negative-int $length + * + * @noinspection PhpUnused + */ + public function generateString(int $length): string + { + Assert::assertGreaterThanOrEqual(0, $length); + $result = ''; + while (true) { + foreach ($this->chars as $char) { + if (strlen($result) === $length) { + return $result; + } + $result .= $char; + } + } + } +} diff --git a/tests/ElasticOTelTests/Util/ClassNameUtil.php b/tests/ElasticOTelTests/Util/ClassNameUtil.php new file mode 100644 index 0000000..18afc4e --- /dev/null +++ b/tests/ElasticOTelTests/Util/ClassNameUtil.php @@ -0,0 +1,77 @@ +file === $other->file) + && ($this->class === $other->class) + && ($this->isStaticMethod === $other->isStaticMethod) + && ($this->function === $other->function) + && ($this->thisObj === $other->thisObj) + ); + } + + #[Override] + public function toLog(LogStreamInterface $stream): void + { + $nonNullProps = []; + foreach ($this as $propName => $propVal) { // @phpstan-ignore foreach.nonIterable + if ($propVal === null || $propName === 'isStaticMethod') { + continue; + } + $nonNullProps[$propName] = $propVal; + } + $stream->toLogAs($nonNullProps); + } +} diff --git a/tests/ElasticOTelTests/Util/Clock.php b/tests/ElasticOTelTests/Util/Clock.php new file mode 100644 index 0000000..ae8a8d3 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Clock.php @@ -0,0 +1,90 @@ +logger = $loggerFactory->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__)->addAllContext(compact('this')); + } + + /** + * @param-out ?float $last + */ + private function checkAgainstUpdateLast(string $dbgSourceDesc, float $current, /* ref */ ?float &$last): float // @phpstan-ignore paramOut.unusedType + { + if ($last !== null) { + if ($current < $last) { + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + 'Detected that clock has jumped backwards' + . ' - returning the later time (i.e., the time further into the future) instead', + [ + 'time source' => $dbgSourceDesc, + 'last as duration' => TimeUtil::formatDurationInMicroseconds($last), + 'current as duration' => TimeUtil::formatDurationInMicroseconds($current), + 'current - last' => TimeUtil::formatDurationInMicroseconds($current - $last), + 'last as number' => number_format($last), + 'current as number' => number_format($current), + ] + ); + return $last; + } + } + $last = $current; + return $current; + } + + /** @inheritDoc */ + #[Override] + public function getSystemClockCurrentTime(): SystemTime + { + // Return value should be in microseconds + // while microtime(as_float: true) returns current Unix timestamp in seconds with microseconds being the fractional part + return new SystemTime($this->checkAgainstUpdateLast('microtime', round(TimeUtil::secondsToMicroseconds(microtime(as_float: true))), /* ref */ $this->lastSystemTime)); + } + + /** @inheritDoc */ + #[Override] + public function getMonotonicClockCurrentTime(): MonotonicTime + { + $hrtimeRetVal = floatval(hrtime(as_number: true)); + return new MonotonicTime($this->checkAgainstUpdateLast('hrtime', round(TimeUtil::nanosecondsToMicroseconds($hrtimeRetVal)), /* ref */ $this->lastMonotonicTime)); + } +} diff --git a/tests/ElasticOTelTests/Util/ClockInterface.php b/tests/ElasticOTelTests/Util/ClockInterface.php new file mode 100644 index 0000000..bdc564f --- /dev/null +++ b/tests/ElasticOTelTests/Util/ClockInterface.php @@ -0,0 +1,46 @@ + $totalSet + * @param int $subSetSize + * + * @return iterable> + */ + public static function permutations(array $totalSet, int $subSetSize): iterable + { + if ($subSetSize > count($totalSet)) { + throw new InvalidArgumentException( + '$subSetSize should not be greater than $totalSet count.' + . ' $totalSet count:' . count($totalSet) . '.' + . ' $subSetSize:' . $subSetSize . '.' + ); + } + + if ($subSetSize < 0) { + throw new InvalidArgumentException( + '$subSetSize should not be negative.' + . ' $subSetSize:' . $subSetSize . '.' + ); + } + + if ($subSetSize === 0) { + yield []; + return; + } + + foreach (RangeUtil::generateUpTo(count($totalSet)) as $firstIndex) { + $newTotalSet = $totalSet; + // remove the first element from $newTotalSet + array_splice(/* ref */ $newTotalSet, $firstIndex, 1); + foreach (self::permutations($newTotalSet, $subSetSize - 1) as $permutation) { + array_unshift(/* ref */ $permutation, $totalSet[$firstIndex]); + yield $permutation; + } + } + } + + /** + * @param array $values + * @param array> $restOfIterables + * + * @return iterable> + */ + private static function cartesianProductImpl(array $values, array $restOfIterables): iterable + { + if (count($restOfIterables) === 0) { + yield $values; + return; + } + + $restOfIterablesForChildCalls = array_slice($restOfIterables, 1); + $currentIterableAsArray = array_slice($restOfIterables, 0, 1); + foreach ($currentIterableAsArray as $currentIterableKey => $currentIterable) { + foreach ($currentIterable as $value) { + yield from self::cartesianProductImpl( + array_merge($values, [$currentIterableKey => $value]), + $restOfIterablesForChildCalls + ); + } + } + } + + /** + * @param array> $iterables + * + * @return iterable> + */ + public static function cartesianProduct(array $iterables): iterable + { + yield from self::cartesianProductImpl([], $iterables); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/BoolOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/BoolOptionMetadata.php new file mode 100644 index 0000000..c43ec18 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/BoolOptionMetadata.php @@ -0,0 +1,39 @@ + + */ +final class BoolOptionMetadata extends OptionWithDefaultValueMetadata +{ + public function __construct(bool $defaultValue) + { + parent::__construct(new BoolOptionParser(), $defaultValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/BoolOptionParser.php b/tests/ElasticOTelTests/Util/Config/BoolOptionParser.php new file mode 100644 index 0000000..6c15b27 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/BoolOptionParser.php @@ -0,0 +1,68 @@ + + */ +final class BoolOptionParser extends EnumOptionParser +{ + /** @var list */ + public static array $trueRawValues = ['true', 'yes', 'on', '1']; + + /** @var list */ + public static array $falseRawValues = ['false', 'no', 'off', '0']; + + /** @var ?list */ + private static ?array $boolNameToValue = null; + + public function __construct() + { + if (self::$boolNameToValue === null) { + self::$boolNameToValue = []; + foreach (self::$trueRawValues as $trueRawValue) { + self::$boolNameToValue[] = [$trueRawValue, true]; + } + foreach (self::$falseRawValues as $falseRawValue) { + self::$boolNameToValue[] = [$falseRawValue, false]; + } + } + + parent::__construct(dbgDesc: 'bool', nameValuePairs: self::$boolNameToValue, isCaseSensitive: false, isUnambiguousPrefixAllowed: false); + } + + /** @inheritDoc */ + #[Override] + public function parse(string $rawValue): bool + { + return TextUtil::isEmptyString($rawValue) ? false : parent::parse($rawValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/CompositeRawSnapshot.php b/tests/ElasticOTelTests/Util/Config/CompositeRawSnapshot.php new file mode 100644 index 0000000..88e4f7a --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/CompositeRawSnapshot.php @@ -0,0 +1,53 @@ + */ + private array $subSnapshots; + + /** + * @param array $subSnapshots + */ + public function __construct(array $subSnapshots) + { + $this->subSnapshots = $subSnapshots; + } + + public function valueFor(string $optionName): ?string + { + foreach ($this->subSnapshots as $subSnapshot) { + if (($value = $subSnapshot->valueFor($optionName)) !== null) { + return $value; + } + } + return null; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/CompositeRawSnapshotSource.php b/tests/ElasticOTelTests/Util/Config/CompositeRawSnapshotSource.php new file mode 100644 index 0000000..6e67e67 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/CompositeRawSnapshotSource.php @@ -0,0 +1,56 @@ + */ + private array $subSources; + + /** + * @param array $subSources + */ + public function __construct(array $subSources) + { + $this->subSources = $subSources; + } + + /** @inheritDoc */ + #[Override] + public function currentSnapshot(array $optionNameToMeta): RawSnapshotInterface + { + $subSnapshots = []; + foreach ($this->subSources as $subSource) { + $subSnapshots[] = $subSource->currentSnapshot($optionNameToMeta); + } + return new CompositeRawSnapshot($subSnapshots); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/ConfigException.php b/tests/ElasticOTelTests/Util/Config/ConfigException.php new file mode 100644 index 0000000..e6a6b56 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/ConfigException.php @@ -0,0 +1,39 @@ + $optNameToParsedValue + */ + public function __construct(array $optNameToParsedValue) + { + self::setPropertiesToValuesFrom($optNameToParsedValue); + } + + public function effectiveLogLevel(): LogLevel + { + $maxFoundLevel = LogLevel::off; + + $keepMaxLevel = function (LogLevel $logLevel) use (&$maxFoundLevel): void { + if ($logLevel->value > $maxFoundLevel->value) { + $maxFoundLevel = $logLevel; + } + }; + + $keepMaxLevel($this->logLevelStderr); + $keepMaxLevel($this->logLevelSyslog); + if ($this->logFile !== null) { + $keepMaxLevel($this->logLevelFile); + } + + return $maxFoundLevel; + } + + /** @noinspection PhpUnused */ + public function isInstrumentationDisabled(string $name): bool + { + if ($this->disabledInstrumentations === null) { + return false; + } + + /** + * @see \OpenTelemetry\SDK\Sdk::isInstrumentationDisabled + * @see \OpenTelemetry\SDK\Sdk::OTEL_PHP_DISABLED_INSTRUMENTATIONS_ALL + */ + return $this->disabledInstrumentations->match('all') || $this->disabledInstrumentations->match($name); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/ConfigSnapshotForTests.php b/tests/ElasticOTelTests/Util/Config/ConfigSnapshotForTests.php new file mode 100644 index 0000000..d7386b6 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/ConfigSnapshotForTests.php @@ -0,0 +1,163 @@ + $optNameToParsedValue + */ + public function __construct(array $optNameToParsedValue) + { + self::setPropertiesToValuesFrom($optNameToParsedValue); + + $this->validateFileExistsIfSet(OptionForTestsName::app_code_php_exe); + $this->validateFileExistsIfSet(OptionForTestsName::app_code_bootstrap_php_part_file); + $this->validateFileExistsIfSet(OptionForTestsName::app_code_ext_binary); + } + + public function appCodeHostKind(): AppCodeHostKind + { + return AssertEx::notNull($this->appCodeHostKind); + } + + public function dataPerProcess(): TestInfraDataPerProcess + { + return AssertEx::notNull($this->dataPerProcess); + } + + public function dataPerRequest(): TestInfraDataPerRequest + { + return AssertEx::notNull($this->dataPerRequest); + } + + public function isEnvVarToPassThrough(string $envVarName): bool + { + if ($this->envVarsToPassThrough === null) { + return false; + } + + return $this->envVarsToPassThrough->match($envVarName) !== null; + } + + public function isSmoke(): bool + { + return $this->group === 'smoke'; + } + + public function escalatedRerunsProdCodeLogLevelOptionName(): ?OptionForProdName + { + if ($this->escalatedRerunsProdCodeLogLevelOptionName === null) { + return null; + } + + /** @var ?OptionForProdName $result */ + static $result = null; + + if ($result === null) { + $result = OptionForProdName::findByName($this->escalatedRerunsProdCodeLogLevelOptionName); + } + return $result; + } + + private function validateNotNullOption(OptionForTestsName $optName): void + { + $propertyName = TextUtil::snakeToCamelCase($optName->name); + $propertyValue = $this->$propertyName; + if ($propertyValue === null) { + $envVarName = $optName->toEnvVarName(); + $allEnvVars = EnvVarUtilForTests::getAll(); + ksort(/* ref */ $allEnvVars); + throw new ConfigException(ExceptionUtil::buildMessage('Mandatory option is not set (snapshot property value is null)', compact('optName', 'envVarName', 'allEnvVars'))); + } + } + + private function validateFileExistsIfSet(OptionForTestsName $optName): void + { + $propertyName = TextUtil::snakeToCamelCase($optName->name); + $propertyValue = $this->$propertyName; + if ($propertyValue !== null) { + Assert::assertIsString($propertyValue); + if (!file_exists($propertyValue)) { + $envVarName = $optName->toEnvVarName(); + throw new ConfigException( + ExceptionUtil::buildMessage('Option for a file path is set but it points to a file that does not exist', compact('optName', 'envVarName', 'propertyValue')) + ); + } + } + } + + public function validateForComponentTests(): void + { + $this->validateNotNullOption(OptionForTestsName::app_code_host_kind); + } + + public function validateForSpawnedProcess(): void + { + $this->validateNotNullOption(OptionForTestsName::data_per_process); + } + + public function validateForAppCode(): void + { + $this->validateForSpawnedProcess(); + $this->validateNotNullOption(OptionForTestsName::app_code_host_kind); + } + + public function validateForAppCodeRequest(): void + { + $this->validateForAppCode(); + $this->validateNotNullOption(OptionForTestsName::data_per_request); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/CustomOptionParser.php b/tests/ElasticOTelTests/Util/Config/CustomOptionParser.php new file mode 100644 index 0000000..487ad6f --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/CustomOptionParser.php @@ -0,0 +1,51 @@ + + */ +final class CustomOptionParser extends OptionParser +{ + /** + * @param Closure(string): T $parseFunc + */ + public function __construct(private readonly Closure $parseFunc) + { + } + + /** @inheritDoc */ + #[Override] + public function parse(string $rawValue): mixed + { + return ($this->parseFunc)($rawValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/DurationOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/DurationOptionMetadata.php new file mode 100644 index 0000000..7835e60 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/DurationOptionMetadata.php @@ -0,0 +1,42 @@ + + */ +final class DurationOptionMetadata extends OptionWithDefaultValueMetadata +{ + public function __construct(?Duration $minValidValue, ?Duration $maxValidValue, DurationUnit $defaultUnit, Duration $defaultValue) + { + parent::__construct(new DurationOptionParser($minValidValue, $maxValidValue, $defaultUnit), $defaultValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/DurationOptionParser.php b/tests/ElasticOTelTests/Util/Config/DurationOptionParser.php new file mode 100644 index 0000000..d98930a --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/DurationOptionParser.php @@ -0,0 +1,87 @@ + + */ +final class DurationOptionParser extends OptionParser +{ + public function __construct( + public readonly ?Duration $minValidValue, + public readonly ?Duration $maxValidValue, + public readonly DurationUnit $defaultUnits + ) { + } + + /** @inheritDoc */ + #[Override] + public function parse(string $rawValue): Duration + { + $partWithoutSuffix = ''; + $units = $this->defaultUnits; + self::splitToValueAndUnits($rawValue, /* ref */ $partWithoutSuffix, /* ref */ $units); + + $auxFloatOptionParser = new FloatOptionParser(null /* minValidValue */, null /* maxValidValue */); + $parsedValue = new Duration($auxFloatOptionParser->parse($partWithoutSuffix), $units); + + if ( + (($this->minValidValue !== null) && ($this->minValidValue->compare($parsedValue) > 0)) + || + (($this->maxValidValue !== null) && ($this->maxValidValue->compare($parsedValue) < 0)) + ) { + throw new ParseException( + ExceptionUtil::buildMessage( + 'Value is not in range between the valid minimum and maximum values', + array_merge(compact('rawValue', 'parsedValue'), ['minValidValue' => $this->minValidValue, 'maxValidValue' => $this->maxValidValue]) + ) + ); + } + + return $parsedValue; + } + + private static function splitToValueAndUnits(string $rawValue, string &$partWithoutSuffix, DurationUnit &$units): void + { + foreach (DurationUnit::cases() as $durationUnit) { + $suffix = $durationUnit->name; + if (TextUtil::isSuffixOf($suffix, $rawValue, isCaseSensitive: false)) { + $partWithoutSuffix = trim(substr($rawValue, 0, -strlen($suffix))); + $units = $durationUnit; + return; + } + } + $partWithoutSuffix = $rawValue; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/EnumOptionParser.php b/tests/ElasticOTelTests/Util/Config/EnumOptionParser.php new file mode 100644 index 0000000..fbd91da --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/EnumOptionParser.php @@ -0,0 +1,140 @@ + + */ +class EnumOptionParser extends OptionParser +{ + /** + * We are forced to use list-array of pairs instead of regular associative array + * because in an associative array if the key is numeric string it's automatically converted to int + * (see https://www.php.net/manual/en/language.types.array.php) + * + * @param list $nameValuePairs + */ + public function __construct( + private readonly string $dbgDesc, + private readonly array $nameValuePairs, + private readonly bool $isCaseSensitive, + private readonly bool $isUnambiguousPrefixAllowed + ) { + } + + /** + * @template TEnum of UnitEnum + * + * @param class-string $enumClass + * + * @return self + */ + public static function useEnumCasesNames(string $enumClass, bool $isCaseSensitive, bool $isUnambiguousPrefixAllowed): self + { + $nameValuePairs = []; + foreach ($enumClass::cases() as $enumCase) { + $nameValuePairs[] = [$enumCase->name, $enumCase]; + } + return new self($enumClass, $nameValuePairs, $isCaseSensitive, $isUnambiguousPrefixAllowed); + } + + /** + * @template TEnum of BackedEnum + * * + * @param class-string $enumClass + * + * @return self + */ + public static function useEnumCasesValues(string $enumClass, bool $isCaseSensitive, bool $isUnambiguousPrefixAllowed): self + { + /** @var list $nameValuePairs */ + $nameValuePairs = []; + foreach ($enumClass::cases() as $enumCase) { + Assert::assertIsString($enumCase->value); + $nameValuePairs[] = [$enumCase->value, $enumCase]; + } + return new self($enumClass, $nameValuePairs, $isCaseSensitive, $isUnambiguousPrefixAllowed); + } + + /** + * @return list + */ + public function nameValuePairs(): array + { + return $this->nameValuePairs; + } + + public function isCaseSensitive(): bool + { + return $this->isCaseSensitive; + } + + public function isUnambiguousPrefixAllowed(): bool + { + return $this->isUnambiguousPrefixAllowed; + } + + /** @inheritDoc */ + #[Override] + public function parse(string $rawValue): mixed + { + /** @var ?array{string, T} $foundPair */ + $foundPair = null; + foreach ($this->nameValuePairs as $currentPair) { + if (TextUtil::isPrefixOf($rawValue, $currentPair[0], $this->isCaseSensitive)) { + if (strlen($currentPair[0]) === strlen($rawValue)) { + return $currentPair[1]; + } + + if (!$this->isUnambiguousPrefixAllowed) { + continue; + } + + if ($foundPair != null) { + throw new ParseException(ExceptionUtil::buildMessage('Not a valid value - it matches more than one entry as a prefix', compact('this', 'rawValue', 'foundPair', 'currentPair'))); + } + $foundPair = $currentPair; + } + } + + if ($foundPair == null) { + throw new ParseException('Not a valid ' . $this->dbgDesc . ' value. Raw option value: `$rawValue\''); + } + + return $foundPair[1]; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/EnvVarsRawSnapshotSource.php b/tests/ElasticOTelTests/Util/Config/EnvVarsRawSnapshotSource.php new file mode 100644 index 0000000..90ed62b --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/EnvVarsRawSnapshotSource.php @@ -0,0 +1,80 @@ + */ + private readonly array $limitToOptionNameToEnvVarName; + + /** + * @param string $envVarNamesPrefix + * @param iterable $limitToOptionNames + */ + public function __construct(string $envVarNamesPrefix, iterable $limitToOptionNames) + { + $limitToOptionNameToEnvVarName = []; + foreach ($limitToOptionNames as $optName) { + $envVarName = self::optionNameToEnvVarName($envVarNamesPrefix, $optName); + Assert::assertArrayNotHasKey($envVarName, $limitToOptionNameToEnvVarName); + $limitToOptionNameToEnvVarName[$optName] = $envVarName; + } + $this->limitToOptionNameToEnvVarName = $limitToOptionNameToEnvVarName; + } + + public static function optionNameToEnvVarName(string $envVarNamesPrefix, string $optionName): string + { + return $envVarNamesPrefix . strtoupper($optionName); + } + + /** @inheritDoc */ + #[Override] + public function currentSnapshot(array $optionNameToMeta): RawSnapshotInterface + { + /** @var array $optionNameToEnvVarValue */ + $optionNameToEnvVarValue = []; + + foreach (IterableUtil::keys($optionNameToMeta) as $optionName) { + if (!ArrayUtil::getValueIfKeyExists($optionName, $this->limitToOptionNameToEnvVarName, /* out */ $envVarName)) { + continue; + } + $envVarValue = getenv($envVarName); + if ($envVarValue !== false) { + $optionNameToEnvVarValue[$optionName] = $envVarValue; + } + } + + return new RawSnapshotFromArray($optionNameToEnvVarValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/FloatOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/FloatOptionMetadata.php new file mode 100644 index 0000000..9b3008d --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/FloatOptionMetadata.php @@ -0,0 +1,39 @@ + + */ +final class FloatOptionMetadata extends OptionWithDefaultValueMetadata +{ + public function __construct(?float $minValidValue, ?float $maxValidValue, float $defaultValue) + { + parent::__construct(new FloatOptionParser($minValidValue, $maxValidValue), $defaultValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/FloatOptionParser.php b/tests/ElasticOTelTests/Util/Config/FloatOptionParser.php new file mode 100644 index 0000000..26dbbc3 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/FloatOptionParser.php @@ -0,0 +1,55 @@ + + */ +final class FloatOptionParser extends NumericOptionParser +{ + #[Override] + protected function dbgValueTypeDesc(): string + { + return 'float'; + } + + #[Override] + public static function isValidFormat(string $rawValue): bool + { + return filter_var($rawValue, FILTER_VALIDATE_FLOAT) !== false; + } + + /** @inheritDoc */ + #[Override] + protected function stringToNumber(string $rawValue): float + { + return floatval($rawValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/IniRawSnapshotSource.php b/tests/ElasticOTelTests/Util/Config/IniRawSnapshotSource.php new file mode 100644 index 0000000..242b851 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/IniRawSnapshotSource.php @@ -0,0 +1,85 @@ +iniNamesPrefix = $iniNamesPrefix; + } + + public static function optionNameToIniName(string $iniNamesPrefix, string $optionName): string + { + return $iniNamesPrefix . $optionName; + } + + /** @inheritDoc */ + #[Override] + public function currentSnapshot(array $optionNameToMeta): RawSnapshotInterface + { + /** @var array $optionNameToValue */ + $optionNameToValue = []; + + /** @var array $allOpts */ + $allOpts = ini_get_all(extension: null, details: false); + + foreach ($optionNameToMeta as $optionName => $optionMeta) { + $iniName = self::optionNameToIniName($this->iniNamesPrefix, $optionName); + if (($iniValue = ArrayUtil::getValueIfKeyExistsElse($iniName, $allOpts, null)) !== null) { + /** @var bool|float|int|string $iniValue */ + $optionNameToValue[$optionName] = self::iniValueToString($iniValue); + } + } + + return new RawSnapshotFromArray($optionNameToValue); + } + + /** + * @param bool|float|int|string $iniValue + */ + private static function iniValueToString(mixed $iniValue): string + { + if (is_bool($iniValue)) { + return $iniValue ? 'true' : 'false'; + } + + return strval($iniValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/IntOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/IntOptionMetadata.php new file mode 100644 index 0000000..608787a --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/IntOptionMetadata.php @@ -0,0 +1,39 @@ + + */ +final class IntOptionMetadata extends OptionWithDefaultValueMetadata +{ + public function __construct(?int $minValidValue, ?int $maxValidValue, int $defaultValue) + { + parent::__construct(new IntOptionParser($minValidValue, $maxValidValue), $defaultValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/IntOptionParser.php b/tests/ElasticOTelTests/Util/Config/IntOptionParser.php new file mode 100644 index 0000000..cc737bf --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/IntOptionParser.php @@ -0,0 +1,55 @@ + + */ +final class IntOptionParser extends NumericOptionParser +{ + #[Override] + protected function dbgValueTypeDesc(): string + { + return 'int'; + } + + #[Override] + public static function isValidFormat(string $rawValue): bool + { + return filter_var($rawValue, FILTER_VALIDATE_INT) !== false; + } + + /** @inheritDoc */ + #[Override] + protected function stringToNumber(string $rawValue): int + { + return intval($rawValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/LogLevelOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/LogLevelOptionMetadata.php new file mode 100644 index 0000000..6b62c1b --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/LogLevelOptionMetadata.php @@ -0,0 +1,54 @@ + + */ +final class LogLevelOptionMetadata extends OptionWithDefaultValueMetadata +{ + public function __construct(LogLevel $defaultValue) + { + parent::__construct(self::parserSingleton(), $defaultValue); + } + + /** + * @return EnumOptionParser + */ + public static function parserSingleton(): EnumOptionParser + { + /** @var ?EnumOptionParser $result */ + static $result = null; + if ($result === null) { + $result = EnumOptionParser::useEnumCasesNames(LogLevel::class, isCaseSensitive: false, isUnambiguousPrefixAllowed: true); + } + return $result; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/NullableAppCodeHostKindOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/NullableAppCodeHostKindOptionMetadata.php new file mode 100644 index 0000000..cbbb331 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/NullableAppCodeHostKindOptionMetadata.php @@ -0,0 +1,41 @@ + + */ +final class NullableAppCodeHostKindOptionMetadata extends NullableOptionMetadata +{ + public function __construct() + { + parent::__construct(EnumOptionParser::useEnumCasesValues(AppCodeHostKind::class, isCaseSensitive: false, isUnambiguousPrefixAllowed: false)); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/NullableBoolOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/NullableBoolOptionMetadata.php new file mode 100644 index 0000000..76951a4 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/NullableBoolOptionMetadata.php @@ -0,0 +1,39 @@ + + */ +final class NullableBoolOptionMetadata extends NullableOptionMetadata +{ + public function __construct() + { + parent::__construct(new BoolOptionParser()); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/NullableCustomOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/NullableCustomOptionMetadata.php new file mode 100644 index 0000000..a1497bb --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/NullableCustomOptionMetadata.php @@ -0,0 +1,48 @@ + + */ +final class NullableCustomOptionMetadata extends NullableOptionMetadata +{ + /** + * @param Closure(string): T $parseFunc + */ + public function __construct(Closure $parseFunc) + { + parent::__construct(new CustomOptionParser($parseFunc)); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/NullableIntOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/NullableIntOptionMetadata.php new file mode 100644 index 0000000..eb2cf7b --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/NullableIntOptionMetadata.php @@ -0,0 +1,39 @@ + + */ +final class NullableIntOptionMetadata extends NullableOptionMetadata +{ + public function __construct(?int $minValidValue, ?int $maxValidValue) + { + parent::__construct(new IntOptionParser($minValidValue, $maxValidValue)); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/NullableLogLevelOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/NullableLogLevelOptionMetadata.php new file mode 100644 index 0000000..cad70fc --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/NullableLogLevelOptionMetadata.php @@ -0,0 +1,43 @@ + + * + * @noinspection PhpUnused + */ +final class NullableLogLevelOptionMetadata extends NullableOptionMetadata +{ + public function __construct() + { + parent::__construct(LogLevelOptionMetadata::parserSingleton()); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/NullableOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/NullableOptionMetadata.php new file mode 100644 index 0000000..2a8b5fe --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/NullableOptionMetadata.php @@ -0,0 +1,67 @@ + + */ +abstract class NullableOptionMetadata extends OptionMetadata +{ + /** @var OptionParser */ + private OptionParser $parser; + + /** + * @param OptionParser $parser + */ + public function __construct(OptionParser $parser) + { + $this->parser = $parser; + } + + /** @inheritDoc */ + #[Override] + public function parser(): OptionParser + { + return $this->parser; + } + + /** + * @inheritDoc + * + * @return null + */ + #[Override] + public function defaultValue(): mixed + { + return null; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/NullableStringOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/NullableStringOptionMetadata.php new file mode 100644 index 0000000..cc16837 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/NullableStringOptionMetadata.php @@ -0,0 +1,39 @@ + + */ +final class NullableStringOptionMetadata extends NullableOptionMetadata +{ + public function __construct() + { + parent::__construct(new StringOptionParser()); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/NullableWildcardListOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/NullableWildcardListOptionMetadata.php new file mode 100644 index 0000000..0627fa7 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/NullableWildcardListOptionMetadata.php @@ -0,0 +1,41 @@ + + */ +final class NullableWildcardListOptionMetadata extends NullableOptionMetadata +{ + public function __construct() + { + parent::__construct(new WildcardListOptionParser()); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/NumericOptionParser.php b/tests/ElasticOTelTests/Util/Config/NumericOptionParser.php new file mode 100644 index 0000000..a663162 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/NumericOptionParser.php @@ -0,0 +1,101 @@ + + */ +abstract class NumericOptionParser extends OptionParser +{ + /** + * @phpstan-param ?TParsedValue $minValidValue + * @phpstan-param ?TParsedValue $maxValidValue + */ + public function __construct( + private readonly null|int|float $minValidValue, + private readonly null|int|float $maxValidValue, + ) { + } + + abstract protected function dbgValueTypeDesc(): string; + + abstract public static function isValidFormat(string $rawValue): bool; + + /** + * @return TParsedValue + */ + abstract protected function stringToNumber(string $rawValue): int|float; + + /** @inheritDoc */ + #[Override] + public function parse(string $rawValue): int|float + { + if (!static::isValidFormat($rawValue)) { + throw new ParseException( + 'Not a valid ' . $this->dbgValueTypeDesc() . " value. Raw option value: `''$rawValue'" + ); + } + + $parsedValue = $this->stringToNumber($rawValue); + + if ( + (($this->minValidValue !== null) && ($parsedValue < $this->minValidValue)) + || (($this->maxValidValue !== null) && ($parsedValue > $this->maxValidValue)) + ) { + throw new ParseException( + 'Value is not in range between the valid minimum and maximum values.' + . ' Raw option value: `' . $rawValue . "'." + . ' Parsed option value: ' . $parsedValue . '.' + . ' The valid minimum value: ' . $this->minValidValue . '.' + . ' The valid maximum value: ' . $this->maxValidValue . '.' + ); + } + + return $parsedValue; + } + + /** + * @phpstan-return ?TParsedValue + */ + public function minValidValue(): null|int|float + { + return $this->minValidValue; + } + + /** + * @phpstan-return ?TParsedValue + */ + public function maxValidValue(): null|int|float + { + return $this->maxValidValue; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/OptionForProdName.php b/tests/ElasticOTelTests/Util/Config/OptionForProdName.php new file mode 100644 index 0000000..30629cf --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/OptionForProdName.php @@ -0,0 +1,155 @@ + + */ + private static function getEnvVarNamePrefixToOptionNames(): array + { + $otelPrefix = [ + self::exporter_otlp_endpoint, + ]; + + $otelPhpPrefix = [ + self::autoload_enabled, + self::disabled_instrumentations, + ]; + + $elasticOTelPrefix = [ + self::bootstrap_php_part_file, + self::enabled, + self::log_file, + self::log_level_file, + self::log_level_stderr, + self::log_level_syslog, + self::transaction_span_enabled, + self::transaction_span_enabled_cli, + ]; + + return [ + self::OTEL_ENV_VAR_NAME_PREFIX => $otelPrefix, + self::OTEL_PHP_ENV_VAR_NAME_PREFIX => $otelPhpPrefix, + self::ELASTIC_OTEL_ENV_VAR_NAME_PREFIX => $elasticOTelPrefix, + ]; + } + + /** + * @return list + */ + public static function getEnvVarNamePrefixes(): array + { + /** @var ?list $envVarNamePrefixes */ + static $envVarNamePrefixes = null; + + if ($envVarNamePrefixes === null) { + $envVarNamePrefixes = array_keys(self::getEnvVarNamePrefixToOptionNames()); + } + + return $envVarNamePrefixes; + } + + /** + * @return non-empty-string + */ + public function getEnvVarNamePrefix(): string + { + /** @var ?array $optNameToEnvVarPrefix */ + static $optNameToEnvVarPrefix = null; + + if ($optNameToEnvVarPrefix === null) { + $optNameToEnvVarPrefix = []; + foreach (self::getEnvVarNamePrefixToOptionNames() as $envVarPrefix => $optNames) { + foreach ($optNames as $currentOptNameCase) { + Assert::assertArrayNotHasKey($currentOptNameCase->name, $optNameToEnvVarPrefix); + $optNameToEnvVarPrefix[$currentOptNameCase->name] = $envVarPrefix; + } + } + } + + return $optNameToEnvVarPrefix[$this->name]; + } + + public function toEnvVarName(): string + { + return EnvVarsRawSnapshotSource::optionNameToEnvVarName($this->getEnvVarNamePrefix(), $this->name); + } + + public function isLogLevelRelated(): bool + { + return in_array($this, self::LOG_LEVEL_RELATED, strict: true); + } + + public function isLogRelated(): bool + { + return in_array($this, self::LOG_RELATED, strict: true); + } + + /** + * @return iterable + */ + public static function getAllLogRelated(): iterable + { + return self::LOG_RELATED; + } + + /** + * @return iterable + */ + public static function getAllLogLevelRelated(): iterable + { + return self::LOG_LEVEL_RELATED; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/OptionForTestsName.php b/tests/ElasticOTelTests/Util/Config/OptionForTestsName.php new file mode 100644 index 0000000..36ced20 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/OptionForTestsName.php @@ -0,0 +1,55 @@ +name); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/OptionMetadata.php b/tests/ElasticOTelTests/Util/Config/OptionMetadata.php new file mode 100644 index 0000000..c5ab7ac --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/OptionMetadata.php @@ -0,0 +1,49 @@ + + */ + abstract public function parser(): OptionParser; + + /** + * @return null|TParsedValue + */ + abstract public function defaultValue(): mixed; +} diff --git a/tests/ElasticOTelTests/Util/Config/OptionParser.php b/tests/ElasticOTelTests/Util/Config/OptionParser.php new file mode 100644 index 0000000..6464c6f --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/OptionParser.php @@ -0,0 +1,46 @@ + + */ +abstract class OptionWithDefaultValueMetadata extends OptionMetadata +{ + /** + * @param OptionParser $parser + * @param TParsedValue $defaultValue + */ + public function __construct( + public readonly OptionParser $parser, + public readonly mixed $defaultValue + ) { + } + + /** + * @inheritDoc + * + * @return OptionParser + */ + #[Override] + public function parser(): OptionParser + { + return $this->parser; + } + + /** + * @inheritDoc + * + * @return TParsedValue + */ + #[Override] + public function defaultValue(): mixed + { + return $this->defaultValue; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/OptionsForProdDefaultValues.php b/tests/ElasticOTelTests/Util/Config/OptionsForProdDefaultValues.php new file mode 100644 index 0000000..c271849 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/OptionsForProdDefaultValues.php @@ -0,0 +1,44 @@ +}[] $optNameMetaPairs */ + $optNameMetaPairs = [ + [OptionForProdName::autoload_enabled, new BoolOptionMetadata(false)], + [OptionForProdName::bootstrap_php_part_file, new NullableStringOptionMetadata()], + [OptionForProdName::disabled_instrumentations, new NullableWildcardListOptionMetadata()], + [OptionForProdName::enabled, new BoolOptionMetadata(true)], + [OptionForProdName::exporter_otlp_endpoint, new NullableStringOptionMetadata()], + [OptionForProdName::log_file, new NullableStringOptionMetadata()], + [OptionForProdName::log_level_file, new LogLevelOptionMetadata(OptionsForProdDefaultValues::LOG_LEVEL_FILE)], + [OptionForProdName::log_level_stderr, new LogLevelOptionMetadata(OptionsForProdDefaultValues::LOG_LEVEL_STDERR)], + [OptionForProdName::log_level_syslog, new LogLevelOptionMetadata(OptionsForProdDefaultValues::LOG_LEVEL_SYSLOG)], + [OptionForProdName::transaction_span_enabled, new BoolOptionMetadata(OptionsForProdDefaultValues::TRANSACTION_SPAN_ENABLED)], + [OptionForProdName::transaction_span_enabled_cli, new BoolOptionMetadata(OptionsForProdDefaultValues::TRANSACTION_SPAN_ENABLED_CLI)], + ]; + $this->optionsNameValueMap = self::convertPairsToMap($optNameMetaPairs, OptionForProdName::cases()); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/OptionsForTestsMetadata.php b/tests/ElasticOTelTests/Util/Config/OptionsForTestsMetadata.php new file mode 100644 index 0000000..75d9a5c --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/OptionsForTestsMetadata.php @@ -0,0 +1,68 @@ +}[] $optNameMetaPairs */ + $optNameMetaPairs = [ + [OptionForTestsName::app_code_host_kind, new NullableAppCodeHostKindOptionMetadata()], + [OptionForTestsName::app_code_php_exe, new NullableStringOptionMetadata()], + [OptionForTestsName::app_code_bootstrap_php_part_file, new NullableStringOptionMetadata()], + [OptionForTestsName::app_code_ext_binary, new NullableStringOptionMetadata()], + [OptionForTestsName::data_per_process, new NullableCustomOptionMetadata($parseTestInfraDataPerProcess)], + [OptionForTestsName::data_per_request, new NullableCustomOptionMetadata($parseTestInfraDataPerRequest)], + [OptionForTestsName::env_vars_to_pass_through, new NullableWildcardListOptionMetadata()], + [OptionForTestsName::escalated_reruns_max_count, new IntOptionMetadata(minValidValue: 0, maxValidValue: null, defaultValue: 10)], + [OptionForTestsName::escalated_reruns_prod_code_log_level_option_name, new NullableStringOptionMetadata()], + [OptionForTestsName::group, new NullableStringOptionMetadata()], + [OptionForTestsName::log_level, new LogLevelOptionMetadata(LogLevel::info)], + ]; + $this->optionsNameValueMap = self::convertPairsToMap($optNameMetaPairs, OptionForTestsName::cases()); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/OptionsMetadataTrait.php b/tests/ElasticOTelTests/Util/Config/OptionsMetadataTrait.php new file mode 100644 index 0000000..ee5e76a --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/OptionsMetadataTrait.php @@ -0,0 +1,74 @@ +> */ + private array $optionsNameValueMap; + + /** + * @template TOptionNameEnum of UnitEnum + + * @param TOptionNameEnum[] $cases + * @param array}> $pairs + * + * @return array> + */ + private static function convertPairsToMap(array $pairs, array $cases): array + { + /** @var array> $result */ + $result = []; + foreach ($pairs as $pair) { + Assert::assertArrayNotHasKey($pair[0]->name, $result); + $result[$pair[0]->name] = $pair[1]; + } + + Assert::assertCount(count($cases), $result); + foreach ($cases as $case) { + Assert::assertArrayHasKey($case->name, $result); + } + + return $result; + } + + /** + * @return array> + */ + public static function get(): array + { + return self::singletonInstance()->optionsNameValueMap; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/ParseException.php b/tests/ElasticOTelTests/Util/Config/ParseException.php new file mode 100644 index 0000000..bc6a11d --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/ParseException.php @@ -0,0 +1,37 @@ +logger = $loggerFactory->loggerForClass(LogCategoryForTests::CONFIG, __NAMESPACE__, __CLASS__, __FILE__); + } + + /** + * @template T + * + * @param string $rawValue + * @param OptionParser $optionParser + * + * @return T + */ + public static function parseOptionRawValue(string $rawValue, OptionParser $optionParser): mixed + { + return $optionParser->parse(trim($rawValue)); + } + + /** + * @param array> $optNameToMeta + * @param RawSnapshotInterface $rawSnapshot + * + * @return array Option name to parsed value + */ + public function parse(array $optNameToMeta, RawSnapshotInterface $rawSnapshot): array + { + $optNameToParsedValue = []; + foreach ($optNameToMeta as $optName => $optMeta) { + $rawValue = $rawSnapshot->valueFor($optName); + if ($rawValue === null) { + $parsedValue = $optMeta->defaultValue(); + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + "Input raw config snapshot doesn't have a value for the option - using default value", + ['Option name' => $optName, 'Option default value' => $optMeta->defaultValue()] + ); + } else { + try { + $parsedValue = self::parseOptionRawValue($rawValue, $optMeta->parser()); + + ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + 'Input raw config snapshot has a value - using parsed value', + ['Option name' => $optName, 'Raw value' => $rawValue, 'Parsed value' => $parsedValue] + ); + } catch (ParseException $ex) { + $parsedValue = $optMeta->defaultValue(); + + ($loggerProxy = $this->logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + "Input raw config snapshot has a value but it's invalid - using default value", + [ + 'Option name' => $optName, + 'Option default value' => $optMeta->defaultValue(), + 'Exception' => $ex, + ] + ); + } + } + $optNameToParsedValue[$optName] = $parsedValue; + } + + return $optNameToParsedValue; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/RawSnapshotFromArray.php b/tests/ElasticOTelTests/Util/Config/RawSnapshotFromArray.php new file mode 100644 index 0000000..d6f4f69 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/RawSnapshotFromArray.php @@ -0,0 +1,50 @@ + */ + private array $optNameToRawValue; + + /** + * @param array $optNameToRawValue + */ + public function __construct(array $optNameToRawValue) + { + $this->optNameToRawValue = $optNameToRawValue; + } + + public function valueFor(string $optionName): ?string + { + return ArrayUtil::getValueIfKeyExistsElse($optionName, $this->optNameToRawValue, null); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/RawSnapshotInterface.php b/tests/ElasticOTelTests/Util/Config/RawSnapshotInterface.php new file mode 100644 index 0000000..048865a --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/RawSnapshotInterface.php @@ -0,0 +1,34 @@ +> $optionNameToMeta + * + * @return RawSnapshotInterface + */ + public function currentSnapshot(array $optionNameToMeta): RawSnapshotInterface; +} diff --git a/tests/ElasticOTelTests/Util/Config/SnapshotTrait.php b/tests/ElasticOTelTests/Util/Config/SnapshotTrait.php new file mode 100644 index 0000000..6a800cf --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/SnapshotTrait.php @@ -0,0 +1,99 @@ + */ + private ?array $optNameToParsedValue = null; + + /** + * @param array $optNameToParsedValue + */ + protected function setPropertiesToValuesFrom(array $optNameToParsedValue): void + { + Assert::assertNull($this->optNameToParsedValue); + + $actualClass = get_called_class(); + foreach ($optNameToParsedValue as $optName => $parsedValue) { + $propertyName = TextUtil::snakeToCamelCase($optName); + if (!property_exists($actualClass, $propertyName)) { + throw new ConfigException("Property `$propertyName' doesn't exist in class " . $actualClass); + } + $this->$propertyName = $parsedValue; + } + + $this->optNameToParsedValue = $optNameToParsedValue; + } + + /** + * @return string[] + */ + protected static function snapshotTraitPropNamesNotForOptions(): array + { + return ['optNameToParsedValue']; + } + + /** + * @return string[] + */ + protected static function additionalPropNamesNotForOptions(): array + { + return []; + } + + /** + * @return string[] + */ + public static function propertyNamesForOptions(): array + { + /** @var ?array $result */ + static $result = null; + if ($result === null) { + $result = array_keys(get_class_vars(get_called_class())); + $propNamesNotForOptions = array_merge(self::snapshotTraitPropNamesNotForOptions(), self::additionalPropNamesNotForOptions()); + Assert::assertSame(count($propNamesNotForOptions), ArrayUtilForTests::removeAllValues(/* in,out */ $result, $propNamesNotForOptions)); + } + return $result; + } + + public function getOptionValueByName(OptionForTestsName $optName): mixed + { + Assert::assertNotNull($this->optNameToParsedValue); + return ArrayUtil::getValueIfKeyExistsElse($optName->name, $this->optNameToParsedValue, null); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/StringOptionMetadata.php b/tests/ElasticOTelTests/Util/Config/StringOptionMetadata.php new file mode 100644 index 0000000..811fe32 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/StringOptionMetadata.php @@ -0,0 +1,41 @@ + + * + * @noinspection PhpUnused + */ +final class StringOptionMetadata extends OptionWithDefaultValueMetadata +{ + public function __construct(string $defaultValue) + { + parent::__construct(new StringOptionParser(), $defaultValue); + } +} diff --git a/tests/ElasticOTelTests/Util/Config/StringOptionParser.php b/tests/ElasticOTelTests/Util/Config/StringOptionParser.php new file mode 100644 index 0000000..0ba1eea --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/StringOptionParser.php @@ -0,0 +1,43 @@ + + */ +final class StringOptionParser extends OptionParser +{ + /** @inheritDoc */ + #[Override] + public function parse(string $rawValue): string + { + return $rawValue; + } +} diff --git a/tests/ElasticOTelTests/Util/Config/WildcardListOptionParser.php b/tests/ElasticOTelTests/Util/Config/WildcardListOptionParser.php new file mode 100644 index 0000000..ab7cf1e --- /dev/null +++ b/tests/ElasticOTelTests/Util/Config/WildcardListOptionParser.php @@ -0,0 +1,58 @@ + + */ +final class WildcardListOptionParser extends OptionParser +{ + /** @inheritDoc */ + #[Override] + public function parse(string $rawValue): WildcardListMatcher + { + return self::staticParse($rawValue); + } + + private static function staticParse(string $rawValue): WildcardListMatcher + { + /** + * @return iterable + */ + $splitWildcardExpr = function () use ($rawValue): iterable { + foreach (explode(',', $rawValue) as $listElementRaw) { + yield trim($listElementRaw); + } + }; + + return new WildcardListMatcher($splitWildcardExpr()); + } +} diff --git a/tests/ElasticOTelTests/Util/DataProviderForTestBuilder.php b/tests/ElasticOTelTests/Util/DataProviderForTestBuilder.php new file mode 100644 index 0000000..ae4a6f8 --- /dev/null +++ b/tests/ElasticOTelTests/Util/DataProviderForTestBuilder.php @@ -0,0 +1,614 @@ +): iterable>> */ + private array $generators = []; + + private ?int $emitOnlyDataSetWithIndex = null; + + private function assertValid(): void + { + Assert::assertSameSize($this->generators, $this->onlyFirstValueCombinable); + } + + /** + * @template T + * + * @param array|callable(): iterable $values + * + * @return callable(): iterable + */ + private static function adaptArrayToMultiUseIterable(mixed $values): callable + { + if (!is_array($values)) { + return $values; + } + + /** + * @return iterable + */ + return function () use ($values): iterable { + return $values; + }; + } + + /** + * @param bool $onlyFirstValueCombinable + * @param callable(array): iterable> $generator + * + * @return $this + */ + public function addGenerator(bool $onlyFirstValueCombinable, callable $generator): self + { + $this->assertValid(); + + $this->onlyFirstValueCombinable[] = $onlyFirstValueCombinable; + $this->generators[] = $generator; + + $this->assertValid(); + return $this; + } + + /** + * @param callable(array $resultSoFar): iterable> $generator + * + * @return $this + * + * @noinspection PhpUnused + */ + public function addGeneratorOnlyFirstValueCombinable(callable $generator): self + { + return $this->addGenerator(/* onlyFirstValueCombinable: */ true, $generator); + } + + /** + * @param callable(array): iterable> $generator + * + * @return $this + * + * @noinspection PhpUnused + */ + public function addGeneratorAllValuesCombinable(callable $generator): self + { + return $this->addGenerator(/* onlyFirstValueCombinable: */ false, $generator); + } + + /** + * @param array|callable(): iterable $values + * + * @return $this + */ + public function addDimension(bool $onlyFirstValueCombinable, array|callable $values): self + { + $this->addGenerator( + $onlyFirstValueCombinable, + /** + * @param array $resultSoFar + * @return iterable> + */ + function (array $resultSoFar) use ($values): iterable { + $expectedKeyForList = 0; + foreach (self::adaptArrayToMultiUseIterable($values)() as $key => $val) { + yield array_merge($resultSoFar, ($key === $expectedKeyForList) ? [$val] : [$key => $val]); + ++$expectedKeyForList; + } + } + ); + return $this; + } + + /** + * @param array|callable(): iterable $values + * + * @return $this + */ + public function addDimensionOnlyFirstValueCombinable(array|callable $values): self + { + return $this->addDimension(/* onlyFirstValueCombinable: */ true, $values); + } + + /** + * @param array|callable(): iterable $values + * + * @return $this + */ + public function addDimensionAllValuesCombinable(array|callable $values): self + { + return $this->addDimension(/* onlyFirstValueCombinable: */ false, $values); + } + + /** + * @param array|callable(): iterable $values + * + * @return $this + */ + public function addKeyedDimension(string $dimensionKey, bool $onlyFirstValueCombinable, array|callable $values): self + { + $this->addGenerator( + $onlyFirstValueCombinable, + /** + * @param array $resultSoFar + * @return iterable> + */ + function (array $resultSoFar) use ($dimensionKey, $values): iterable { + foreach (self::adaptArrayToMultiUseIterable($values)() as $val) { + yield array_merge($resultSoFar, [$dimensionKey => $val]); + } + } + ); + return $this; + } + + /** + * @param array|callable(): iterable $values + * + * @return $this + */ + public function addKeyedDimensionOnlyFirstValueCombinable(string $dimensionKey, array|callable $values): self + { + return $this->addKeyedDimension($dimensionKey, /* onlyFirstValueCombinable: */ true, $values); + } + + /** + * @param array|callable(): iterable $values + * + * @return $this + */ + public function addKeyedDimensionAllValuesCombinable(string $dimensionKey, mixed $values): self + { + return $this->addKeyedDimension($dimensionKey, /* onlyFirstValueCombinable: */ false, $values); + } + + /** + * @return $this + */ + public function addBoolDimension(bool $onlyFirstValueCombinable): self + { + return $this->addDimension($onlyFirstValueCombinable, BoolUtil::ALL_VALUES); + } + + /** + * @return $this + * + * @noinspection PhpUnused + */ + public function addBoolDimensionOnlyFirstValueCombinable(): self + { + return $this->addBoolDimension(onlyFirstValueCombinable: true); + } + + /** + * @return $this + * + * @noinspection PhpUnused + */ + public function addBoolDimensionAllValuesCombinable(): self + { + return $this->addBoolDimension(onlyFirstValueCombinable: false); + } + + /** + * @return $this + */ + public function addBoolKeyedDimension(string $dimensionKey, bool $onlyFirstValueCombinable): self + { + return $this->addKeyedDimension($dimensionKey, $onlyFirstValueCombinable, BoolUtil::ALL_VALUES); + } + + /** + * @return $this + */ + public function addNullableBoolKeyedDimension(string $dimensionKey, bool $onlyFirstValueCombinable): self + { + return $this->addKeyedDimension($dimensionKey, $onlyFirstValueCombinable, BoolUtil::ALL_NULLABLE_VALUES); + } + + /** + * @return $this + */ + public function addBoolKeyedDimensionOnlyFirstValueCombinable(string $dimensionKey): self + { + return $this->addBoolKeyedDimension($dimensionKey, onlyFirstValueCombinable: true); + } + + /** + * @return $this + * + * @noinspection PhpUnused + */ + public function addBoolKeyedDimensionAllValuesCombinable(string $dimensionKey): self + { + return $this->addBoolKeyedDimension($dimensionKey, onlyFirstValueCombinable: false); + } + + /** + * @return $this + */ + public function addNullableBoolKeyedDimensionOnlyFirstValueCombinable(string $dimensionKey): self + { + return $this->addNullableBoolKeyedDimension($dimensionKey, onlyFirstValueCombinable: true); + } + + /** + * @return $this + */ + public function addNullableBoolKeyedDimensionAllValuesCombinable(string $dimensionKey): self + { + return $this->addNullableBoolKeyedDimension($dimensionKey, onlyFirstValueCombinable: false); + } + + /** + * @return $this + * + * @noinspection PhpUnused + */ + public function addSingleValueKeyedDimension(string $dimensionKey, mixed $value): self + { + return $this->addKeyedDimension($dimensionKey, /* onlyFirstValueCombinable: */ true, [$value]); + } + + /** + * @param iterable $iterable + */ + private static function getIterableFirstValue(iterable $iterable): mixed + { + Assert::assertTrue(IterableUtil::getFirstValue($iterable, /* out */ $value)); + return $value; + } + + /** + * @param array $resultSoFar + * + * @return iterable> + */ + private function buildForGenIndex(int $genIndexForAllValues, array $resultSoFar, int $currentGenIndex): iterable + { + Assert::assertLessThanOrEqual(count($this->generators), $currentGenIndex); + if ($currentGenIndex === count($this->generators)) { + yield $resultSoFar; + return; + } + + $currentGen = $this->generators[$currentGenIndex]; + Assert::assertFalse(IterableUtil::isEmpty($currentGen($resultSoFar))); + $iterable = $currentGen($resultSoFar); + $shouldGenAfterFirst = ($currentGenIndex === $genIndexForAllValues) || (!$this->onlyFirstValueCombinable[$currentGenIndex]); + $resultsToGen = $shouldGenAfterFirst ? $iterable : [self::getIterableFirstValue($iterable)]; + $shouldGenFirst = ($genIndexForAllValues === 0) || ($currentGenIndex !== $genIndexForAllValues); + $resultsToGen = $shouldGenFirst ? $resultsToGen : IterableUtil::skipFirst($resultsToGen); + + foreach ($resultsToGen as $resultSoFarPlusCurrent) { + /** @var array $resultSoFarPlusCurrent */ + yield from $this->buildForGenIndex($genIndexForAllValues, $resultSoFarPlusCurrent, $currentGenIndex + 1); + } + } + + /** + * @param array> $iterables + * + * @return callable(array $resultSoFar): iterable> $generator + */ + private static function cartesianProduct(array $iterables): callable + { + /** + * @param array $resultSoFar + * + * @return iterable> + */ + return function (array $resultSoFar) use ($iterables): iterable { + $cartesianProduct = CombinatorialUtil::cartesianProduct($iterables); + foreach ($cartesianProduct as $cartesianProductRow) { + yield array_merge($resultSoFar, $cartesianProductRow); + } + }; + } + + /** + * @param array> $iterables + * + * @return $this + */ + public function addCartesianProduct(bool $onlyFirstValueCombinable, array $iterables): self + { + return $this->addGenerator($onlyFirstValueCombinable, self::cartesianProduct($iterables)); + } + + /** + * @param array> $iterables + * + * @return $this + */ + public function addCartesianProductOnlyFirstValueCombinable(array $iterables): self + { + return $this->addCartesianProduct(/* onlyFirstValueCombinable: */ true, $iterables); + } + + /** + * @param array> $iterables + * + * @return $this + * + * @noinspection PhpUnused + */ + public function addCartesianProductAllValuesCombinable(array $iterables): self + { + return $this->addCartesianProduct(/* onlyFirstValueCombinable: */ false, $iterables); + } + + /** + * @param string $masterSwitchKey + * @param array>|callable(): iterable> $combinationsForEnabled + * @param array>|callable(): iterable> $combinationsForDisabled + * + * @return callable(array): iterable> + * + * @noinspection PhpUnused + */ + public static function masterSwitchCombinationsGenerator(string $masterSwitchKey, mixed $combinationsForEnabled, mixed $combinationsForDisabled): callable + { + /** + * @param array $resultSoFar + * + * @return iterable> + */ + return function (array $resultSoFar) use ($masterSwitchKey, $combinationsForEnabled, $combinationsForDisabled): iterable { + foreach (self::adaptArrayToMultiUseIterable($combinationsForEnabled)() as $combination) { + yield array_merge([$masterSwitchKey => true], array_merge($combination, $resultSoFar)); + } + foreach (self::adaptArrayToMultiUseIterable($combinationsForDisabled)() as $combination) { + yield array_merge([$masterSwitchKey => false], array_merge($combination, $resultSoFar)); + } + }; + } + + /** + * @param string $dimensionKey + * @param bool $onlyFirstValueCombinable + * @param string $prevDimensionKey + * @param mixed $prevDimensionTrueValue + * @param iterable $iterableForTrue + * @param iterable $iterableForFalse + * + * @return $this + */ + public function addConditionalKeyedDimension( + string $dimensionKey, + bool $onlyFirstValueCombinable, + string $prevDimensionKey, + mixed $prevDimensionTrueValue, + iterable $iterableForTrue, + iterable $iterableForFalse + ): self { + return $this->addGenerator( + $onlyFirstValueCombinable, + /** + * @param array $resultSoFar + * + * @return iterable> + */ + function (array $resultSoFar) use ($dimensionKey, $prevDimensionKey, $prevDimensionTrueValue, $iterableForTrue, $iterableForFalse): iterable { + $iterable = $resultSoFar[$prevDimensionKey] === $prevDimensionTrueValue ? $iterableForTrue : $iterableForFalse; + foreach ($iterable as $value) { + yield array_merge([$dimensionKey => $value], $resultSoFar); + } + } + ); + } + + /** + * @param string $dimensionKey + * @param string $prevDimensionKey + * @param mixed $prevDimensionTrueValue + * @param iterable $iterableForTrue + * @param iterable $iterableForFalse + * + * @return $this + * + * @noinspection PhpUnused + */ + public function addConditionalKeyedDimensionOnlyFirstValueCombinable( + string $dimensionKey, + string $prevDimensionKey, + mixed $prevDimensionTrueValue, + iterable $iterableForTrue, + iterable $iterableForFalse + ): self { + return $this->addConditionalKeyedDimension($dimensionKey, /* onlyFirstValueCombinable: */ true, $prevDimensionKey, $prevDimensionTrueValue, $iterableForTrue, $iterableForFalse); + } + + /** + * @param string $dimensionKey + * @param string $prevDimensionKey + * @param mixed $prevDimensionTrueValue + * @param iterable $iterableForTrue + * @param iterable $iterableForFalse + * + * @return $this + */ + public function addConditionalKeyedDimensionAllValueCombinable( + string $dimensionKey, + string $prevDimensionKey, + mixed $prevDimensionTrueValue, + iterable $iterableForTrue, + iterable $iterableForFalse + ): self { + return $this->addConditionalKeyedDimension($dimensionKey, /* onlyFirstValueCombinable: */ false, $prevDimensionKey, $prevDimensionTrueValue, $iterableForTrue, $iterableForFalse); + } + + /** + * @return iterable> + */ + public function buildWithoutDataSetName(): iterable + { + $this->assertValid(); + Assert::assertNotEmpty($this->generators); + + for ($genIndexForAllValues = 0; $genIndexForAllValues < count($this->generators); ++$genIndexForAllValues) { + if ($genIndexForAllValues !== 0 && !$this->onlyFirstValueCombinable[$genIndexForAllValues]) { + continue; + } + yield from $this->buildForGenIndex($genIndexForAllValues, resultSoFar: [], currentGenIndex: 0); + } + } + + /** + * @template TKey of array-key + * @template TValue + * + * @param array>|callable(): iterable> $dataSetsSource + * + * @return iterable> + */ + public static function keyEachDataSetWithDbgDesc(array|callable $dataSetsSource, ?int $emitOnlyDataSetWithIndex = null): iterable + { + if (is_array($dataSetsSource)) { + $dataSetsCount = IterableUtil::count($dataSetsSource); + $dataSets = $dataSetsSource; + } else { + $dataSetsCount = IterableUtil::count($dataSetsSource()); + $dataSets = $dataSetsSource(); + } + + $dataSetIndex = 0; + /** @var array $dataSet */ + foreach ($dataSets as $dataSet) { + ++$dataSetIndex; + if ($emitOnlyDataSetWithIndex !== null && $dataSetIndex !== $emitOnlyDataSetWithIndex) { + continue; + } + yield ('#' . $dataSetIndex . ' out of ' . $dataSetsCount . ': ' . LoggableToString::convert($dataSet)) => $dataSet; + } + } + + /** + * @return $this + * + * @noinspection PhpUnused + */ + public function emitOnlyDataSetWithIndex(int $emitOnlyDataSetWithIndex): self + { + $this->emitOnlyDataSetWithIndex = $emitOnlyDataSetWithIndex; + return $this; + } + + /** + * @return iterable> + */ + public function build(): iterable + { + return self::keyEachDataSetWithDbgDesc( + /** + * @return iterable> + */ + function (): iterable { + return $this->buildWithoutDataSetName(); + }, + $this->emitOnlyDataSetWithIndex + ); + } + + /** + * @return iterable + */ + public function buildAsMixedMaps(): iterable + { + return self::convertEachDataSetToMixedMap($this->build()); + } + + /** + * @return callable(): iterable> + * + * @noinspection PhpUnused + */ + public function buildAsMultiUse(): callable + { + /** + * @return iterable> + */ + return function (): iterable { + return $this->build(); + }; + } + + /** + * @param iterable> $dataSets + * + * @return iterable + */ + public static function convertEachDataSetToMixedMap(iterable $dataSets): iterable + { + foreach ($dataSets as $dbgDataSetName => $dataSet) { + yield $dbgDataSetName => [new MixedMap(MixedMap::assertValidMixedMapArray($dataSet))]; + } + } + + /** + * @param callable(): iterable> $dataSetsGenerator + * + * @return iterable + */ + public static function convertEachDataSetToMixedMapAndAddDesc(callable $dataSetsGenerator): iterable + { + return self::convertEachDataSetToMixedMap(self::keyEachDataSetWithDbgDesc($dataSetsGenerator)); + } + + /** + * @param int $count + * + * @return callable(): iterable + */ + public static function rangeUpTo(int $count): callable + { + /** + * @return iterable> + */ + return function () use ($count): iterable { + return RangeUtil::generateUpTo($count); + }; + } + + /** + * @return callable(): iterable + * + * @noinspection PhpUnused + */ + public static function rangeFromToIncluding(int $first, int $last): callable + { + /** + * @return iterable> + */ + return function () use ($first, $last): iterable { + return RangeUtil::generateFromToIncluding($first, $last); + }; + } +} diff --git a/tests/ElasticOTelTests/Util/DebugContext.php b/tests/ElasticOTelTests/Util/DebugContext.php new file mode 100644 index 0000000..a3205b3 --- /dev/null +++ b/tests/ElasticOTelTests/Util/DebugContext.php @@ -0,0 +1,80 @@ + + * @phpstan-type ContextsStack array + * @phpstan-type ConfigOptionName DebugContextConfig::*_OPTION_NAME + * @phpstan-type ConfigStore array + * + * @phpstan-import-type PreProcessMessageCallback from AssertionFailedError + * @phpstan-type StackTraceSegment ListSlice + */ +final class DebugContext +{ + use StaticClassTrait; + + public const THIS_CONTEXT_KEY = 'this'; + + public const TEXT_ADDED_TO_ASSERTION_MESSAGE_PREFIX = 'DebugContext begin'; + public const TEXT_ADDED_TO_ASSERTION_MESSAGE_SUFFIX = 'DebugContext end'; + + public const TEXT_ADDED_TO_ASSERTION_MESSAGE_WHEN_DISABLED = 'DebugContext is DISABLED!'; + + /** + * Out parameter is used instead of return value to make harder to discard the scope object reference + * thus making stay alive until the scope ends + * + * @param ?DebugContextScopeRef &$scopeVar + * @param Context $initialCtx + * + * @param-out DebugContextScopeRef $scopeVar + */ + public static function getCurrentScope(/* out */ ?DebugContextScopeRef &$scopeVar, array $initialCtx = []): void + { + DebugContextSingleton::singletonInstance()->getCurrentScope(/* out */ $scopeVar, $initialCtx, numberOfStackFramesToSkip: 1); + } + + /** + * @return ContextsStack + */ + public static function getContextsStack(): array + { + return DebugContextSingleton::singletonInstance()->getContextsStack(numberOfStackFramesToSkip: 1); + } + + public static function reset(): void + { + DebugContextSingleton::singletonInstance()->reset(); + } + + public static function ensureInited(): void + { + DebugContextSingleton::singletonInstance(); + } +} diff --git a/tests/ElasticOTelTests/Util/DebugContextConfig.php b/tests/ElasticOTelTests/Util/DebugContextConfig.php new file mode 100644 index 0000000..9fa3ef7 --- /dev/null +++ b/tests/ElasticOTelTests/Util/DebugContextConfig.php @@ -0,0 +1,110 @@ + self::ENABLED_DEFAULT_VALUE, + self::USE_DESTRUCTORS_OPTION_NAME => self::USE_DESTRUCTORS_DEFAULT_VALUE, + self::ADD_TO_ASSERTION_MESSAGE_OPTION_NAME => self::ADD_TO_ASSERTION_MESSAGE_DEFAULT_VALUE, + self::AUTO_CAPTURE_THIS_OPTION_NAME => self::AUTO_CAPTURE_THIS_DEFAULT_VALUE, + self::AUTO_CAPTURE_ARGS_OPTION_NAME => self::AUTO_CAPTURE_ARGS_DEFAULT_VALUE, + self::ONLY_ADDED_CONTEXT_OPTION_NAME => self::ONLY_ADDED_CONTEXT_DEFAULT_VALUE, + self::TRIM_VENDOR_FRAMES_OPTION_NAME => self::TRIM_VENDOR_FRAMES_DEFAULT_VALUE, + ]; + + /** + * @return ConfigStore + */ + public static function getCopy(): array + { + return DebugContextSingleton::singletonInstance()->getConfigCopy(); + } + + /** + * @param ConfigStore $config + */ + public static function set(array $config): void + { + DebugContextSingleton::singletonInstance()->setConfig($config); + } + + public static function enabled(?bool $newValue = null): bool + { + return DebugContextSingleton::singletonInstance()->readWriteConfigOption(self::ENABLED_OPTION_NAME, $newValue); + } + + public static function useDestructors(?bool $newValue = null): bool + { + return DebugContextSingleton::singletonInstance()->readWriteConfigOption(self::USE_DESTRUCTORS_OPTION_NAME, $newValue); + } + + public static function addToAssertionMessage(?bool $newValue = null): bool + { + return DebugContextSingleton::singletonInstance()->readWriteConfigOption(self::ADD_TO_ASSERTION_MESSAGE_OPTION_NAME, $newValue); + } + + public static function onlyAddedContext(?bool $newValue = null): bool + { + return DebugContextSingleton::singletonInstance()->readWriteConfigOption(self::ONLY_ADDED_CONTEXT_OPTION_NAME, $newValue); + } + + public static function autoCaptureThis(?bool $newValue = null): bool + { + return DebugContextSingleton::singletonInstance()->readWriteConfigOption(self::AUTO_CAPTURE_THIS_OPTION_NAME, $newValue); + } + + public static function autoCaptureArgs(?bool $newValue = null): bool + { + return DebugContextSingleton::singletonInstance()->readWriteConfigOption(self::AUTO_CAPTURE_ARGS_OPTION_NAME, $newValue); + } + + public static function trimVendorFrames(?bool $newValue = null): bool + { + return DebugContextSingleton::singletonInstance()->readWriteConfigOption(self::TRIM_VENDOR_FRAMES_OPTION_NAME, $newValue); + } +} diff --git a/tests/ElasticOTelTests/Util/DebugContextScope.php b/tests/ElasticOTelTests/Util/DebugContextScope.php new file mode 100644 index 0000000..a9b1b98 --- /dev/null +++ b/tests/ElasticOTelTests/Util/DebugContextScope.php @@ -0,0 +1,167 @@ +context = $initialCtx; + } + + /** + * @param Context $from + * @param Context &$to + * + * @param-out Context $to + */ + public static function appendContext(array $from, /* in,out */ array &$to): void + { + // Remove keys that exist in new context to make the new entry the last in added order + foreach (IterableUtil::keys($from) as $key) { + if (array_key_exists($key, $to)) { + unset($to[$key]); + } + } + + ArrayUtilForTests::append(from: $from, to: $to); + } + + /** + * @param Context $ctx + */ + public function add(array $ctx): void + { + self::appendContext(from: $ctx, to: $this->context); + } + + /** + * @param StackTraceSegment $currentStackTraceTopSegment + * + * @param-out non-negative-int $matchingFrameIndex + * @param-out bool $matchingFrameHasSameLine + * + * @phpstan-assert-if-true non-negative-int $matchingFrameIndex + * @phpstan-assert-if-true bool $matchingFrameHasSameLine + */ + public function syncWithCallStack(ListSlice $currentStackTraceTopSegment, /* out */ ?int &$matchingFrameIndex, /* out */ ?bool &$matchingFrameHasSameLine): bool + { + $thisStackTraceSegmentCount = count($this->stackTraceSegment); + Assert::assertGreaterThan(0, $thisStackTraceSegmentCount); + if ($currentStackTraceTopSegment->count() < $thisStackTraceSegmentCount) { + return false; + } + + $currentCallStackTraceSubSegment = IterableUtil::takeUpTo($currentStackTraceTopSegment, $thisStackTraceSegmentCount); + /** @var int $frameIndex */ + /** @var ClassicFormatStackTraceFrame $thisStackTraceFrame */ + /** @var ClassicFormatStackTraceFrame $currentStackTraceFrame */ + foreach (IterableUtil::zipWithIndex($this->stackTraceSegment, $currentCallStackTraceSubSegment) as [$frameIndex, $thisStackTraceFrame, $currentStackTraceFrame]) { + if (!$thisStackTraceFrame->canBeSameCall($currentStackTraceFrame)) { + return false; + } + Assert::assertLessThan($thisStackTraceSegmentCount, $frameIndex); + // If source code line is different that means that all the scopes up to top of the scopes stack + // are for calls different from the ones on the current calls stack trace + if (($frameIndex !== ($thisStackTraceSegmentCount - 1)) && ($thisStackTraceFrame->line !== $currentStackTraceFrame->line)) { + return false; + } + } + + // $this->stackTraceSegment should not be empty so foreach loop above should iterate at least once + // so $thisStackTraceFrame and $currentStackTraceFrame should be defined + $matchingFrameHasSameLine = ($thisStackTraceFrame->line === $currentStackTraceFrame->line); // @phpstan-ignore variable.undefined, variable.undefined + $thisStackTraceFrame->line = $currentStackTraceFrame->line; // @phpstan-ignore variable.undefined, variable.undefined + $matchingFrameIndex = $thisStackTraceSegmentCount - 1; // @phpstan-ignore paramOut.type + return true; + } + + /** + * @return Context + */ + public function getContext(): array + { + return $this->context; + } + + public function getName(): string + { + $topStackFrame = $this->stackTraceSegment->getLastValue(); + $classMethodPart = ''; + if ($topStackFrame->class !== null) { + $classMethodPart .= $topStackFrame->class; + } + if ($topStackFrame->function !== null) { + if ($classMethodPart !== '') { + $classMethodPart .= '::'; + } + $classMethodPart .= $topStackFrame->function; + } + + $fileLinePart = ''; + if ($topStackFrame->file !== null) { + $fileLinePart .= $topStackFrame->file; + if ($topStackFrame->line !== null) { + $fileLinePart .= ':' . $topStackFrame->line; + } + } + + if ($classMethodPart === '') { + return $fileLinePart; + } + + return $classMethodPart . ' [' . $fileLinePart . ']'; + } + + /** + * @param Context $initialCtx + */ + public function reset(array $initialCtx): void + { + $this->context = $initialCtx; + } + + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs(['stackTraceSegment' => $this->stackTraceSegment, 'context count' => count($this->context)]); + } +} diff --git a/tests/ElasticOTelTests/Util/DebugContextScopeRef.php b/tests/ElasticOTelTests/Util/DebugContextScopeRef.php new file mode 100644 index 0000000..8116586 --- /dev/null +++ b/tests/ElasticOTelTests/Util/DebugContextScopeRef.php @@ -0,0 +1,65 @@ +popThisScope(numberOfStackFramesToSkip: 1); + } + } + + /** + * @param non-negative-int $numberOfStackFramesToSkip + */ + public function popThisScope(int $numberOfStackFramesToSkip = 0): void + { + if ($this->scope === null) { + return; + } + + $this->containingStack->popTopScope($this->scope, $numberOfStackFramesToSkip + 1); + $this->scope->reset(['This scope was pop-ed' => true]); + $this->scope = null; + } + + /** + * @phpstan-param Context $ctx + */ + public function add(array $ctx): void + { + $this->scope?->add($ctx); + } +} diff --git a/tests/ElasticOTelTests/Util/DebugContextSingleton.php b/tests/ElasticOTelTests/Util/DebugContextSingleton.php new file mode 100644 index 0000000..bbd145f --- /dev/null +++ b/tests/ElasticOTelTests/Util/DebugContextSingleton.php @@ -0,0 +1,469 @@ + + * + * @phpstan-import-type PreProcessMessageCallback from AssertionFailedError + */ +final class DebugContextSingleton implements LoggableInterface +{ + use LoggableTrait; + use SingletonInstanceTrait; + + /** @var ConfigStore */ + private array $config = DebugContextConfig::DEFAULT_VALUES; + + /** @var DebugContextScope[] */ + private array $addedContextScopesStack = []; + + private StackTraceUtil $stackTraceUtil; + + private function __construct() + { + $this->stackTraceUtil = new StackTraceUtil(AmbientContextForTests::loggerFactory()); + + $this->applyConfig(); + } + + /** + * @return ConfigStore + */ + public function getConfigCopy(): array + { + return $this->config; + } + + /** + * @param ConfigStore $config + */ + public function setConfig(array $config): void + { + DebugContextSingleton::singletonInstance()->config = $config; + + $this->applyConfig(); + } + + private function applyConfig(): void + { + AssertionFailedError::$preprocessMessage = $this->config[DebugContextConfig::ADD_TO_ASSERTION_MESSAGE_OPTION_NAME] ? $this->addToFailedAssertionMessage(...) : null; + + if (!$this->config[DebugContextConfig::ENABLED_OPTION_NAME]) { + $this->addedContextScopesStack = []; + } + } + + /** + * @phpstan-param ConfigOptionName $optionName + */ + public function readWriteConfigOption(string $optionName, ?bool $newValue = null): bool + { + if ($newValue === null) { + return $this->config[$optionName]; + } + + $oldValue = $this->config[$optionName]; + $this->config[$optionName] = $newValue; + self::applyConfig(); + return $oldValue; + } + + /** + * @param ?DebugContextScopeRef &$scopeVar + * @param Context $initialCtx + * @param non-negative-int $numberOfStackFramesToSkip + * + * @param-out DebugContextScopeRef $scopeVar + */ + public function getCurrentScope(/* out */ ?DebugContextScopeRef &$scopeVar, array $initialCtx, int $numberOfStackFramesToSkip): void + { + Assert::assertNull($scopeVar); + + if (!$this->config[DebugContextConfig::ENABLED_OPTION_NAME]) { + $scopeVar = self::getNoopRefSingleton(); + return; + } + + $stackTrace = $this->captureStackTraceTopFrameLast($numberOfStackFramesToSkip + 1); + + // Do not use the top frame for sync since the top frame should be used for the new (current) scope + $remainingStackTraceTopStartIndex = $this->syncScopesStackWithCallStack((new ListSlice($stackTrace))->withoutSuffix(1)); + // Construct a new stack trace top segment that does include the top frame + $remainingStackTraceTop = new ListSlice($stackTrace, $remainingStackTraceTopStartIndex); + + $newScope = new DebugContextScope($remainingStackTraceTop, $initialCtx); + $this->addedContextScopesStack[] = $newScope; + $scopeVar = new DebugContextScopeRef($this, $newScope); + } + + /** + * @param non-negative-int $numberOfStackFramesToSkip + * + * @return ContextsStack + */ + public function getContextsStack(int $numberOfStackFramesToSkip): array + { + if (!$this->config[DebugContextConfig::ENABLED_OPTION_NAME]) { + return []; + } + + $onlyAddedContext = $this->config[DebugContextConfig::ONLY_ADDED_CONTEXT_OPTION_NAME]; + $stackTrace = $this->captureStackTraceTopFrameLast($numberOfStackFramesToSkip + 1, includeArgs: !$onlyAddedContext && $this->config[DebugContextConfig::AUTO_CAPTURE_ARGS_OPTION_NAME]); + $syncRetVal = $this->syncScopesStackWithCallStack(new ListSlice($stackTrace), returnFrameIndexesForScopes: !$onlyAddedContext); + + /** @var ?non-negative-int $lastNonVendorFrameIndex */ + $lastNonVendorFrameIndex = null; + if ($onlyAddedContext) { + $scopesStack = $this->addedContextScopesStack; + } else { + $frameIndexToScope = []; + /** @var non-empty-list $frameIndexesForScopes */ + $frameIndexesForScopes = $syncRetVal; + foreach (IterableUtil::zipWithIndex($frameIndexesForScopes) as [$scopeIndex, $frameIndex]) { + $frameIndexToScope[$frameIndex] = $this->addedContextScopesStack[$scopeIndex]; + } + + /** @var DebugContextScope[] $scopesStack */ + $scopesStack = []; + $trimVendorFrames = $this->config[DebugContextConfig::TRIM_VENDOR_FRAMES_OPTION_NAME]; + foreach (RangeUtil::generateUpTo(count($stackTrace)) as $stackTraceFrameIndex) { + $stackTraceFrame = $stackTrace[$stackTraceFrameIndex]; + if ($trimVendorFrames) { + $isSourceCodeFileFromVendor = ($stackTraceFrame->file !== null) && self::isSourceCodeFileFromVendor($stackTraceFrame->file); + if ($lastNonVendorFrameIndex === null) { + if ($isSourceCodeFileFromVendor) { + continue; + } + } + if (!$isSourceCodeFileFromVendor) { + $lastNonVendorFrameIndex = count($scopesStack); + } + } + $scopesStack[] = $this->buildScopeForStackTraceFrame($stackTraceFrame, ArrayUtil::getValueIfKeyExistsElse($stackTraceFrameIndex, $frameIndexToScope, null)); + } + if ($trimVendorFrames && !ArrayUtilForTests::isEmpty($scopesStack)) { + Assert::assertNotNull($lastNonVendorFrameIndex); + AssertEx::countAtLeast($lastNonVendorFrameIndex + 1, $scopesStack); + } else { + Assert::assertNull($lastNonVendorFrameIndex); + } + + // Keep one vendor frame above the last non-vendor frame - this vendor frame corresponds to Assert::assertXyz() or Assert::fail() call + if ($lastNonVendorFrameIndex !== null && ArrayUtilForTests::isValidIndexOf($lastNonVendorFrameIndex + 2, $scopesStack)) { + ArrayUtilForTests::popFromIndex($scopesStack, $lastNonVendorFrameIndex + 2); + } + } + + $totalCount = count($scopesStack); + $result = []; + /** @var non-negative-int $scopeIndex */ + /** @var DebugContextScope $scope */ + foreach (IterableUtil::iterateListWithIndex(ArrayUtilForTests::iterateListInReverse($scopesStack)) as [$scopeIndex, $scope]) { + $name = 'Scope ' . ($scopeIndex + 1) . ' out of ' . $totalCount . ': ' . $scope->getName(); + $result[$name] = $scope->getContext(); + } + return $result; + } + + public function reset(): void + { + $this->addedContextScopesStack = []; + } + + /** + * @param non-negative-int $numberOfStackFramesToSkip + */ + private function addToFailedAssertionMessage(AssertionFailedError $exceptionBeingConstructed, string $baseMessage, int $numberOfStackFramesToSkip): string + { + $formattedContextsStack = $this->config[DebugContextConfig::ENABLED_OPTION_NAME] + ? $this->getFormattedContextsStack($exceptionBeingConstructed, $numberOfStackFramesToSkip + 1) + : DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_WHEN_DISABLED; + return $baseMessage . PHP_EOL . + DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_PREFIX . PHP_EOL . + $formattedContextsStack . PHP_EOL . + DebugContext::TEXT_ADDED_TO_ASSERTION_MESSAGE_SUFFIX; + } + + /** + * @param non-negative-int $numberOfStackFramesToSkip + * + * @return ClassicFormatStackTraceFrame[] + */ + private function captureStackTraceTopFrameLast(int $numberOfStackFramesToSkip, bool $includeArgs = false): array + { + // Always capture $this since it's used to determine if two stack trace frames represent the same call + return array_reverse($this->stackTraceUtil->captureInClassicFormat(offset: $numberOfStackFramesToSkip + 1, includeThisObj: true, includeArgs: $includeArgs)); + } + + /** + * @param ListSlice $stackTrace + * + * @return ($returnFrameIndexesForScopes is true ? list : non-negative-int) + */ + private function syncScopesStackWithCallStack(ListSlice $stackTrace, bool $returnFrameIndexesForScopes = false): array|int + { + if ($returnFrameIndexesForScopes) { + $frameIndexesForScopes = []; + } + $popScopesFromIndex = null; + $remainingStackTraceTop = $stackTrace->clone(); + /** @var non-negative-int $scopeIndex */ + /** @var DebugContextScope $scope */ + foreach (IterableUtil::iterateListWithIndex($this->addedContextScopesStack) as [$scopeIndex, $scope]) { + if (!$scope->syncWithCallStack($remainingStackTraceTop, /* out */ $matchingFrameIndex, /* out */ $matchingFrameHasSameLine)) { + $popScopesFromIndex = $scopeIndex; + break; + } + // If source code line is different that means that all the scopes up to top of the scopes stack + // are for calls different from the ones on the current calls stack trace + // so we should pop those scopes as stale + if (!$matchingFrameHasSameLine && ($scopeIndex !== count($this->addedContextScopesStack) - 1)) { + $popScopesFromIndex = $scopeIndex + 1; + break; + } + $remainingStackTraceTop = $remainingStackTraceTop->withoutPrefix($matchingFrameIndex + 1); + if ($returnFrameIndexesForScopes) { + $frameIndexesForScopes[] = $remainingStackTraceTop->offset - 1; + } + } + + if ($popScopesFromIndex !== null) { + Assert::assertLessThan(count($this->addedContextScopesStack), $popScopesFromIndex); + ArrayUtilForTests::popFromIndex(/* in,out */ $this->addedContextScopesStack, $popScopesFromIndex); + } + + /** @var list $frameIndexesForScopes */ + return $returnFrameIndexesForScopes ? $frameIndexesForScopes : $remainingStackTraceTop->offset; // @phpstan-ignore variable.undefined + } + + private function getNoopRefSingleton(): DebugContextScopeRef + { + /** @var ?DebugContextScopeRef $result */ + static $result = null; + + if ($result === null) { + $result = new DebugContextScopeRef($this, null); + } + + return $result; + } + + /** + * @param non-negative-int $numberOfStackFramesToSkip + */ + public function popTopScope(DebugContextScope $scopeToPop, int $numberOfStackFramesToSkip): void + { + // Relevant call stack trace starts from this function caller + $stackTrace = $this->captureStackTraceTopFrameLast($numberOfStackFramesToSkip + 1); + + // Do not use the top frame for sync since the top frame is for the scope that should be pop-ed + $this->syncScopesStackWithCallStack((new ListSlice($stackTrace))); + + /** @var ?non-positive-int $scopeToPopIndex */ + $scopeToPopIndex = null; + foreach (IterableUtil::iterateListWithIndex($this->addedContextScopesStack) as [$currentScopeIndex, $currentScope]) { + if ($currentScope === $scopeToPop) { + $scopeToPopIndex = $currentScopeIndex; + break; + } + } + if ($scopeToPopIndex !== null) { + AssertEx::isValidIndexOf($scopeToPopIndex, $this->addedContextScopesStack); + ArrayUtilForTests::popFromIndex($this->addedContextScopesStack, $scopeToPopIndex); + } + } + + /** + * @return null|ReflectionParameter[] + */ + private static function getReflectionParametersForStackFrame(ClassicFormatStackTraceFrame $frame): ?array + { + if ($frame->function === null) { + return null; + } + + try { + if ($frame->class === null) { + $reflFuc = new ReflectionFunction($frame->function); + return $reflFuc->getParameters(); + } + /** @var class-string $className */ + $className = $frame->class; + $reflClass = new ReflectionClass($className); + $reflMethod = $reflClass->getMethod($frame->function); + return $reflMethod->getParameters(); + } catch (ReflectionException) { + return null; + } + } + + /** + * @return Context + */ + private static function buildFuncArgsNamesToValues(ClassicFormatStackTraceFrame $stackTraceFrame): array + { + if ($stackTraceFrame->args === null || ArrayUtilForTests::isEmpty($stackTraceFrame->args)) { + return []; + } + + $result = []; + $reflParams = self::getReflectionParametersForStackFrame($stackTraceFrame); + foreach (RangeUtil::generateUpTo(count($stackTraceFrame->args)) as $argIndex) { + $argName = $reflParams === null || count($reflParams) <= $argIndex ? ('arg #' . ($argIndex + 1)) : $reflParams[$argIndex]->getName(); + $result[$argName] = $stackTraceFrame->args[$argIndex]; + } + return $result; + } + + private function buildScopeForStackTraceFrame(ClassicFormatStackTraceFrame $stackTraceFrame, ?DebugContextScope $scopeWithAddedContext): DebugContextScope + { + $ctx = []; + + $onlyAddedContext = $this->config[DebugContextConfig::ONLY_ADDED_CONTEXT_OPTION_NAME]; + if (!$onlyAddedContext && $this->config[DebugContextConfig::AUTO_CAPTURE_THIS_OPTION_NAME] && ($stackTraceFrame->thisObj !== null)) { + $ctx[DebugContext::THIS_CONTEXT_KEY] = $stackTraceFrame->thisObj; + } + + if (!$onlyAddedContext && $this->config[DebugContextConfig::AUTO_CAPTURE_ARGS_OPTION_NAME]) { + ArrayUtilForTests::append(from: self::buildFuncArgsNamesToValues($stackTraceFrame), to: $ctx); + } + + if ($scopeWithAddedContext !== null) { + ArrayUtilForTests::append(from: $scopeWithAddedContext->getContext(), to: $ctx); + } + + return new DebugContextScope(new ListSlice([$stackTraceFrame]), $ctx); + } + + /** + * @param non-negative-int $numberOfStackFramesToSkip + * + * @noinspection PhpDocMissingThrowsInspection + */ + private function getFormattedContextsStack(AssertionFailedError $exceptionBeingConstructed, int $numberOfStackFramesToSkip): string + { + /* + * We would like to get the following format: + * + * { + * "Scope 1 of 2: MyClass::func (MyFile.php:133)": { + * "localVar_1": 1, + * "localVar_2": "localVar_2 value", + * "localVar_3": {...}, + * }, + * "Scope 2 of 2: MyClass::func (MyFile.php:123)": { + * "arg_1": 1, + * "arg_2": "arg_2 value", + * "arg_3": {...}, + * "localVar_1": 1, + * "localVar_2": "localVar_2 value", + * "localVar_3": {...}, + * } + * } + * + * namely the whole stack is JSON encoded and pretty printed but each arg/var is printed on one line (i.e., not pretty printed) + * In order to achieve that we will create an auxiliary structure with arg/var values replaced by unique strings then JSON encode and pretty print it: + * + * { + * "Scope 1 of 2: MyClass::func (MyFile.php:133)": { + * "localVar_1": "", + * "localVar_2": "", + * "localVar_3": "", + * }, + * "Scope 2 of 2: MyClass::func (MyFile.php:123)": { + * "arg_1": "", + * "arg_2": "", + * "arg_3": "", + * "localVar_1": "", + * "localVar_2": "", + * "localVar_3": "", + * } + * } + * + * then we JSON encode each arg/var (not pretty printed) and replace the corresponding "" with with JSON encoded arg/var. + */ + $uniqueStrSuffix = ' value ' . uniqid(); + $uniqueStrIndex = 1; + $genUniqueStr = function (string $ctxKey) use ($uniqueStrSuffix, &$uniqueStrIndex): string { + return $ctxKey . $uniqueStrSuffix . ($uniqueStrIndex++); + }; + + /** @var ?stdClass $emptyStdClassInstance */ + static $emptyStdClassInstance = null; + if ($emptyStdClassInstance === null) { + $emptyStdClassInstance = new stdClass(); + } + + $contextsStack = self::getContextsStack($numberOfStackFramesToSkip + 1); + $outerStruct = []; + $uniqueStrToCtxValue = []; + foreach ($contextsStack as $desc => $context) { + $ctxWithValuesReplaced = []; + foreach ($context as $ctxKey => $ctxValue) { + $uniqueStr = $genUniqueStr($ctxKey); + $uniqueStrToCtxValue[$uniqueStr] = $ctxValue; + $ctxWithValuesReplaced[$ctxKey] = $uniqueStr; + } + $outerStruct[$desc] = ArrayUtilForTests::isEmpty($ctxWithValuesReplaced) ? $emptyStdClassInstance : $ctxWithValuesReplaced; + } + + $exceptionBeingConstructedAsJson = JsonUtil::encode(['exception being constructed class' => get_class($exceptionBeingConstructed)]); + $result = JsonUtil::encode($outerStruct, prettyPrint: true); + foreach ($uniqueStrToCtxValue as $uniqueStr => $ctxValue) { + $ctxValueAsJson = $exceptionBeingConstructed === $ctxValue ? $exceptionBeingConstructedAsJson : LoggableToString::convert($ctxValue); + $result = str_replace(JsonUtil::encode($uniqueStr), $ctxValueAsJson, $result); + } + + return $result; + } + + private static function isSourceCodeFileFromVendor(string $filePath): bool + { + /** @var ?string $vendorDirPathPrefix */ + static $vendorDirPathPrefix = null; + if ($vendorDirPathPrefix === null) { + $vendorDirPathPrefix = VendorDir::get() . DIRECTORY_SEPARATOR; + } + + return str_starts_with($filePath, $vendorDirPathPrefix); + } +} diff --git a/tests/ElasticOTelTests/Util/DisableDebugContextTestTrait.php b/tests/ElasticOTelTests/Util/DisableDebugContextTestTrait.php new file mode 100644 index 0000000..b621b33 --- /dev/null +++ b/tests/ElasticOTelTests/Util/DisableDebugContextTestTrait.php @@ -0,0 +1,37 @@ +dummyPublicStringProperty = $dummyPublicStringProperty; + } +} diff --git a/tests/ElasticOTelTests/Util/Duration.php b/tests/ElasticOTelTests/Util/Duration.php new file mode 100644 index 0000000..dfae787 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Duration.php @@ -0,0 +1,66 @@ +toMilliseconds(), $other->toMilliseconds()); + } + + public function equals(Duration $other): bool + { + return $this->toMilliseconds() === $other->toMilliseconds(); + } + + public function toMilliseconds(): float + { + return self::valueToMilliseconds($this->value, $this->unit); + } + + public static function valueToMilliseconds(float $value, DurationUnit $unit): float + { + return $value * $unit->toMillisecondsFactor(); + } + + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs($this->value . $this->unit->name); + } +} diff --git a/tests/ElasticOTelTests/Util/DurationUnit.php b/tests/ElasticOTelTests/Util/DurationUnit.php new file mode 100644 index 0000000..a72862d --- /dev/null +++ b/tests/ElasticOTelTests/Util/DurationUnit.php @@ -0,0 +1,45 @@ + 1, + self::s => 1000, + self::m => 60 * 1000, + }; + } +} diff --git a/tests/ElasticOTelTests/Util/ElasticOTelExtensionUtil.php b/tests/ElasticOTelTests/Util/ElasticOTelExtensionUtil.php new file mode 100644 index 0000000..f4d5e8f --- /dev/null +++ b/tests/ElasticOTelTests/Util/ElasticOTelExtensionUtil.php @@ -0,0 +1,49 @@ + + */ +final class EnvVarUtil +{ + use StaticClassTrait; + + public static function get(string $envVarName): ?string + { + $envVarValue = getenv($envVarName, /* local_only: */ true); + return $envVarValue === false ? null : $envVarValue; + } + + public static function set(string $envVarName, string $envVarValue): void + { + Assert::assertTrue(putenv($envVarName . '=' . $envVarValue)); + Assert::assertSame($envVarValue, self::get($envVarName)); + } + + public static function unset(string $envVarName): void + { + Assert::assertTrue(putenv($envVarName)); + Assert::assertNull(self::get($envVarName)); + } + + public static function setOrUnsetIfValueNull(string $envVarName, ?string $envVarValue): void + { + if ($envVarValue === null) { + self::unset($envVarName); + } else { + self::set($envVarName, $envVarValue); + } + } + + /** + * @return EnvVars + */ + public static function getAll(): array + { + return getenv(); + } +} diff --git a/tests/ElasticOTelTests/Util/ExceptionUtil.php b/tests/ElasticOTelTests/Util/ExceptionUtil.php new file mode 100644 index 0000000..7b04b5c --- /dev/null +++ b/tests/ElasticOTelTests/Util/ExceptionUtil.php @@ -0,0 +1,77 @@ + $context + * @param ?non-negative-int $numberOfStackFramesToSkip PHP_INT_MAX means no stack trace + */ + public static function buildMessage(string $messagePrefix, array $context = [], ?int $numberOfStackFramesToSkip = null): string + { + $messageSuffixObj = new AdhocLoggableObject($context); + if ($numberOfStackFramesToSkip !== null) { + $stacktrace = LoggableStackTrace::buildForCurrent($numberOfStackFramesToSkip + 1); + $messageSuffixObj->addProperties([LoggableStackTrace::STACK_TRACE_KEY => $stacktrace], PropertyLogPriority::MUST_BE_INCLUDED); + } + $messageSuffix = LoggableToString::convert($messageSuffixObj, prettyPrint: true); + return $messagePrefix . (TextUtil::isEmptyString($messageSuffix) ? '' : ('. ' . $messageSuffix)); + } + + /** + * @template TReturnValue + * + * @param callable(): TReturnValue $callableToRun + * + * @return TReturnValue + * + * @noinspection PhpDocMissingThrowsInspection + */ + public static function runCatchLogRethrow(callable $callableToRun): mixed + { + try { + return $callableToRun(); + } catch (Throwable $throwable) { + LogSinkForTests::writeLineToStdErr('Caught throwable: ' . $throwable); + throw $throwable; + } + } +} diff --git a/tests/ElasticOTelTests/Util/ExternalTestData.php b/tests/ElasticOTelTests/Util/ExternalTestData.php new file mode 100644 index 0000000..02fae19 --- /dev/null +++ b/tests/ElasticOTelTests/Util/ExternalTestData.php @@ -0,0 +1,65 @@ +loggerForClass($logCategory, __NAMESPACE__, __CLASS__, __FILE__); + + if ($tempFileFullPath === false) { + ($loggerProxy = $logger->ifCriticalLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->includeStackTrace()->log('Failed to create a temporary file', compact('dbgTempFilePurpose')); + Assert::fail(LoggableToString::convert(compact('dbgTempFilePurpose'))); + } + + ($loggerProxy = $logger->ifTraceLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->includeStackTrace()->log('Created a temporary file', compact('tempFileFullPath', 'dbgTempFilePurpose')); + + return $tempFileFullPath; + } +} diff --git a/tests/ElasticOTelTests/Util/FloatLimits.php b/tests/ElasticOTelTests/Util/FloatLimits.php new file mode 100644 index 0000000..36588a0 --- /dev/null +++ b/tests/ElasticOTelTests/Util/FloatLimits.php @@ -0,0 +1,39 @@ + + */ + public static function getAll(): iterable + { + foreach ($_SERVER as $key => $value) { + yield $key => $value; + } + } + + /** + * @return iterable + * + * @noinspection PhpUnused + */ + public static function getAllRequestHeaders(): iterable + { + $prefixLen = strlen(self::HTTP_REQUEST_HEADER_KEY_PREFIX); + foreach ($_SERVER as $key => $value) { + if (TextUtil::isPrefixOf(self::HTTP_REQUEST_HEADER_KEY_PREFIX, $key)) { + yield substr($key, $prefixLen) => $value; + } + } + } +} diff --git a/tests/ElasticOTelTests/Util/HttpContentTypes.php b/tests/ElasticOTelTests/Util/HttpContentTypes.php new file mode 100644 index 0000000..2d5a5c7 --- /dev/null +++ b/tests/ElasticOTelTests/Util/HttpContentTypes.php @@ -0,0 +1,40 @@ + $iterable + */ + public static function count(iterable $iterable): int + { + if ($iterable instanceof Countable) { + return count($iterable); + } + + $result = 0; + foreach ($iterable as $ignored) { + ++$result; + } + return $result; + } + + /** + * @param iterable $iterable + */ + public static function isEmpty(iterable $iterable): bool + { + /** @noinspection PhpLoopNeverIteratesInspection */ + foreach ($iterable as $ignored) { + return false; + } + return true; + } + + /** + * @template T + * + * @param iterable $iterable + * @param T &$valOut + * @param-out T $valOut + */ + public static function getFirstValue(iterable $iterable, /* out */ mixed &$valOut): bool + { + return self::getNthValue($iterable, /* n: */ 0, /* out */ $valOut); + } + + /** + * @template T + * + * @param iterable $iterable + * @param T &$valOut + * @param-out T $valOut + */ + public static function getNthValue(iterable $iterable, int $n, /* out */ mixed &$valOut): bool + { + Assert::assertGreaterThanOrEqual(0, $n); + $i = 0; + foreach ($iterable as $val) { + if ($i === $n) { + $valOut = $val; + return true; + } + ++$i; + } + return false; + } + + /** + * @template TKey + * @template TValue + * + * @param iterable $iterable + * @param TKey &$keyOut + * @param-out TKey $keyOut + */ + public static function getNthKey(iterable $iterable, int $n, /* out */ mixed &$keyOut): bool + { + Assert::assertGreaterThanOrEqual(0, $n); + $i = 0; + foreach ($iterable as $key => $_) { + if ($i === $n) { + $keyOut = $key; + return true; + } + ++$i; + } + return false; + } + + /** + * @param iterable $iterable + * + * @return iterable + */ + public static function skipFirst(iterable $iterable): iterable + { + $isFirst = true; + foreach ($iterable as $key => $val) { + if ($isFirst) { + $isFirst = false; + continue; + } + yield $key => $val; + } + } + + /** + * @template TValue + * + * @param iterable $iterable + * + * @return array + */ + public static function toList(iterable $iterable): array + { + if (is_array($iterable)) { + return $iterable; + } + + $result = []; + foreach ($iterable as $val) { + $result[] = $val; + } + return $result; + } + + /** + * @template TKey of array-key + * @template TValue + * + * @param iterable $iterable + * + * @return array + * + * @noinspection PhpUnused + */ + public static function toMap(iterable $iterable): array + { + if (is_array($iterable)) { + return $iterable; + } + + /** + * @var array $result + */ + $result = []; + /** @noinspection PhpLoopCanBeConvertedToArrayMapInspection */ + foreach ($iterable as $key => $val) { + $result[$key] = $val; + } + return $result; + } + + /** + * @template T + * + * @param iterable $inputIterable + * + * @return Generator + */ + public static function iterableToGenerator(iterable $inputIterable): Generator + { + foreach ($inputIterable as $val) { + yield $val; + } + } + + /** + * @template T + * + * @param iterable $inputIterable + * + * @return Iterator + */ + public static function iterableToIterator(iterable $inputIterable): Iterator + { + if ($inputIterable instanceof Iterator) { + return $inputIterable; + } + + return self::iterableToGenerator($inputIterable); + } + + /** + * @param iterable $iterables + * + * @return Generator + * + * @noinspection PhpPluralMixedCanBeReplacedWithArrayInspection + */ + public static function zipWithOptionalIndex(bool $withIndex, iterable ...$iterables): Generator + { + $expectedEndTupleCount = $withIndex ? 1 : 0; + $expectedTupleCount = count($iterables) + $expectedEndTupleCount; + $index = 0; + if (ArrayUtilForTests::isEmpty($iterables)) { + return; + } + + /** @var Iterator[] $iterators */ + $iterators = []; + foreach ($iterables as $inputIterable) { + $iterator = self::iterableToIterator($inputIterable); + $iterator->rewind(); + $iterators[] = $iterator; + } + + while (true) { + $tuple = $withIndex ? [$index] : []; + foreach ($iterators as $iterator) { + if ($iterator->valid()) { + $tuple[] = $iterator->current(); + $iterator->next(); + } else { + Assert::assertCount($expectedEndTupleCount, $tuple); + } + } + + if (count($tuple) === $expectedEndTupleCount) { + return; + } + + Assert::assertSame($expectedTupleCount, count($tuple)); + yield $tuple; + ++$index; + } + } + + /** + * @param iterable $iterables + * + * @return Generator + * + * @noinspection PhpPluralMixedCanBeReplacedWithArrayInspection + */ + public static function zipWithIndex(iterable ...$iterables): Generator + { + return self::zipWithOptionalIndex(/* withIndex */ true, ...$iterables); + } + + /** + * @param iterable $iterables + * + * @return Generator + * + * @noinspection PhpPluralMixedCanBeReplacedWithArrayInspection + */ + public static function zip(iterable ...$iterables): Generator + { + return self::zipWithOptionalIndex(/* withIndex */ false, ...$iterables); + } + + /** + * @template TKey + * + * @param iterable $inputMap + * + * @return Generator + */ + public static function keys(iterable $inputMap): Generator + { + foreach ($inputMap as $key => $_) { + yield $key; + } + } + + /** + * @template TKey + * @template TValue + * + * @param iterable $inputSeq + * + * @return iterable + */ + public static function duplicateEachElement(iterable $inputSeq, int $dupCount): iterable + { + foreach ($inputSeq as $key => $value) { + foreach (RangeUtil::generateUpTo($dupCount) as $ignored) { + yield $key => $value; + } + } + } + + /** + * @template TKey + * @template TValue + * + * @param iterable $input1 + * @param iterable $input2 + * + * @return iterable + */ + public static function concat(iterable $input1, iterable $input2): iterable + { + foreach ($input1 as $key => $value) { + yield $key => $value; + } + foreach ($input2 as $key => $value) { + yield $key => $value; + } + } + + /** + * @template TValue + * + * @param TValue $headVal + * @param iterable $tail + * + * @return iterable + */ + public static function prepend($headVal, iterable $tail): iterable + { + yield $headVal; + yield from $tail; + } + + /** + * @template TValue + * + * @param TValue[] $inArray + * @param int $suffixStartIndex + * + * @return iterable + */ + public static function arraySuffix(array $inArray, int $suffixStartIndex): iterable + { + foreach (RangeUtil::generateFromToIncluding($suffixStartIndex, count($inArray) - 1) as $index) { + yield $inArray[$index]; + } + } + + /** + * @template TValue + * + * @param iterable $iterable + * @param non-negative-int $upTo + * + * @return iterable + */ + public static function takeUpTo(iterable $iterable, int $upTo): iterable + { + $index = 0; + foreach ($iterable as $value) { + if ($index >= $upTo) { + return; + } + yield $value; + ++$index; + } + } + + /** + * @template T + * + * @param iterable $iterable + * @param T &$lastValue + * + * @param-out T $lastValue + * + * @noinspection PhpUnused + */ + public static function getLastValue(iterable $iterable, /* out */ mixed &$lastValue): bool + { + $wereThereAnyElements = false; + foreach ($iterable as $value) { + $wereThereAnyElements = true; + $lastValue = $value; + } + return $wereThereAnyElements; + } + + /** + * @template TInputValue + * @template TOutputValue + * + * @param iterable $iterable + * @param callable(TInputValue): TOutputValue $mapFunc + * + * @return iterable + */ + public static function map(iterable $iterable, callable $mapFunc): iterable + { + foreach ($iterable as $val) { + yield $mapFunc($val); + } + } + + /** + * @template TValue of int|float + * + * @param iterable $iterable + * + * @return ?TValue + */ + public static function max(iterable $iterable): mixed + { + /** @var ?TValue $result */ + $result = null; + foreach ($iterable as $val) { + if ($result === null || $result < $val) { + $result = $val; + } + } + return $result; + } + + /** + * @template T + * + * @param iterable $iterable + * + * @return T + */ + public static function singleValue(iterable $iterable): mixed + { + $iterator = self::iterableToIterator($iterable); + $iterator->rewind(); + Assert::assertTrue($iterator->valid()); + $result = $iterator->current(); + $iterator->next(); + Assert::assertFalse($iterator->valid()); + return $result; + } + + /** + * @template T + * + * @param iterable $input + * + * @return iterable + */ + public static function iterateListWithIndex(iterable $input): iterable + { + $index = 0; + foreach ($input as $value) { + yield [$index++, $value]; + } + } + + /** + * @template TValue + * + * @param iterable $iterable + * @param callable(TValue): bool $valuePredicate + * + * @return iterable + */ + public static function findByPredicateOnValue(iterable $iterable, callable $valuePredicate): iterable + { + foreach ($iterable as $value) { + if ($valuePredicate($value)) { + yield $value; + } + } + } +} diff --git a/tests/ElasticOTelTests/Util/JsonUtil.php b/tests/ElasticOTelTests/Util/JsonUtil.php new file mode 100644 index 0000000..7a47f0a --- /dev/null +++ b/tests/ElasticOTelTests/Util/JsonUtil.php @@ -0,0 +1,70 @@ + */ + private array $keyToValue = []; + + /** + * @param non-negative-int $countLowWaterMark + * @param non-negative-int $countHighWaterMark + */ + public function __construct( + private readonly int $countLowWaterMark, + private readonly int $countHighWaterMark, + ) { + Assert::assertGreaterThan($countLowWaterMark, $countHighWaterMark); + } + + /** + * @phpstan-param TKey $key + * @phpstan-param TValue $value + */ + public function put(string|int $key, mixed $value): void + { + $cacheCount = count($this->keyToValue); + if ($cacheCount > $this->countHighWaterMark) { + // Keep the last countLowWaterMark entries + $this->keyToValue = array_slice(array: $this->keyToValue, offset: $cacheCount - $this->countLowWaterMark); + } + + // Remove the key if it already exists to make the new entry the last in added order + if (array_key_exists($key, $this->keyToValue)) { + unset($this->keyToValue[$key]); + } + $this->keyToValue[$key] = $value; + } + + /** + * @phpstan-param TKey $key + * @phpstan-param callable(TKey): TValue $computeValue + * + * @return TValue + */ + public function getIfCachedElseCompute(string|int $key, callable $computeValue): mixed + { + if (ArrayUtil::getValueIfKeyExists($key, $this->keyToValue, /* out */ $valueInCache)) { + return $valueInCache; + } + + $value = $computeValue($key); + $this->put($key, $value); + return $value; + } +} diff --git a/tests/ElasticOTelTests/Util/ListSlice.php b/tests/ElasticOTelTests/Util/ListSlice.php new file mode 100644 index 0000000..0378f39 --- /dev/null +++ b/tests/ElasticOTelTests/Util/ListSlice.php @@ -0,0 +1,140 @@ + + */ +final class ListSlice implements Countable, IteratorAggregate, LoggableInterface +{ + /** @var array */ + public readonly array $base; + + /** @var non-negative-int */ + public readonly int $offset; + + /** @var non-negative-int */ + public readonly int $length; + + /** + * @param array $base + * @param non-negative-int $offset + * @param ?non-negative-int $length + */ + public function __construct(array $base, int $offset = 0, ?int $length = null) + { + $this->base = $base; + $this->offset = $offset; + $baseLength = count($base); + if ($length === null) { + if ($offset > $baseLength) { + throw new OutOfBoundsException(ExceptionUtil::buildMessage('offset > baseLength', compact('baseLength', 'offset', 'base'))); + } + $this->length = $baseLength - $offset; // @phpstan-ignore assign.propertyType + } else { + if ($offset + $length > $baseLength) { + throw new OutOfBoundsException(ExceptionUtil::buildMessage('offset + length > baseLength', compact('baseLength', 'offset', 'length', 'base'))); + } + $this->length = $length; + } + } + + #[Override] + public function count(): int + { + return $this->length; + } + + #[Override] + public function getIterator(): Traversable + { + foreach (RangeUtil::generateUpTo($this->length) as $i) { + yield $this->base[$this->offset + $i]; + } + } + + /** + * @return self + */ + public function clone(): self + { + return new self($this->base, $this->offset, $this->length); + } + + /** + * @param non-negative-int $prefixLength + * + * @return self + */ + public function withoutPrefix(int $prefixLength): self + { + if ($prefixLength > $this->length) { + throw new OutOfBoundsException(ExceptionUtil::buildMessage('prefixLength is larger than length', compact('prefixLength', 'this'))); + } + return new self($this->base, $this->offset + $prefixLength, $this->length - $prefixLength); // @phpstan-ignore argument.type + } + + /** + * @param non-negative-int $suffixLength + * + * @return self + */ + public function withoutSuffix(int $suffixLength): self + { + if ($suffixLength > $this->length) { + throw new OutOfBoundsException(ExceptionUtil::buildMessage('suffixLength is larger than length', compact('suffixLength', 'this'))); + } + return new self($this->base, $this->offset, $this->length - $suffixLength); // @phpstan-ignore argument.type + } + + /** + * @return T + */ + public function getLastValue(): mixed + { + if ($this->length === 0) { + throw new OutOfBoundsException(ExceptionUtil::buildMessage(' is empty', compact('this'))); + } + return $this->base[$this->offset + $this->length - 1]; + } + + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs(array_slice($this->base, $this->offset, $this->length)); + } +} diff --git a/tests/ElasticOTelTests/Util/Log/AdhocLoggableObject.php b/tests/ElasticOTelTests/Util/Log/AdhocLoggableObject.php new file mode 100644 index 0000000..02c695d --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/AdhocLoggableObject.php @@ -0,0 +1,63 @@ + */ + private array $propertyNameToData = []; + + /** + * @param array $nameToValue + * @param int $logPriority + */ + public function __construct(array $nameToValue, int $logPriority = PropertyLogPriority::NORMAL) + { + $this->addProperties($nameToValue, $logPriority); + } + + /** + * @param array $nameToValue + * @param int $logPriority + * + * @return self + */ + public function addProperties( + array $nameToValue, + /** @noinspection PhpUnusedParameterInspection */ int $logPriority = PropertyLogPriority::NORMAL + ): self { + $this->propertyNameToData += $nameToValue; + return $this; + } + + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs($this->propertyNameToData); + } +} diff --git a/tests/ElasticOTelTests/Util/Log/Backend.php b/tests/ElasticOTelTests/Util/Log/Backend.php new file mode 100644 index 0000000..0c14de7 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/Backend.php @@ -0,0 +1,137 @@ +maxEnabledLevel = $maxEnabledLevel; + $this->logSink = $logSink ?? NoopLogSink::singletonInstance(); + } + + public function isEnabledForLevel(LogLevel $level): bool + { + return $this->maxEnabledLevel->value >= $level->value; + } + + public function clone(): self + { + return new self($this->maxEnabledLevel, $this->logSink); + } + + public function setMaxEnabledLevel(LogLevel $maxEnabledLevel): void + { + $this->maxEnabledLevel = $maxEnabledLevel; + } + + /** + * @param array $statementCtx + * + * @return array + */ + private static function mergeContexts(LoggerData $loggerData, array $statementCtx): array + { + /** + * @see Comment in \ElasticOTelTests\Util\Log\Logger::addAllContext regarding the order of entries in logger context + */ + + $result = $statementCtx; + + $mergeKeyValueToResult = function (string|int $key, mixed $value) use (&$result): void { + if (!array_key_exists($key, $result)) { + $result[$key] = $value; + } + }; + + for ( + $currentLoggerData = $loggerData; + $currentLoggerData !== null; + $currentLoggerData = $currentLoggerData->inheritedData + ) { + foreach (ArrayUtilForTests::iterateMapInReverse($currentLoggerData->context) as $key => $value) { + $mergeKeyValueToResult($key, $value); + } + } + + $mergeKeyValueToResult(self::NAMESPACE_KEY, $loggerData->namespace); + $mergeKeyValueToResult(self::CLASS_KEY, ClassNameUtil::fqToShort($loggerData->fqClassName)); + + return $result; + } + + /** + * @param array $statementCtx + * @param non-negative-int $numberOfStackFramesToSkip + */ + public function log( + LogLevel $statementLevel, + string $message, + array $statementCtx, + int $srcCodeLine, + string $srcCodeFunc, + LoggerData $loggerData, + ?bool $includeStacktrace, + int $numberOfStackFramesToSkip + ): void { + $this->logSink->consume( + $statementLevel, + $message, + self::mergeContexts($loggerData, $statementCtx), + $loggerData->category, + $loggerData->srcCodeFile, + $srcCodeLine, + $srcCodeFunc, + $includeStacktrace, + $numberOfStackFramesToSkip + 1 + ); + } + + /** @noinspection PhpUnused */ + public function getSink(): SinkInterface + { + return $this->logSink; + } + + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs(['maxEnabledLevel' => $this->maxEnabledLevel, 'logSink' => get_debug_type($this->logSink)]); + } +} diff --git a/tests/ElasticOTelTests/Util/Log/EnabledLoggerProxy.php b/tests/ElasticOTelTests/Util/Log/EnabledLoggerProxy.php new file mode 100644 index 0000000..e7d52a6 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/EnabledLoggerProxy.php @@ -0,0 +1,91 @@ +includeStackTrace = $shouldIncludeStackTrace; + return $this; + } + + /** + * @param array $statementCtx + */ + public function log(string $message, array $statementCtx = []): bool + { + $this->loggerData->backend->log( + $this->statementLevel, + $message, + $statementCtx, + $this->srcCodeLine, + $this->srcCodeFunc, + $this->loggerData, + $this->includeStackTrace, + numberOfStackFramesToSkip: 1 + ); + // return dummy bool to suppress PHPStan's "Right side of && is always false" + return true; + } + + /** + * @param array $statementCtx + * + * @noinspection PhpUnused + */ + public function logThrowable(Throwable $throwable, string $message, array $statementCtx = []): bool + { + $this->loggerData->backend->log( + $this->statementLevel, + $message, + $statementCtx + ['throwable' => $throwable], + $this->srcCodeLine, + $this->srcCodeFunc, + $this->loggerData, + $this->includeStackTrace, + numberOfStackFramesToSkip: 1 + ); + // return dummy bool to suppress PHPStan's "Right side of && is always false" + return true; + } +} diff --git a/tests/ElasticOTelTests/Util/Log/EnabledLoggerProxyNoLine.php b/tests/ElasticOTelTests/Util/Log/EnabledLoggerProxyNoLine.php new file mode 100644 index 0000000..9610f84 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/EnabledLoggerProxyNoLine.php @@ -0,0 +1,89 @@ +includeStackTrace = $shouldIncludeStackTrace; + return $this; + } + + /** + * @param array $statementCtx + */ + public function log(int $srcCodeLine, string $message, array $statementCtx = []): bool + { + $this->loggerData->backend->log( + $this->statementLevel, + $message, + $statementCtx, + $srcCodeLine, + $this->srcCodeFunc, + $this->loggerData, + $this->includeStackTrace, + numberOfStackFramesToSkip: 1, + ); + // return dummy bool to suppress PHPStan's "Right side of && is always false" + return true; + } + + /** + * @param array $statementCtx + */ + public function logThrowable(int $srcCodeLine, Throwable $throwable, string $message, array $statementCtx = []): bool + { + $this->loggerData->backend->log( + $this->statementLevel, + $message, + $statementCtx + ['throwable' => $throwable], + $srcCodeLine, + $this->srcCodeFunc, + $this->loggerData, + $this->includeStackTrace, + numberOfStackFramesToSkip: 1 + ); + // return dummy bool to suppress PHPStan's "Right side of && is always false" + return true; + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LogCategoryForTests.php b/tests/ElasticOTelTests/Util/Log/LogCategoryForTests.php new file mode 100644 index 0000000..ed6fc13 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LogCategoryForTests.php @@ -0,0 +1,40 @@ +' => /** @lang text */ '']; +} diff --git a/tests/ElasticOTelTests/Util/Log/LogExternalClassesRegistry.php b/tests/ElasticOTelTests/Util/Log/LogExternalClassesRegistry.php new file mode 100644 index 0000000..76efd48 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LogExternalClassesRegistry.php @@ -0,0 +1,84 @@ +, ?ConverterToLog> */ + private LimitedSizeCache $classNameToConverterCache; + + private const CACHE_COUNT_LOW_WATER_MARK = 10000; + private const CACHE_COUNT_HIGH_WATER_MARK = 2 * self::CACHE_COUNT_LOW_WATER_MARK; + + private function __construct() + { + $this->classNameToConverterCache = new LimitedSizeCache(countLowWaterMark: self::CACHE_COUNT_LOW_WATER_MARK, countHighWaterMark: self::CACHE_COUNT_HIGH_WATER_MARK); + } + + /** + * @param FinderConverterToLog $finderConverterToLog + */ + public function addFinder(callable $finderConverterToLog): void + { + self::singletonInstance()->findersConverterToLog[] = $finderConverterToLog; + } + + /** + * @param object $object + * + * @return ?ConverterToLog + */ + public function finderConverterToLog(object $object): ?callable + { + /** + * @return ?ConverterToLog + */ + $queryFinders = function () use ($object): ?callable { + foreach ($this->findersConverterToLog as $finder) { + if (($converter = $finder($object)) !== null) { + return $converter; + } + } + return null; + }; + + return $this->classNameToConverterCache->getIfCachedElseCompute(get_class($object), $queryFinders); + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LogLevelUtil.php b/tests/ElasticOTelTests/Util/Log/LogLevelUtil.php new file mode 100644 index 0000000..b551a9b --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LogLevelUtil.php @@ -0,0 +1,52 @@ + $logLevel->value)); + /** @var int $maxValue */ + $result = LogLevel::from($maxValue); + } + + return $result; + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LogStream.php b/tests/ElasticOTelTests/Util/Log/LogStream.php new file mode 100644 index 0000000..896a20b --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LogStream.php @@ -0,0 +1,44 @@ +value = $value; + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LogStreamInterface.php b/tests/ElasticOTelTests/Util/Log/LogStreamInterface.php new file mode 100644 index 0000000..996dce0 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LogStreamInterface.php @@ -0,0 +1,36 @@ + */ + private array $wrappedArray; + + /** + * @param array $wrappedArray + */ + public function __construct(array $wrappedArray) + { + $this->wrappedArray = $wrappedArray; + } + + public function toLog(LogStreamInterface $stream): void + { + if ($stream->isLastLevel()) { + $stream->toLogAs( + [LogConsts::TYPE_KEY => self::ARRAY_TYPE, self::COUNT_KEY => count($this->wrappedArray)] + ); + return; + } + + $stream->toLogAs( + [LogConsts::TYPE_KEY => self::ARRAY_TYPE, self::COUNT_KEY => count($this->wrappedArray)] + ); + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LoggableInterface.php b/tests/ElasticOTelTests/Util/Log/LoggableInterface.php new file mode 100644 index 0000000..fb8d7d6 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LoggableInterface.php @@ -0,0 +1,39 @@ +captureInClassicFormat($numberOfStackFramesToSkip + 1, $maxNumberOfStackFrames); + /** @var ClassicFormatStackTraceFrame[] $result */ + $result = []; + + foreach ($capturedFrames as $capturedFrame) { + $result[] = new ClassicFormatStackTraceFrame( + self::adaptSourceCodeFilePath($capturedFrame->file), + $capturedFrame->line, + ($capturedFrame->class === null) ? null : ClassNameUtil::fqToShortFromRawString($capturedFrame->class), + $capturedFrame->isStaticMethod, + $capturedFrame->function + ); + } + return $result; + } + + public static function adaptSourceCodeFilePath(?string $srcFile): ?string + { + return $srcFile === null ? null : basename($srcFile); + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LoggableToEncodedJson.php b/tests/ElasticOTelTests/Util/Log/LoggableToEncodedJson.php new file mode 100644 index 0000000..8297336 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LoggableToEncodedJson.php @@ -0,0 +1,71 @@ + get_debug_type($value)], + $ex + ); + } + + try { + return JsonUtil::encode($jsonEncodable, $prettyPrint); + } catch (Exception $ex) { + return LoggingSubsystem::onInternalFailure( + 'JsonUtil::encode() failed', + ['$jsonEncodable type' => get_debug_type($jsonEncodable)], + $ex + ); + } + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LoggableToJsonEncodable.php b/tests/ElasticOTelTests/Util/Log/LoggableToJsonEncodable.php new file mode 100644 index 0000000..fcfd066 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LoggableToJsonEncodable.php @@ -0,0 +1,421 @@ + $value + * + * @return array + */ + public static function convertArrayForMaxDepth(array $value, int $depth): array + { + return [LogConsts::MAX_DEPTH_REACHED => $depth, LogConsts::TYPE_KEY => get_debug_type($value), LogConsts::ARRAY_COUNT_KEY => count($value)]; + } + + /** + * @param object $value + * @param int $depth + * + * @return array + */ + public static function convertObjectForMaxDepth(object $value, int $depth): array + { + return [LogConsts::MAX_DEPTH_REACHED => $depth, LogConsts::TYPE_KEY => get_debug_type($value)]; + } + + public static function convert(mixed $value, int $depth): mixed + { + if ($value === null) { + return null; + } + + // Scalar variables are those containing an int, float, string or bool. + // Types array, object and resource are not scalar. + if (is_scalar($value)) { + return $value; + } + + if (is_array($value)) { + if ($depth >= self::$maxDepth) { + return self::convertArrayForMaxDepth($value, $depth); + } + return self::convertArray($value, $depth + 1); + } + + if (is_resource($value)) { + return self::convertOpenResource($value); + } + + if (is_object($value)) { + if ($depth >= self::$maxDepth) { + return self::convertObjectForMaxDepth($value, $depth); + } + return self::convertObject($value, $depth); + } + + return [LogConsts::TYPE_KEY => get_debug_type($value), LogConsts::VALUE_AS_STRING_KEY => strval($value)]; /** @phpstan-ignore argument.type */ + } + + /** + * @param array $array + * + * @return array + */ + private static function convertArray(array $array, int $depth): array + { + return self::convertArrayImpl($array, array_is_list($array), $depth); + } + + /** + * @param array $array + * @param bool $isListArray + * @param int $depth + * + * @return array + */ + private static function convertArrayImpl(array $array, bool $isListArray, int $depth): array + { + $arrayCount = count($array); + $smallArrayMaxCount = $isListArray + ? LogConsts::SMALL_LIST_ARRAY_MAX_COUNT + : LogConsts::SMALL_MAP_ARRAY_MAX_COUNT; + if ($arrayCount <= $smallArrayMaxCount) { + return self::convertSmallArray($array, $isListArray, $depth); + } + + $result = [LogConsts::TYPE_KEY => LogConsts::LIST_ARRAY_TYPE_VALUE]; + $result[LogConsts::ARRAY_COUNT_KEY] = $arrayCount; + + $halfOfSmallArrayMaxCount = intdiv($smallArrayMaxCount, 2); + $firstElements = array_slice($array, 0, $halfOfSmallArrayMaxCount); + $result['0-' . intdiv($smallArrayMaxCount, 2)] + = self::convertSmallArray($firstElements, $isListArray, $depth); + + $result[($arrayCount - $halfOfSmallArrayMaxCount) . '-' . $arrayCount] + = self::convertSmallArray(array_slice($array, -$halfOfSmallArrayMaxCount), $isListArray, $depth); + + return $result; + } + + /** + * @param array $array + * @param bool $isListArray + * @param int $depth + * + * @return array + */ + private static function convertSmallArray(array $array, bool $isListArray, int $depth): array + { + return $isListArray ? self::convertSmallListArray($array, $depth) : self::convertSmallMapArray($array, $depth); + } + + /** + * @param array $listArray + * + * @return array + */ + private static function convertSmallListArray(array $listArray, int $depth): array + { + $result = []; + foreach ($listArray as $value) { + $result[] = self::convert($value, $depth); + } + return $result; + } + + /** + * @param array $mapArrayValue + * + * @return array + */ + private static function convertSmallMapArray(array $mapArrayValue, int $depth): array + { + return self::isStringKeysMapArray($mapArrayValue) + ? self::convertSmallStringKeysMapArray($mapArrayValue, $depth) + : self::convertSmallMixedKeysMapArray($mapArrayValue, $depth); + } + + /** + * @param array $mapArrayValue + * + * @return bool + */ + private static function isStringKeysMapArray(array $mapArrayValue): bool + { + foreach ($mapArrayValue as $key => $_) { + if (!is_string($key)) { + return false; + } + } + return true; + } + + /** + * @param array $mapArrayValue + * + * @return array + */ + private static function convertSmallStringKeysMapArray(array $mapArrayValue, int $depth): array + { + return array_map(function ($value) use ($depth) { + return self::convert($value, $depth); + }, $mapArrayValue); + } + + /** + * @param array $mapArrayValue + * @param int $depth + * + * @return array + */ + private static function convertSmallMixedKeysMapArray(array $mapArrayValue, int $depth): array + { + $result = []; + foreach ($mapArrayValue as $key => $value) { + $result[] = ['key' => self::convert($key, $depth), 'value' => self::convert($value, $depth)]; + } + return $result; + } + + /** + * @param resource $resource + * + * @return array + */ + private static function convertOpenResource($resource): array + { + return [ + LogConsts::TYPE_KEY => LogConsts::RESOURCE_TYPE_VALUE, + LogConsts::RESOURCE_TYPE_KEY => get_resource_type($resource), + LogConsts::RESOURCE_ID_KEY => intval($resource), + ]; + } + + private static function isFromElasticNamespace(string $fqClassName): bool + { + foreach (self::ELASTIC_NAMESPACE_PREFIXES as $prefix) { + if (TextUtil::isPrefixOf($prefix, $fqClassName)) { + return true; + } + } + return false; + } + + private static function convertObject(object $object, int $depth): mixed + { + if ($object instanceof LoggableInterface) { + return self::convertLoggable($object, $depth); + } + + if ($object instanceof Throwable) { + return self::convertThrowable($object, $depth); + } + + if ($object instanceof LogLevel) { + return strtoupper($object->name); + } + + if ($object instanceof UnitEnum) { + return $object::class . '(' . $object->name . ')'; + } + + if (self::isFromElasticNamespace(get_class($object)) && self::isDtoObject($object)) { + return self::convertDtoObject($object, $depth); + } + + if (($converterToLog = LogExternalClassesRegistry::singletonInstance()->finderConverterToLog($object)) !== null) { + return self::convert($converterToLog($object), $depth); + } + + if (method_exists($object, '__debugInfo')) { + return [ + LogConsts::TYPE_KEY => get_class($object), + LogConsts::VALUE_AS_DEBUG_INFO_KEY => self::convert($object->__debugInfo(), $depth), + ]; + } + + if (method_exists($object, '__toString')) { + return [ + LogConsts::TYPE_KEY => get_class($object), + LogConsts::VALUE_AS_STRING_KEY => self::convert($object->__toString(), $depth), + ]; + } + + return [ + LogConsts::TYPE_KEY => get_class($object), + LogConsts::OBJECT_ID_KEY => spl_object_id($object), + LogConsts::OBJECT_HASH_KEY => spl_object_hash($object), + ]; + } + + /** + * @param LoggableInterface $loggable + * @param int $depth + * + * @return mixed + */ + private static function convertLoggable(LoggableInterface $loggable, int $depth): mixed + { + $logStream = new LogStream(); + $loggable->toLog($logStream); + return self::convert($logStream->value, $depth); + } + + /** + * @param Throwable $throwable + * @param int $depth + * + * @return array + */ + private static function convertThrowable(Throwable $throwable, int $depth): array + { + return [ + LogConsts::TYPE_KEY => get_class($throwable), + LogConsts::VALUE_AS_STRING_KEY => self::convert($throwable->__toString(), $depth), + ]; + } + + /** + * @return string|array + * + * @phpstan-ignore return.unusedType + */ + private static function convertDtoObject(object $object, int $depth): string|array + { + $class = get_class($object); + try { + $currentClass = new ReflectionClass($class); + } catch (ReflectionException $ex) { // @phpstan-ignore catch.neverThrown + return LoggingSubsystem::onInternalFailure('Failed to reflect', ['class' => $class], $ex); + } + + $nameToValue = []; + while (true) { + foreach ($currentClass->getProperties() as $reflectionProperty) { + if ($reflectionProperty->isStatic()) { + continue; + } + + $propName = $reflectionProperty->name; + $propValue = $reflectionProperty->getValue($object); + $nameToValue[$propName] = self::convert($propValue, $depth); + } + $currentClass = $currentClass->getParentClass(); + if ($currentClass === false) { + break; + } + } + return $nameToValue; + } + + /** + * @return LimitedSizeCache, bool> + */ + private static function isDtoObjectCacheSingleton(): LimitedSizeCache + { + /** + * @var ?LimitedSizeCache, bool> + * + * @noinspection PhpVarTagWithoutVariableNameInspection + */ + static $isDtoObjectCache = null; + + if ($isDtoObjectCache === null) { + $isDtoObjectCache = new LimitedSizeCache( + countLowWaterMark: self::IS_DTO_OBJECT_CACHE_MAX_COUNT_LOW_WATER_MARK, + countHighWaterMark: self::IS_DTO_OBJECT_CACHE_MAX_COUNT_HIGH_WATER_MARK + ); + /** @var LimitedSizeCache, bool> $isDtoObjectCache */ + } + + return $isDtoObjectCache; + } + + private static function isDtoObject(object $object): bool + { + return self::isDtoObjectCacheSingleton()->getIfCachedElseCompute(get_class($object), self::detectIfDtoObject(...)); + } + + /** + * @template T of object + * + * @param class-string $className + * + * @return bool + */ + private static function detectIfDtoObject(string $className): bool + { + try { + $currentClass = new ReflectionClass($className); + } catch (ReflectionException $ex) { // @phpstan-ignore catch.neverThrown + LoggingSubsystem::onInternalFailure('Failed to reflect', ['className' => $className], $ex); + return false; + } + + while (true) { + foreach ($currentClass->getProperties() as $reflectionProperty) { + if ($reflectionProperty->isStatic()) { + continue; + } + + if (!$reflectionProperty->isPublic()) { + return false; + } + } + $currentClass = $currentClass->getParentClass(); + if ($currentClass === false) { + break; + } + } + + return true; + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LoggableToString.php b/tests/ElasticOTelTests/Util/Log/LoggableToString.php new file mode 100644 index 0000000..a67a7f6 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LoggableToString.php @@ -0,0 +1,43 @@ + $customPropValues + */ + protected function toLogLoggableTraitImpl(LogStreamInterface $stream, array $customPropValues = []): void + { + $nameToValue = $customPropValues; + + $classNameToLog = static::classNameToLog(); + if ($classNameToLog !== null) { + $nameToValue[LogConsts::TYPE_KEY] = $classNameToLog; + } + + try { + $currentClass = new ReflectionClass(get_class($this)); + } /** @noinspection PhpRedundantCatchClauseInspection */ catch (ReflectionException $ex) { + $stream->toLogAs( + LoggingSubsystem::onInternalFailure('Failed to reflect', ['class' => get_class($this)], $ex) + ); + return; + } + + $propertiesExcludedFromLog = array_merge(static::propertiesExcludedFromLog(), static::defaultPropertiesExcludedFromLog()); + while (true) { + foreach ($currentClass->getProperties() as $reflectionProperty) { + if ($reflectionProperty->isStatic()) { + continue; + } + + $propName = $reflectionProperty->name; + if (array_key_exists($propName, $customPropValues)) { + continue; + } + if (in_array($propName, $propertiesExcludedFromLog, strict: true)) { + continue; + } + $nameToValue[$propName] = $reflectionProperty->isInitialized($this) ? $reflectionProperty->getValue($this) : LogConsts::UNINITIALIZED_PROPERTY_SUBSTITUTE; + } + $currentClass = $currentClass->getParentClass(); + if ($currentClass === false) { + break; + } + } + + $stream->toLogAs($nameToValue); + } + + public function toLog(LogStreamInterface $stream): void + { + $this->toLogLoggableTraitImpl($stream); + } +} diff --git a/tests/ElasticOTelTests/Util/Log/Logger.php b/tests/ElasticOTelTests/Util/Log/Logger.php new file mode 100644 index 0000000..d9f460f --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/Logger.php @@ -0,0 +1,201 @@ + $context + * + * @return static + */ + public static function makeRoot( + string $category, + string $namespace, + string $fqClassName, + string $srcCodeFile, + array $context, + Backend $backend + ): self { + return new self(LoggerData::makeRoot($category, $namespace, $fqClassName, $srcCodeFile, $context, $backend)); + } + + public function inherit(): self + { + return new self($this->data->inherit()); + } + + public function addContext(string $key, mixed $value): self + { + $this->data->context[$key] = $value; + return $this; + } + + /** + * @param array $keyValuePairs + * + * @return $this + */ + public function addAllContext(array $keyValuePairs): self + { + // Entries in the context are kept in order of increasing importance. + // More recently entry is considered more important. + // When a batch of entries is added using addAllContext + // we consider the first entry in the batch the most important + // so that is why the batch is iterated in the reverse order + foreach (ArrayUtilForTests::iterateMapInReverse($keyValuePairs) as $key => $value) { + $this->addContext($key, $value); + } + return $this; + } + + /** + * @return array + * + * @noinspection PhpUnused + */ + public function getContext(): array + { + return $this->data->context; + } + + public function ifCriticalLevelEnabled(int $srcCodeLine, string $srcCodeFunc): ?EnabledLoggerProxy + { + return $this->ifLevelEnabled(LogLevel::critical, $srcCodeLine, $srcCodeFunc); + } + + public function ifErrorLevelEnabled(int $srcCodeLine, string $srcCodeFunc): ?EnabledLoggerProxy + { + return $this->ifLevelEnabled(LogLevel::error, $srcCodeLine, $srcCodeFunc); + } + + public function ifWarningLevelEnabled(int $srcCodeLine, string $srcCodeFunc): ?EnabledLoggerProxy + { + return $this->ifLevelEnabled(LogLevel::warning, $srcCodeLine, $srcCodeFunc); + } + + /** @noinspection PhpUnused */ + public function ifInfoLevelEnabled(int $srcCodeLine, string $srcCodeFunc): ?EnabledLoggerProxy + { + return $this->ifLevelEnabled(LogLevel::info, $srcCodeLine, $srcCodeFunc); + } + + public function ifDebugLevelEnabled(int $srcCodeLine, string $srcCodeFunc): ?EnabledLoggerProxy + { + return $this->ifLevelEnabled(LogLevel::debug, $srcCodeLine, $srcCodeFunc); + } + + public function ifTraceLevelEnabled(int $srcCodeLine, string $srcCodeFunc): ?EnabledLoggerProxy + { + return $this->ifLevelEnabled(LogLevel::trace, $srcCodeLine, $srcCodeFunc); + } + + /** @noinspection PhpUnused */ + public function ifCriticalLevelEnabledNoLine(string $srcCodeFunc): ?EnabledLoggerProxyNoLine + { + return $this->ifLevelEnabledNoLine(LogLevel::critical, $srcCodeFunc); + } + + /** @noinspection PhpUnused */ + public function ifErrorLevelEnabledNoLine(string $srcCodeFunc): ?EnabledLoggerProxyNoLine + { + return $this->ifLevelEnabledNoLine(LogLevel::error, $srcCodeFunc); + } + + /** @noinspection PhpUnused */ + public function ifWarningLevelEnabledNoLine(string $srcCodeFunc): ?EnabledLoggerProxyNoLine + { + return $this->ifLevelEnabledNoLine(LogLevel::warning, $srcCodeFunc); + } + + /** @noinspection PhpUnused */ + public function ifInfoLevelEnabledNoLine(string $srcCodeFunc): ?EnabledLoggerProxyNoLine + { + return $this->ifLevelEnabledNoLine(LogLevel::info, $srcCodeFunc); + } + + /** @noinspection PhpUnused */ + public function ifDebugLevelEnabledNoLine(string $srcCodeFunc): ?EnabledLoggerProxyNoLine + { + return $this->ifLevelEnabledNoLine(LogLevel::debug, $srcCodeFunc); + } + + /** @noinspection PhpUnused */ + public function ifTraceLevelEnabledNoLine(string $srcCodeFunc): ?EnabledLoggerProxyNoLine + { + return $this->ifLevelEnabledNoLine(LogLevel::trace, $srcCodeFunc); + } + + public function ifLevelEnabled(LogLevel $statementLevel, int $srcCodeLine, string $srcCodeFunc): ?EnabledLoggerProxy + { + return ($this->data->backend->isEnabledForLevel($statementLevel)) + ? new EnabledLoggerProxy($statementLevel, $srcCodeLine, $srcCodeFunc, $this->data) + : null; + } + + public function ifLevelEnabledNoLine(LogLevel $statementLevel, string $srcCodeFunc): ?EnabledLoggerProxyNoLine + { + return ($this->data->backend->isEnabledForLevel($statementLevel)) + ? new EnabledLoggerProxyNoLine($statementLevel, $srcCodeFunc, $this->data) + : null; + } + + public function isEnabledForLevel(LogLevel $level): bool + { + return $this->data->backend->isEnabledForLevel($level); + } + + public function isTraceLevelEnabled(): bool + { + return $this->isEnabledForLevel(LogLevel::trace); + } + + /** @noinspection PhpUnused */ + public function possiblySecuritySensitive(mixed $value): mixed + { + if ($this->isTraceLevelEnabled()) { + return $value; + } + return 'REDACTED (POSSIBLY SECURITY SENSITIVE) DATA'; + } + + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs($this->data); + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LoggerData.php b/tests/ElasticOTelTests/Util/Log/LoggerData.php new file mode 100644 index 0000000..3d03210 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LoggerData.php @@ -0,0 +1,119 @@ + */ + public array $context; + + public Backend $backend; + + /** + * @param class-string $fqClassName + * @param array $context + */ + private function __construct( + string $category, + string $namespace, + string $fqClassName, + string $srcCodeFile, + array $context, + Backend $backend, + ?LoggerData $inheritedData + ) { + $this->category = $category; + $this->namespace = $namespace; + $this->fqClassName = $fqClassName; + $this->srcCodeFile = $srcCodeFile; + $this->context = $context; + $this->backend = $backend; + $this->inheritedData = $inheritedData; + } + + /** + * @param class-string $fqClassName + * @param array $context + * + * @return self + */ + public static function makeRoot( + string $category, + string $namespace, + string $fqClassName, + string $srcCodeFile, + array $context, + Backend $backend + ): self { + return new self( + $category, + $namespace, + $fqClassName, + $srcCodeFile, + $context, + $backend, + /* inheritedData */ null + ); + } + + public function inherit(): self + { + return new self( + $this->category, + $this->namespace, + $this->fqClassName, + $this->srcCodeFile, + [] /* <- context */, + $this->backend, + $this + ); + } + + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs( + [ + 'category' => $this->category, + 'namespace' => $this->namespace, + 'fqClassName' => $this->fqClassName, + 'srcCodeFile' => $this->srcCodeFile, + 'inheritedData' => $this->inheritedData, + 'count(context)' => count($this->context), + 'backend' => $this->backend, + ] + ); + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LoggerFactory.php b/tests/ElasticOTelTests/Util/Log/LoggerFactory.php new file mode 100644 index 0000000..a3773a0 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LoggerFactory.php @@ -0,0 +1,98 @@ + */ + public array $context; + + /** + * @param Backend $backend + * @param array $context + */ + public function __construct(Backend $backend, array $context = []) + { + $this->backend = $backend; + $this->context = $context; + } + + /** + * @param class-string $fqClassName + */ + public function loggerForClass( + string $category, + string $namespace, + string $fqClassName, + string $srcCodeFile + ): Logger { + return Logger::makeRoot($category, $namespace, $fqClassName, $srcCodeFile, $this->context, $this->backend); + } + + public function getBackend(): Backend + { + return $this->backend; + } + + /** @noinspection PhpUnused */ + public function isEnabledForLevel(LogLevel $level): bool + { + return $this->backend->isEnabledForLevel($level); + } + + public function inherit(): self + { + return new self($this->backend); + } + + public function addContext(string $key, mixed $value): self + { + $this->context[$key] = $value; + return $this; + } + + /** + * @param array $keyValuePairs + * + * @return self + * + * @noinspection PhpUnused + */ + public function addAllContext(array $keyValuePairs): self + { + foreach ($keyValuePairs as $key => $value) { + $this->addContext($key, $value); + } + return $this; + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LoggingSubsystem.php b/tests/ElasticOTelTests/Util/Log/LoggingSubsystem.php new file mode 100644 index 0000000..df985eb --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LoggingSubsystem.php @@ -0,0 +1,66 @@ + $context + * @param Throwable $causedBy + * + * @return string + */ + public static function onInternalFailure(string $message, array $context, Throwable $causedBy): string + { + self::$wereThereAnyInternalFailures = true; + if (self::$isInTestingContext) { + throw new LoggingSubsystemException(ExceptionUtil::buildMessage($message, $context), $causedBy); + } + + return $message . '. ' . LoggableToString::convert($context + ['causedBy' => $causedBy]); + } + + public static function wereThereAnyInternalFailures(): bool + { + return self::$wereThereAnyInternalFailures; + } +} diff --git a/tests/ElasticOTelTests/Util/Log/LoggingSubsystemException.php b/tests/ElasticOTelTests/Util/Log/LoggingSubsystemException.php new file mode 100644 index 0000000..ab7433e --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/LoggingSubsystemException.php @@ -0,0 +1,40 @@ +consumePreformatted( + $statementLevel, + $category, + $srcCodeFile, + $srcCodeLine, + $srcCodeFunc, + $messageWithContext + ); + } + + abstract protected function consumePreformatted( + LogLevel $statementLevel, + string $category, + string $srcCodeFile, + int $srcCodeLine, + string $srcCodeFunc, + string $messageWithContext + ): void; +} diff --git a/tests/ElasticOTelTests/Util/Log/SinkForTests.php b/tests/ElasticOTelTests/Util/Log/SinkForTests.php new file mode 100644 index 0000000..ba6a454 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/SinkForTests.php @@ -0,0 +1,101 @@ +format('Y-m-d H:i:s.v P'); + $formattedRecord .= ' [' . strtoupper($statementLevel->name) . ']'; + $formattedRecord .= ' [PID: ' . getmypid() . ']'; + $formattedRecord .= ' [' . $this->dbgProcessName . ']'; + $formattedRecord .= ' [' . basename($srcCodeFile) . ':' . $srcCodeLine . ']'; + $formattedRecord .= ' [' . $srcCodeFunc . ']'; + $formattedRecord .= TextUtilForTests::combineWithSeparatorIfNotEmpty(' ', $messageWithContext); + $this->consumeFormatted($statementLevel, $formattedRecord); + } + + public static function writeLineToStdErr(string $text): void + { + if (self::ensureStdErrIsDefined()) { + fwrite(STDERR, $text . PHP_EOL); + fflush(STDERR); + } + } + + private function consumeFormatted(LogLevel $statementLevel, string $statementText): void + { + syslog(self::levelToSyslog($statementLevel), $statementText); + self::writeLineToStdErr($statementText); + } + + private static function levelToSyslog(LogLevel $level): int + { + return match ($level) { + LogLevel::off, LogLevel::critical => LOG_CRIT, + LogLevel::error => LOG_ERR, + LogLevel::warning => LOG_WARNING, + LogLevel::info => LOG_INFO, + LogLevel::debug, LogLevel::trace => LOG_DEBUG + }; + } +} diff --git a/tests/ElasticOTelTests/Util/Log/SinkInterface.php b/tests/ElasticOTelTests/Util/Log/SinkInterface.php new file mode 100644 index 0000000..35b056d --- /dev/null +++ b/tests/ElasticOTelTests/Util/Log/SinkInterface.php @@ -0,0 +1,50 @@ + $context + * @param non-negative-int $numberOfStackFramesToSkip + */ + public function consume( + LogLevel $statementLevel, + string $message, + array $context, + string $category, + string $srcCodeFile, + int $srcCodeLine, + string $srcCodeFunc, + ?bool $includeStacktrace, + int $numberOfStackFramesToSkip + ): void; +} diff --git a/tests/ElasticOTelTests/Util/MixedMap.php b/tests/ElasticOTelTests/Util/MixedMap.php new file mode 100644 index 0000000..b828b5e --- /dev/null +++ b/tests/ElasticOTelTests/Util/MixedMap.php @@ -0,0 +1,354 @@ + + * @implements IteratorAggregate + */ +class MixedMap implements LoggableInterface, ArrayAccess, IteratorAggregate +{ + /** @var array */ + private array $map; + + /** + * @param array $initialMap + */ + public function __construct(array $initialMap = []) + { + $this->map = $initialMap; + } + + /** + * @param array $array + * + * @return array + */ + public static function assertValidMixedMapArray(array $array): array + { + foreach ($array as $key => $ignored) { + Assert::assertIsString($key); + } + /** + * @var array $array + */ + return $array; + } + + /** + * @param array $from + */ + public static function getFrom(string $key, array $from): mixed + { + Assert::assertArrayHasKey($key, $from); + return $from[$key]; + } + + public function get(string $key): mixed + { + return self::getFrom($key, $this->map); + } + + /** @noinspection PhpUnused */ + public function getIfKeyExistsElse(string $key, mixed $fallbackValue): mixed + { + return ArrayUtil::getValueIfKeyExistsElse($key, $this->map, $fallbackValue); + } + + /** + * @param array $from + */ + public static function getNullableBoolFrom(string $key, array $from): ?bool + { + $value = self::getFrom($key, $from); + if ($value !== null) { + Assert::assertIsBool($value); + } + return $value; + } + + /** + * @param array $from + */ + public static function getBoolFrom(string $key, array $from): bool + { + return AssertEx::notNull(self::getNullableBoolFrom($key, $from)); + } + + public function getNullableBool(string $key): ?bool + { + return self::getNullableBoolFrom($key, $this->map); + } + + public function getBool(string $key): bool + { + return self::getBoolFrom($key, $this->map); + } + + public function tryToGetBool(string $key): ?bool + { + if (!array_key_exists($key, $this->map)) { + return null; + } + return self::getBool($key); + } + + public function isBoolIsNotSetOrSetToTrue(string $key): bool + { + return (($value = self::tryToGetBool($key)) === null) || $value; + } + + /** + * @param array $from + */ + public static function getNullableStringFrom(string $key, array $from): ?string + { + $value = self::getFrom($key, $from); + if ($value !== null) { + Assert::assertIsString($value); + } + return $value; + } + + public function getNullableString(string $key): ?string + { + return self::getNullableStringFrom($key, $this->map); + } + + public function getString(string $key): string + { + return AssertEx::notNull($this->getNullableString($key)); + } + + public function getNullableFloat(string $key): ?float + { + $value = $this->get($key); + if ($value === null || is_float($value)) { + return $value; + } + Assert::assertIsInt($value); + return floatval($value); + } + + /** @noinspection PhpUnused */ + public function getFloat(string $key): float + { + $value = $this->getNullableFloat($key); + return AssertEx::notNull($value); + } + + public function getNullableInt(string $key): ?int + { + $value = $this->get($key); + if ($value === null) { + return null; + } + return AssertEx::isInt($value); + } + + /** + * @param string $key + * + * @return null|positive-int|0 + * + * @noinspection PhpUnused + */ + public function getNullablePositiveOrZeroInt(string $key): ?int + { + $value = $this->getNullableInt($key); + if ($value !== null) { + Assert::assertGreaterThanOrEqual(0, $value); + } + /** @var null|positive-int|0 $value */ + return $value; + } + + public function getInt(string $key): int + { + return AssertEx::notNull($this->getNullableInt($key)); + } + + /** + * @param string $key + * + * @return positive-int|0 + * + * @noinspection PhpUnused + */ + public function getPositiveOrZeroInt(string $key): int + { + $value = $this->getInt($key); + Assert::assertGreaterThanOrEqual(0, $value); + /** @var positive-int|0 $value */ + return $value; + } + + /** + * @return ?array + */ + public function getNullableArray(string $key): ?array + { + $value = $this->get($key); + if ($value !== null) { + Assert::assertIsArray($value); + } + return $value; + } + + /** + * @return array + */ + public function getArray(string $key): array + { + return AssertEx::notNull($this->getNullableArray($key)); + } + + /** + * @template TObj of object + * + * @param class-string $className + * + * @phpstan-return ?TObj + */ + public function getNullableObject(string $key, string $className): ?object + { + $value = $this->get($key); + if ($value === null) { + return null; + } + Assert::assertInstanceOf($className, $value); + return $value; + } + + /** + * @template TObj of object + * + * @param class-string $className + * + * @phpstan-return TObj + */ + public function getObject(string $key, string $className): object + { + return AssertEx::notNull($this->getNullableObject($key, $className)); + } + + public function getLogLevel(string $key): LogLevel + { + return $this->getObject($key, LogLevel::class); + } + + /** + * @return self + */ + public function clone(): self + { + return new MixedMap($this->map); + } + + /** + * @return array + */ + public function cloneAsArray(): array + { + return $this->map; + } + + /** + * @inheritDoc + * + * @param string $offset + * + * @return bool + */ + #[Override] + public function offsetExists($offset): bool + { + return array_key_exists($offset, $this->map); + } + + /** + * @inheritDoc + * + * @param string $offset + * + * @return mixed + */ + #[Override] + #[ReturnTypeWillChange] + public function offsetGet($offset): mixed + { + return $this->map[$offset]; + } + + /** + * @inheritDoc + * + * @param string $offset + */ + #[Override] + public function offsetSet($offset, mixed $value): void + { + Assert::assertIsString($offset); /** @phpstan-ignore staticMethod.alreadyNarrowedType */ + $this->map[$offset] = $value; + } + + /** + * @inheritDoc + * + * @param string $offset + */ + #[Override] + public function offsetUnset($offset): void + { + Assert::assertArrayHasKey($offset, $this->map); + unset($this->map[$offset]); + } + + /** + * @return Traversable + */ + #[Override] + public function getIterator(): Traversable + { + return new ArrayIterator($this->map); + } + + #[Override] + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs($this->map); + } +} diff --git a/tests/ElasticOTelTests/Util/MonotonicTime.php b/tests/ElasticOTelTests/Util/MonotonicTime.php new file mode 100644 index 0000000..62c8bc7 --- /dev/null +++ b/tests/ElasticOTelTests/Util/MonotonicTime.php @@ -0,0 +1,37 @@ +toLogAs([LogConsts::TYPE_KEY => ClassNameUtil::fqToShort(get_class($this))]); + } +} diff --git a/tests/ElasticOTelTests/Util/NumericUtilForTests.php b/tests/ElasticOTelTests/Util/NumericUtilForTests.php new file mode 100644 index 0000000..e486e64 --- /dev/null +++ b/tests/ElasticOTelTests/Util/NumericUtilForTests.php @@ -0,0 +1,41 @@ +isValueSet); + return $this->value; + } + + /** + * @param T $value + */ + public function setValue($value): void + { + $this->value = $value; + $this->isValueSet = true; + } + + /** + * @param T $elseValue + * + * @return T + * + * @noinspection PhpUnused + */ + public function getValueOr($elseValue) + { + return $this->isValueSet ? $this->value : $elseValue; + } + + public function reset(): void + { + $this->isValueSet = false; + unset($this->value); + } + + public function isValueSet(): bool + { + return $this->isValueSet; + } + + /** + * @param T $value + * + * @noinspection PhpUnused + */ + public function setValueIfNotSet($value): void + { + if (!$this->isValueSet) { + $this->setValue($value); + } + } + + public function toLog(LogStreamInterface $stream): void + { + $stream->toLogAs($this->isValueSet ? $this->value : /** @lang text */ ''); + } +} diff --git a/tests/ElasticOTelTests/Util/PHPUnitExtensionBase.php b/tests/ElasticOTelTests/Util/PHPUnitExtensionBase.php new file mode 100644 index 0000000..1591e02 --- /dev/null +++ b/tests/ElasticOTelTests/Util/PHPUnitExtensionBase.php @@ -0,0 +1,400 @@ +logger = AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + + ($loggerProxy = $this->logger->ifLevelEnabled($this->logLevelForEnvInfo(), __LINE__, __FUNCTION__)) + && $loggerProxy->includeStackTrace()->log('Done', ['environment variables' => EnvVarUtilForTests::getAll()]); + } + + protected function logLevelForEnvInfo(): LogLevel + { + return LogLevel::debug; + } + + public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void + { + $this->registerSubscribers($facade); + } + + public function registerSubscribers(Facade $facade): void + { + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestPreparedSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestPrepared $event): void + { + $this->extension->beforeTestCaseIsRun($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestPassedSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestPassed $event): void + { + $this->extension->afterTestCasePassed($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestConsideredRiskySubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestConsideredRisky $event): void + { + $this->extension->afterTestCaseConsideredRisky($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestErroredSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestErrored $event): void + { + $this->extension->afterTestCaseErrored($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestErrorTriggeredSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestErrorTriggered $event): void + { + $this->extension->afterTestCaseErrorTriggered($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestFailedSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestFailed $event): void + { + $this->extension->afterTestCaseFailed($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestMarkedIncompleteSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestMarkedIncomplete $event): void + { + $this->extension->afterTestCaseMarkedIncomplete($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestPhpunitErrorTriggeredSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestPhpunitErrorTriggered $event): void + { + $this->extension->afterTestCasePhpunitErrorTriggered($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestPhpunitWarningTriggeredSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestPhpunitWarningTriggered $event): void + { + $this->extension->afterTestCasePhpunitWarningTriggered($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestPhpWarningTriggeredSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestPhpWarningTriggered $event): void + { + $this->extension->afterTestCasePhpWarningTriggered($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestSkippedSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestSkipped $event): void + { + $this->extension->afterTestCaseSkipped($event); + } + } + ); + + $facade->registerSubscriber( + new class ($this) implements PHPUnitEventTestWarningTriggeredSubscriber { + public function __construct( + private readonly PHPUnitExtensionBase $extension, + ) { + } + + #[Override] + public function notify(PHPUnitEventTestWarningTriggered $event): void + { + $this->extension->afterTestCaseWarningTriggered($event); + } + } + ); + } + + protected function logLevelForBeforeTestCaseIsRun(): LogLevel + { + return LogLevel::debug; + } + + /** + * @return array + */ + private static function formatFormatTelemetryInfoSincePrevious(PHPUnitEvent $event): array + { + $prevEvent = self::$lastEvent; + self::$lastEvent = $event; + if ($prevEvent === null) { + return [ + 'Since start' => [ + 'duration' => $event->telemetryInfo()->durationSinceStart()->asString(), + 'memory usage (bytes)' => $event->telemetryInfo()->memoryUsageSinceStart()->bytes(), + ], + ]; + } + + $durationSincePrevious = $event->telemetryInfo()->time()->duration($prevEvent->telemetryInfo()->time()); + return [ + 'Since previous' => [ + 'duration' => $durationSincePrevious->asString(), + 'memory usage (bytes)' => ($event->telemetryInfo()->memoryUsageSinceStart()->bytes() - $prevEvent->telemetryInfo()->memoryUsageSinceStart()->bytes()), + ], + ]; + } + + public function beforeTestCaseIsRun(PHPUnitEventTestPrepared $event): void + { + DebugContext::reset(); + + self::$timestampBeforeTest = AmbientContextForTests::clock()->getSystemClockCurrentTime(); + + self::$lastBeforeTestCaseEvent = $event; + + ($loggerProxy = $this->logger->ifLevelEnabled($this->logLevelForBeforeTestCaseIsRun(), __LINE__, __FUNCTION__)) + && $loggerProxy->log('Before running test case...', ['test' => $event->test()] + self::formatFormatTelemetryInfoSincePrevious($event)); + } + + protected function logLevelForAfterTestCasePassed(): LogLevel + { + return LogLevel::debug; + } + + public function afterTestCasePassed(PHPUnitEventTestPassed $event): void + { + ($loggerProxy = $this->logger->ifLevelEnabled($this->logLevelForAfterTestCasePassed(), __LINE__, __FUNCTION__)) + && $loggerProxy->log('Test case passed', ['test' => $event->test()] + self::formatFormatTelemetryInfoSincePrevious($event)); + } + + protected function logLevelForAfterTestCaseDidNotPass(): LogLevel + { + return LogLevel::critical; + } + + private function afterTestCaseDidNotPass(PHPUnitEvent $event, PHPUnitEventCodeTest $test): void + { + ($loggerProxy = $this->logger->ifLevelEnabled($this->logLevelForAfterTestCaseDidNotPass(), __LINE__, __FUNCTION__)) + && $loggerProxy->includeStackTrace()->log('Test case did not pass', compact('test', 'event') + self::formatFormatTelemetryInfoSincePrevious($event)); + } + + public function afterTestCaseConsideredRisky(PHPUnitEventTestConsideredRisky $event): void + { + $this->afterTestCaseDidNotPass($event, $event->test()); + } + + public function afterTestCaseErrored(PHPUnitEventTestErrored $event): void + { + $this->afterTestCaseDidNotPass($event, $event->test()); + } + + public function afterTestCaseErrorTriggered(PHPUnitEventTestErrorTriggered $event): void + { + $this->afterTestCaseDidNotPass($event, $event->test()); + } + + public function afterTestCaseFailed(PHPUnitEventTestFailed $event): void + { + $this->afterTestCaseDidNotPass($event, $event->test()); + } + + public function afterTestCaseMarkedIncomplete(PHPUnitEventTestMarkedIncomplete $event): void + { + $this->afterTestCaseDidNotPass($event, $event->test()); + } + + public function afterTestCasePhpunitErrorTriggered(PHPUnitEventTestPhpunitErrorTriggered $event): void + { + $this->afterTestCaseDidNotPass($event, $event->test()); + } + + public function afterTestCasePhpunitWarningTriggered(PHPUnitEventTestPhpunitWarningTriggered $event): void + { + $this->afterTestCaseDidNotPass($event, $event->test()); + } + + public function afterTestCasePhpWarningTriggered(PHPUnitEventTestPhpWarningTriggered $event): void + { + $this->afterTestCaseDidNotPass($event, $event->test()); + } + + public function afterTestCaseSkipped(PHPUnitEventTestSkipped $event): void + { + $this->afterTestCaseDidNotPass($event, $event->test()); + } + + public function afterTestCaseWarningTriggered(PHPUnitEventTestWarningTriggered $event): void + { + $this->afterTestCaseDidNotPass($event, $event->test()); + } +} diff --git a/tests/ElasticOTelTests/Util/PHPUnitToLogConverters.php b/tests/ElasticOTelTests/Util/PHPUnitToLogConverters.php new file mode 100644 index 0000000..c576501 --- /dev/null +++ b/tests/ElasticOTelTests/Util/PHPUnitToLogConverters.php @@ -0,0 +1,113 @@ +addFinder(self::findConverter(...)); + } + + /** + * @return ?ConverterToLog + */ + public static function findConverter(object $object): ?callable + { + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $result = match (true) { + $object instanceof PHPUnitEvent => self::convertPHPUnitEvent(...), + $object instanceof PHPUnitEventCodeTestMethod => self::convertPHPUnitEventCodeTestMethod(...), + $object instanceof PHPUnitEventCodeTest => self::convertPHPUnitEventCodeTest(...), + $object instanceof PHPUnitTelemetryHRTime => self::convertPHPUnitTelemetryHRTime(...), + $object instanceof PHPUnitTelemetryInfo => self::convertPHPUnitTelemetryInfo(...), + default => null + }; + + /** @var ?ConverterToLog $result */ + return $result; // @phpstan-ignore varTag.nativeType + } + + /** + * @return array + */ + private static function convertPHPUnitEvent(PHPUnitEvent $object): array + { + return ['asString' => $object->asString(), 'telemetryInfo' => $object->telemetryInfo()]; + } + + /** + * @return array + */ + private static function convertPHPUnitEventCodeTestMethod(PHPUnitEventCodeTestMethod $object): array + { + $result = ['class::method' => ($object->className() . '::' . $object->methodName())]; + if ($object->testData()->hasDataFromDataProvider()) { + $dataSetName = $object->testData()->dataFromDataProvider()->dataSetName(); + $dataSetDesc = is_int($dataSetName) ? "#$dataSetName" : $dataSetName; + $result['data set'] = $dataSetDesc; + } + return $result; + } + + private static function convertPHPUnitEventCodeTest(PHPUnitEventCodeTest $object): string + { + return $object->id(); + } + + /** + * @return array + */ + private static function convertPHPUnitTelemetryHRTime(PHPUnitTelemetryHRTime $object): array + { + return ['seconds' => $object->seconds(), 'nanoseconds' => $object->nanoseconds()]; + } + + /** + * @return array + */ + private static function convertPHPUnitTelemetryInfo(PHPUnitTelemetryInfo $object): array + { + return [ + 'time' => $object->time(), + 'durationSincePrevious' => $object->durationSincePrevious()->asString(), + 'durationSinceStart' => $object->durationSinceStart()->asString(), + 'memoryUsage (bytes)' => $object->memoryUsage()->bytes(), + 'memoryUsageSincePrevious (bytes)' => $object->memoryUsageSincePrevious()->bytes(), + ]; + } +} diff --git a/tests/ElasticOTelTests/Util/Pair.php b/tests/ElasticOTelTests/Util/Pair.php new file mode 100644 index 0000000..9da1493 --- /dev/null +++ b/tests/ElasticOTelTests/Util/Pair.php @@ -0,0 +1,52 @@ +first = $first; + $this->second = $second; + } +} diff --git a/tests/ElasticOTelTests/Util/RandomUtil.php b/tests/ElasticOTelTests/Util/RandomUtil.php new file mode 100644 index 0000000..a2a3d20 --- /dev/null +++ b/tests/ElasticOTelTests/Util/RandomUtil.php @@ -0,0 +1,129 @@ + $totalSet + * + * @param int $subSetSize + * + * @return array + */ + public static function arrayRandValues(array $totalSet, int $subSetSize): array + { + if ($subSetSize > count($totalSet)) { + throw new InvalidArgumentException( + '$subSetSize should not be greater than $totalSet count.' + . ' $totalSet count:' . count($totalSet) . '.' + . ' $subSetSize:' . $subSetSize . '.' + ); + } + + if ($subSetSize < 0) { + throw new InvalidArgumentException( + '$subSetSize should not be negative.' + . ' $subSetSize:' . $subSetSize . '.' + ); + } + + if ($subSetSize === 0) { + return []; + } + + $randSelectedSubsetIndexes = array_rand($totalSet, $subSetSize); + if ($subSetSize === 1) { + $randSelectedSubsetIndexes = [$randSelectedSubsetIndexes]; + } + Assert::assertIsArray($randSelectedSubsetIndexes); + + $randSelectedSubsetValues = []; + foreach ($randSelectedSubsetIndexes as $index) { + Assert::assertIsInt($index); + $randSelectedSubsetValues[] = $totalSet[$index]; + } + return $randSelectedSubsetValues; + } + + /** + * @template T + * + * @param array $arr + * + * @return T + */ + public static function getRandomValueFromArray(array $arr) + { + Assert::assertGreaterThan(0, count($arr)); + return $arr[self::generateIntInRange(0, count($arr) - 1)]; + } +} diff --git a/tests/ElasticOTelTests/Util/RangeUtil.php b/tests/ElasticOTelTests/Util/RangeUtil.php new file mode 100644 index 0000000..a51f282 --- /dev/null +++ b/tests/ElasticOTelTests/Util/RangeUtil.php @@ -0,0 +1,134 @@ + + */ + public static function generate(int $begin, int $end, int $step = 1): iterable + { + for ($i = $begin; $i < $end; $i += $step) { + yield $i; + } + } + + /** + * @param int $begin + * @param int $end + * @param int $step + * + * @return iterable + */ + public static function generateDown(int $begin, int $end, int $step = 1): iterable + { + for ($i = $begin; $i > $end; $i -= $step) { + yield $i; + } + } + + /** + * @param int $count + * + * @return iterable + */ + public static function generateUpTo(int $count): iterable + { + return self::generate(0, $count); + } + + /** + * @param int $count + * + * @return iterable + */ + public static function generateDownFrom(int $count): iterable + { + for ($i = $count - 1; $i >= 0; --$i) { + yield $i; + } + } + + /** + * @param int $first + * @param int $last + * + * @return iterable + */ + public static function generateFromToIncluding(int $first, int $last): iterable + { + return self::generate($first, $last + 1); + } + + /** + * @param int $begin + * @param int $step + * + * @return iterable + * + * @noinspection PhpUnused + */ + public static function generateFrom(int $begin, int $step = 1): iterable + { + for ($i = $begin; $i <= PHP_INT_MAX; $i += $step) { + yield $i; + } + } + + /** + * @template T of int|float + * + * @phpstan-param T $rangeBegin + * @phpstan-param T $actual + * @phpstan-param T $rangeInclusiveEnd + */ + public static function isInClosedRange(int|float $rangeBegin, int|float $actual, int|float $rangeInclusiveEnd): bool + { + return ($rangeBegin <= $actual) && ($actual <= $rangeInclusiveEnd); + } + + /** + * @phpstan-assert-if-true non-negative-int $index + */ + public static function isValidIndexOfCountable(int $index, int $containerCount): bool + { + Assert::assertGreaterThanOrEqual(0, $containerCount); + return self::isInClosedRange(0, $index, $containerCount - 1); + } +} diff --git a/tests/ElasticOTelTests/Util/StackTraceUtil.php b/tests/ElasticOTelTests/Util/StackTraceUtil.php new file mode 100644 index 0000000..914603a --- /dev/null +++ b/tests/ElasticOTelTests/Util/StackTraceUtil.php @@ -0,0 +1,374 @@ +'; + public const THIS_OBJECT_KEY = 'object'; + public const ARGS_KEY = 'args'; + + /** @noinspection PhpUnused */ + public const FILE_NAME_NOT_AVAILABLE_SUBSTITUTE = 'FILE NAME N/A'; + /** @noinspection PhpUnused */ + public const LINE_NUMBER_NOT_AVAILABLE_SUBSTITUTE = 0; + + private const ELASTIC_OTEL_FQ_NAME_PREFIX = 'Elastic\\OTel\\'; + private const ELASTIC_OTEL_INTERNAL_FUNCTION_NAME_PREFIX = 'elastic_otel_'; + + private LoggerFactory $loggerFactory; + private readonly Logger $logger; + private string $namePrefixForFramesToHide; + private string $namePrefixForInternalFramesToHide; + + public function __construct( + LoggerFactory $loggerFactory, + string $namePrefixForFramesToHide = self::ELASTIC_OTEL_FQ_NAME_PREFIX, + string $namePrefixForInternalFramesToHide = self::ELASTIC_OTEL_INTERNAL_FUNCTION_NAME_PREFIX + ) { + $this->loggerFactory = $loggerFactory; + $this->logger = $this->loggerFactory->loggerForClass(LogCategoryForTests::TEST_INFRA, __NAMESPACE__, __CLASS__, __FILE__); + $this->namePrefixForFramesToHide = $namePrefixForFramesToHide; + $this->namePrefixForInternalFramesToHide = $namePrefixForInternalFramesToHide; + } + + /** + * @param non-negative-int $offset + * @param ?positive-int $maxNumberOfFrames + * + * @return ClassicFormatStackTraceFrame[] + */ + public function captureInClassicFormat(int $offset = 0, ?int $maxNumberOfFrames = null, bool $keepElasticOTelFrames = true, bool $includeThisObj = false, bool $includeArgs = false): array + { + $options = ($includeThisObj ? DEBUG_BACKTRACE_PROVIDE_OBJECT : 0) | ($includeArgs ? 0 : DEBUG_BACKTRACE_IGNORE_ARGS); + return $this->convertCaptureToClassicFormat( + // If there is non-null $maxNumberOfFrames we need to capture one more frame in PHP format + debug_backtrace($options, limit: $maxNumberOfFrames === null ? 0 : ($offset + $maxNumberOfFrames + 1)), + // $offset + 1 to exclude the frame for the current method (captureInClassicFormat) call + $offset + 1, + $maxNumberOfFrames, + $keepElasticOTelFrames, + $includeThisObj, + $includeArgs, + ); + } + + /** + * @param array> $phpFormatFrames + * @param non-negative-int $offset + * @param ?positive-int $maxNumberOfFrames + * + * @return ClassicFormatStackTraceFrame[] + */ + public function convertCaptureToClassicFormat(array $phpFormatFrames, int $offset, ?int $maxNumberOfFrames, bool $keepElasticOTelFrames, bool $includeThisObj, bool $includeArgs): array + { + if ($offset >= count($phpFormatFrames)) { + return []; + } + + return $this->convertPhpToClassicFormat( + $offset === 0 ? null : $phpFormatFrames[$offset - 1] /* <- prevPhpFormatFrame */, + $offset === 0 ? $phpFormatFrames : IterableUtil::arraySuffix($phpFormatFrames, $offset), + $maxNumberOfFrames, + $keepElasticOTelFrames, + $includeThisObj, + $includeArgs, + ); + } + + /** + * @param ?array $prevPhpFormatFrame + * @param iterable> $phpFormatFrames + * @param ?positive-int $maxNumberOfFrames + * + * @return ClassicFormatStackTraceFrame[] + */ + public function convertPhpToClassicFormat( + ?array $prevPhpFormatFrame, + iterable $phpFormatFrames, + ?int $maxNumberOfFrames, + bool $keepElasticOTelFrames, + bool $includeThisObj, + bool $includeArgs + ): array { + $allClassicFormatFrames = []; + $prevInFrame = $prevPhpFormatFrame; + foreach ($phpFormatFrames as $currentInFrame) { + $outFrame = new ClassicFormatStackTraceFrame(); + $isOutFrameEmpty = true; + if ($prevInFrame !== null && $this->hasLocationPropertiesInPhpFormat($prevInFrame)) { + $this->copyLocationPropertiesFromPhpToClassicFormat($prevInFrame, $outFrame); + $isOutFrameEmpty = false; + } + if ($this->hasNonLocationPropertiesInPhpFormat($currentInFrame)) { + $this->copyNonLocationPropertiesFromPhpToClassicFormat($currentInFrame, $includeThisObj, $includeArgs, $outFrame); + $isOutFrameEmpty = false; + } + if (!$isOutFrameEmpty) { + $allClassicFormatFrames[] = $outFrame; + } + $prevInFrame = $currentInFrame; + } + + if ($prevInFrame !== null && $this->hasLocationPropertiesInPhpFormat($prevInFrame)) { + $outFrame = new ClassicFormatStackTraceFrame(); + $this->copyLocationPropertiesFromPhpToClassicFormat($prevInFrame, $outFrame); + $allClassicFormatFrames[] = $outFrame; + } + + return $keepElasticOTelFrames + ? ($maxNumberOfFrames === null ? $allClassicFormatFrames : array_slice($allClassicFormatFrames, /* offset */ 0, $maxNumberOfFrames)) + : $this->excludeCodeToHide($allClassicFormatFrames, $maxNumberOfFrames); + } + + + /** + * @param ClassicFormatStackTraceFrame[] $inFrames + * @param ?positive-int $maxNumberOfFrames + * + * @return ClassicFormatStackTraceFrame[] + */ + private function excludeCodeToHide(array $inFrames, ?int $maxNumberOfFrames): array + { + $outFrames = []; + /** @var ?int $bufferedFromIndex */ + $bufferedFromIndex = null; + foreach (RangeUtil::generateUpTo(count($inFrames)) as $currentInFrameIndex) { + $currentInFrame = $inFrames[$currentInFrameIndex]; + if (self::isTrampolineCall($currentInFrame)) { + if ($bufferedFromIndex === null) { + $bufferedFromIndex = $currentInFrameIndex; + } + continue; + } + + if ($this->isCallToCodeToHide($currentInFrame)) { + $bufferedFromIndex = null; + continue; + } + + for ($index = $bufferedFromIndex ?? $currentInFrameIndex; $index <= $currentInFrameIndex; ++$index) { + $hasSpace = self::addToOutputFrames($inFrames[$index], $maxNumberOfFrames, /* ref */ $outFrames); + if (!$hasSpace) { + throw new RuntimeException('Unexpectedly number of frames reached max' . '; number of frames: ' . count($outFrames) . '; max: ' . $maxNumberOfFrames); + } + } + $bufferedFromIndex = null; + } + + return $outFrames; + } + + private static function isTrampolineCall(ClassicFormatStackTraceFrame $frame): bool + { + return $frame->class === null && $frame->isStaticMethod === null && ($frame->function === 'call_user_func' || $frame->function === 'call_user_func_array'); + } + + private function isCallToCodeToHide(ClassicFormatStackTraceFrame $frame): bool + { + return ($frame->class !== null && TextUtil::isPrefixOf($this->namePrefixForFramesToHide, $frame->class)) + || ($frame->function !== null && TextUtil::isPrefixOf($this->namePrefixForFramesToHide, $frame->function)) + || ($frame->function !== null && $frame->file === null && TextUtil::isPrefixOf($this->namePrefixForInternalFramesToHide, $frame->function)); + } + + /** + * @param array $frame + * + * @return ?bool + */ + private function isStaticMethodInPhpFormat(array $frame): ?bool + { + if (($funcType = self::getNullableStringValue(self::TYPE_KEY, $frame)) === null) { + return null; + } + + switch ($funcType) { + case self::FUNCTION_IS_STATIC_METHOD_TYPE_VALUE: + return true; + case self::FUNCTION_IS_METHOD_TYPE_VALUE: + return false; + default: + ($loggerProxy = $this->logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log('Unexpected `' . self::TYPE_KEY . '\' value', ['type' => $funcType]); + return null; + } + } + + /** + * @param string $key + * @param array $phpFormatFormatFrame + * + * @return ?string + */ + private function getNullableStringValue(string $key, array $phpFormatFormatFrame): ?string + { + /** @var ?string $value */ + $value = $this->getNullableValue($key, 'is_string', 'string', $phpFormatFormatFrame); + return $value; + } + + /** + * @param string $key + * @param array $phpFormatFormatFrame + * + * @return ?int + * + * @noinspection PhpSameParameterValueInspection + */ + private function getNullableIntValue(string $key, array $phpFormatFormatFrame): ?int + { + /** @var ?int $value */ + $value = $this->getNullableValue($key, 'is_int', 'int', $phpFormatFormatFrame); + return $value; + } + + /** + * @param string $key + * @param array $phpFormatFormatFrame + * + * @return ?object + * + * @noinspection PhpSameParameterValueInspection + */ + private function getNullableObjectValue(string $key, array $phpFormatFormatFrame): ?object + { + /** @var ?object $value */ + $value = $this->getNullableValue($key, 'is_object', 'object', $phpFormatFormatFrame); + return $value; + } + + /** + * @param string $key + * @param array $phpFormatFormatFrame + * + * @return null|mixed[] + * + * @noinspection PhpSameParameterValueInspection + */ + private function getNullableArrayValue(string $key, array $phpFormatFormatFrame): ?array + { + /** @var ?array $value */ + $value = $this->getNullableValue($key, 'is_array', 'array', $phpFormatFormatFrame); + return $value; + } + + /** + * @param callable(mixed): bool $isValueTypeFunc + * @param array $phpFormatFormatFrame + */ + private function getNullableValue(string $key, callable $isValueTypeFunc, string $dbgExpectedType, array $phpFormatFormatFrame): mixed + { + if (!array_key_exists($key, $phpFormatFormatFrame)) { + return null; + } + + $value = $phpFormatFormatFrame[$key]; + if ($value === null) { + return null; + } + + if (!$isValueTypeFunc($value)) { + ($loggerProxy = $this->logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) + && $loggerProxy->log( + 'Unexpected type for value under key (expected ' . $dbgExpectedType . ')', + ['$key' => $key, 'value type' => get_debug_type($value), 'value' => $value] + ); + return null; + } + + return $value; + } + + /** + * @param array $frame + */ + private function hasNonLocationPropertiesInPhpFormat(array $frame): bool + { + return $this->getNullableStringValue(self::FUNCTION_KEY, $frame) !== null; + } + + /** + * @param array $frame + */ + private function hasLocationPropertiesInPhpFormat(array $frame): bool + { + return $this->getNullableStringValue(self::FILE_KEY, $frame) !== null; + } + + /** + * @param array $srcFrame + * @param ClassicFormatStackTraceFrame $dstFrame + */ + private function copyLocationPropertiesFromPhpToClassicFormat(array $srcFrame, ClassicFormatStackTraceFrame $dstFrame): void + { + $dstFrame->file = $this->getNullableStringValue(self::FILE_KEY, $srcFrame); + $dstFrame->line = $this->getNullableIntValue(self::LINE_KEY, $srcFrame); + } + + /** + * @param array $srcFrame + */ + private function copyNonLocationPropertiesFromPhpToClassicFormat(array $srcFrame, bool $includeThisObj, bool $includeArgs, ClassicFormatStackTraceFrame $dstFrame): void + { + $dstFrame->class = $this->getNullableStringValue(self::CLASS_KEY, $srcFrame); + $dstFrame->function = $this->getNullableStringValue(self::FUNCTION_KEY, $srcFrame); + $dstFrame->isStaticMethod = $this->isStaticMethodInPhpFormat($srcFrame); + if ($includeThisObj) { + $dstFrame->thisObj = $this->getNullableObjectValue(self::THIS_OBJECT_KEY, $srcFrame); + } + if ($includeArgs) { + $dstFrame->args = $this->getNullableArrayValue(self::ARGS_KEY, $srcFrame); + } + } + + /** + * @template TOutputFrame + * + * @param TOutputFrame $frameToAdd + * @param ?positive-int $maxNumberOfFrames + * @param TOutputFrame[] &$outputFrames + */ + private static function addToOutputFrames($frameToAdd, ?int $maxNumberOfFrames, /* ref */ array &$outputFrames): bool + { + $outputFrames[] = $frameToAdd; + return (count($outputFrames) !== $maxNumberOfFrames); + } +} diff --git a/tests/ElasticOTelTests/Util/SystemTime.php b/tests/ElasticOTelTests/Util/SystemTime.php new file mode 100644 index 0000000..4000def --- /dev/null +++ b/tests/ElasticOTelTests/Util/SystemTime.php @@ -0,0 +1,37 @@ +debugContextConfigBeforeTest = DebugContextConfig::getCopy(); + + if (!$this->shouldDebugContextBeEnabledForThisTest()) { + DebugContextConfig::enabled(false); + } + } + + #[Override] + public function tearDown(): void + { + DebugContextConfig::set($this->debugContextConfigBeforeTest); + + parent::tearDown(); + } + + /** + * @param array $idToXyzMap + * + * @return string[] + */ + public static function getIdsFromIdToMap(array $idToXyzMap): array + { + /** @var string[] $result */ + $result = []; + foreach ($idToXyzMap as $id => $_) { + $result[] = strval($id); + } + return $result; + } + + /** + * @param string $namespace + * @param class-string $fqClassName + * @param string $srcCodeFile + * + * @return Logger + */ + public static function getLoggerStatic(string $namespace, string $fqClassName, string $srcCodeFile): Logger + { + return AmbientContextForTests::loggerFactory()->loggerForClass(LogCategoryForTests::TEST, $namespace, $fqClassName, $srcCodeFile); + } + + public static function dummyAssert(): bool + { + Assert::assertTrue(true); /** @phpstan-ignore staticMethod.alreadyNarrowedType */ + return true; + } + + /** + * @param iterable> $srcDataProvider + * + * @return iterable> + */ + protected static function wrapDataProviderFromKeyValueMapToNamedDataSet(iterable $srcDataProvider): iterable + { + $dataSetIndex = 0; + foreach ($srcDataProvider as $namedValuesMap) { + $dataSetName = '#' . $dataSetIndex; + $dataSetName .= ' ' . LoggableToString::convert($namedValuesMap); + yield $dataSetName => array_values($namedValuesMap); + ++$dataSetIndex; + } + } + + private const VERY_LONG_STRING_BASE_PREFIX = ' + */ + public static function dataProviderOneBoolArg(): iterable + { + foreach (BoolUtil::ALL_VALUES as $value) { + $dataSet = [$value]; + yield LoggableToString::convert($value) => $dataSet; + } + } + + /** + * @return iterable + */ + public static function dataProviderTwoBoolArgs(): iterable + { + foreach (BoolUtil::ALL_VALUES as $value1) { + foreach (BoolUtil::ALL_VALUES as $value2) { + $dataSet = [$value1, $value2]; + yield LoggableToString::convert($dataSet) => $dataSet; + } + } + } +} diff --git a/tests/ElasticOTelTests/Util/TestsInfraException.php b/tests/ElasticOTelTests/Util/TestsInfraException.php new file mode 100644 index 0000000..a252c77 --- /dev/null +++ b/tests/ElasticOTelTests/Util/TestsInfraException.php @@ -0,0 +1,30 @@ + + */ + public static function iterateOverChars(string $input): iterable + { + foreach (RangeUtil::generateUpTo(strlen($input)) as $i) { + yield ord($input[$i]); + } + } + + private static function ifEndOfLineSeqGetLength(string $text, int $textLen, int $index): int + { + $charAsInt = ord($text[$index]); + if ($charAsInt === self::CR_AS_INT && $index != ($textLen - 1) && ord($text[$index + 1]) === self::LF_AS_INT) { + return 2; + } + if ($charAsInt === self::CR_AS_INT || $charAsInt === self::LF_AS_INT) { + return 1; + } + return 0; + } + + /** + * @param string $text + * + * @return iterable + * ^^^^^^----- end-of-line (empty for the last line) + * ^^^^^^------------- line text without end-of-line + */ + public static function iterateLinesEx(string $text): iterable + { + $lineStartPos = 0; + $currentPos = $lineStartPos; + $textLen = strlen($text); + for (; $currentPos != $textLen;) { + $endOfLineSeqLength = self::ifEndOfLineSeqGetLength($text, $textLen, $currentPos); + if ($endOfLineSeqLength === 0) { + ++$currentPos; + continue; + } + yield [substr($text, $lineStartPos, $currentPos - $lineStartPos) /* <- line text without end-of-line */, substr($text, $currentPos, $endOfLineSeqLength) /* <- end-of-line */]; + $lineStartPos = $currentPos + $endOfLineSeqLength; + $currentPos = $lineStartPos; + } + + yield [substr($text, $lineStartPos, $currentPos - $lineStartPos), '' /* <- end-of-line is always empty for the last line */]; + } + + /** + * @param string $text + * @param bool $keepEndOfLine + * + * @return iterable + */ + public static function iterateLines(string $text, bool $keepEndOfLine): iterable + { + foreach (self::iterateLinesEx($text) as [$lineText, $endOfLine]) { + yield $lineText . ($keepEndOfLine ? $endOfLine : ''); + } + } + + public static function prefixEachLine(string $text, string $prefix): string + { + $result = ''; + foreach (self::iterateLines($text, keepEndOfLine: true) as $line) { + $result .= $prefix . $line; + } + return $result; + } + + public static function contains(string $haystack, string $needle): bool + { + return str_contains($haystack, $needle); + } + + public static function combineWithSeparatorIfNotEmpty(string $separator, string $partToAppend): string + { + return (TextUtil::isEmptyString($partToAppend) ? '' : $separator) . $partToAppend; + } + + /** + * @param null|int|float|string $input + * + * @noinspection PhpUnused + */ + public static function strvalEmptyIfNull(mixed $input): string + { + return $input === null ? '' : strval($input); + } + + public static function removeIndentation(string $input): string + { + $indentationChars = " \t"; + $indentationLen = strspn($input, $indentationChars); + if ($indentationLen === 0) { + return $input; + } + $indentation = substr($input, offset: 0, length: $indentationLen); + + $result = ''; + foreach (self::iterateLinesEx($input) as [$line, $endOfLine]) { + if ($line !== '' && !str_starts_with(haystack: $line, needle: $indentation)) { + throw new UnexpectedValueException(ExceptionUtil::buildMessage('Line does not start with expected indentation', compact('line', 'indentation', 'indentationLen', 'input'))); + } + $result .= substr($line, offset: $indentationLen) . $endOfLine; + } + return $result; + } +} diff --git a/tests/ElasticOTelTests/Util/TimeUtil.php b/tests/ElasticOTelTests/Util/TimeUtil.php new file mode 100644 index 0000000..9e75408 --- /dev/null +++ b/tests/ElasticOTelTests/Util/TimeUtil.php @@ -0,0 +1,196 @@ +value >= $beginTime->value) ? ($endTime->value - $beginTime->value) : 0; + } + + /** + * @template TimeWrapper of SystemTime|MonotonicTime + * + * @phpstan-param TimeWrapper $beginTime + * @phpstan-param TimeWrapper $endTime + * + * @noinspection PhpUnused + */ + public static function calcDurationInMillisecondsClampNegativeToZero(SystemTime|MonotonicTime $beginTime, SystemTime|MonotonicTime $endTime): float + { + return self::microsecondsToMilliseconds(self::calcDurationInMicrosecondsClampNegativeToZero($beginTime, $endTime)); + } + + public static function microsecondsToMilliseconds(float $microseconds): float + { + return $microseconds / self::NUMBER_OF_MICROSECONDS_IN_MILLISECOND; + } + + /** @noinspection PhpUnused */ + public static function millisecondsToMicroseconds(float $milliseconds): float + { + return $milliseconds * self::NUMBER_OF_MICROSECONDS_IN_MILLISECOND; + } + + public static function nanosecondsToMicroseconds(float $nanoseconds): float + { + return $nanoseconds / self::NUMBER_OF_NANOSECONDS_IN_MICROSECOND; + } + + public static function secondsToMicroseconds(float $seconds): float + { + return $seconds * self::NUMBER_OF_MICROSECONDS_IN_SECOND; + } + + /** @noinspection PhpUnused */ + public static function microsecondsToSeconds(float $microseconds): float + { + return $microseconds / self::NUMBER_OF_MICROSECONDS_IN_SECOND; + } + + private static function calcWholeTimesAndRemainderForFloat( + float $largeVal, + int $smallVal, + float &$wholeTimes, + float &$remainder + ): void { + $wholeTimes = floor($largeVal / $smallVal); + $remainder = $largeVal - ($smallVal * $wholeTimes); + } + + public static function formatDurationInMicroseconds(float $durationInMicroseconds): string + { + if ($durationInMicroseconds === 0.0) { + return '0us'; + } + + $isNegative = ($durationInMicroseconds < 0); + $microsecondsTotalFloat = abs($durationInMicroseconds); + $microsecondsTotalWhole = floor($microsecondsTotalFloat); + $microsecondsFraction = $microsecondsTotalFloat - $microsecondsTotalWhole; + + $millisecondsTotalWhole = 0.0; + $microsecondsRemainder = 0.0; + self::calcWholeTimesAndRemainderForFloat( + $microsecondsTotalWhole, + TimeUtil::NUMBER_OF_MICROSECONDS_IN_MILLISECOND, + /* ref */ $millisecondsTotalWhole, + /* ref */ $microsecondsRemainder + ); + + $secondsTotalWhole = 0.0; + $millisecondsRemainder = 0.0; + self::calcWholeTimesAndRemainderForFloat( + $millisecondsTotalWhole, + TimeUtil::NUMBER_OF_MILLISECONDS_IN_SECOND, + /* ref */ $secondsTotalWhole, + /* ref */ $millisecondsRemainder + ); + + $minutesTotalWhole = 0.0; + $secondsRemainder = 0.0; + self::calcWholeTimesAndRemainderForFloat( + $secondsTotalWhole, + TimeUtil::NUMBER_OF_SECONDS_IN_MINUTE, + /* ref */ $minutesTotalWhole, + /* ref */ $secondsRemainder + ); + + $hoursTotalWhole = 0.0; + $minutesRemainder = 0.0; + self::calcWholeTimesAndRemainderForFloat( + $minutesTotalWhole, + TimeUtil::NUMBER_OF_MINUTES_IN_HOUR, + /* ref */ $hoursTotalWhole, + /* ref */ $minutesRemainder + ); + + $hoursRemainder = 0.0; + $daysTotalWhole = 0.0; + self::calcWholeTimesAndRemainderForFloat( + $hoursTotalWhole, + TimeUtil::NUMBER_OF_HOURS_IN_DAY, + /* ref */ $daysTotalWhole, + /* ref */ $hoursRemainder + ); + + $appendRemainder = function (string $appendTo, float $remainder, string $units): string { + if ($remainder === 0.0) { + return $appendTo; + } + + $remainderAsString = ($remainder === floor($remainder)) ? strval(intval($remainder)) : strval($remainder); + return $appendTo . (TextUtil::isEmptyString($appendTo) ? '' : ' ') . $remainderAsString . $units; + }; + + $result = ''; + $result = $appendRemainder($result, $daysTotalWhole, 'd'); + $result = $appendRemainder($result, $hoursRemainder, 'h'); + $result = $appendRemainder($result, $minutesRemainder, 'm'); + $result = $appendRemainder($result, $secondsRemainder, 's'); + $result = $appendRemainder($result, $millisecondsRemainder, 'ms'); + $result = $appendRemainder($result, $microsecondsRemainder + $microsecondsFraction, 'us'); + + return ($isNegative ? '-' : '') . $result; + } + + /** @noinspection PhpUnused */ + public static function compareTimestamps(float $t1, float $t2): int + { + return ($t1 < $t2) ? -1 : (($t1 == $t2) ? 0 : 1); + } + + /** + * @return array + */ + public static function timestampToLoggable(float $timestamp): array + { + return ['as duration' => TimeUtil::formatDurationInMicroseconds($timestamp), 'as number' => number_format($timestamp)]; + } +} diff --git a/tests/ElasticOTelTests/VendorDir.php b/tests/ElasticOTelTests/VendorDir.php new file mode 100644 index 0000000..8094be0 --- /dev/null +++ b/tests/ElasticOTelTests/VendorDir.php @@ -0,0 +1,42 @@ +/phpcs.xml.dist +PHP source code max allowed line length is configured in /phpcs.xml 1--------10--------20--------30--------40--------50--------60--------70--------80--------90--------100-------110-------120-------130-------140-------150-------160-------170-------180-------190-------> |--------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------| diff --git a/tests/dummyFuncForTestsWithoutNamespace.php b/tests/dummyFuncForTestsWithoutNamespace.php new file mode 100644 index 0000000..ec5f233 --- /dev/null +++ b/tests/dummyFuncForTestsWithoutNamespace.php @@ -0,0 +1,42 @@ + $headers + */ +function initialize( + string $endpoint, + string $contentType, + array $headers, + float $timeout, + int $retryDelay, + int $maxRetries, +): void { +} + +/** + * This function is implemented by the extension + */ +function enqueue(string $endpoint, string $payload): void +{ +} diff --git a/tests/elastic_otel_extension_stubs/Elastic_OTel_InferredSpans_namespace.php b/tests/elastic_otel_extension_stubs/Elastic_OTel_InferredSpans_namespace.php new file mode 100644 index 0000000..327ff73 --- /dev/null +++ b/tests/elastic_otel_extension_stubs/Elastic_OTel_InferredSpans_namespace.php @@ -0,0 +1,34 @@ + $params, string $class, string $function, ?string $filename, ?int $lineno): (void|array)) $pre + * return value is modified parameters + * @phpstan-param ?(Closure(?object $thisObj, array $params, mixed $returnValue, ?Throwable $throwable): mixed) $post + * return value is modified return value + * + * @return bool Whether the observer was successfully added + * + * @see https://github.com/open-telemetry/opentelemetry-php-instrumentation + * + * @noinspection PhpUnusedParameterInspection + */ +function elastic_otel_hook(?string $class, string $function, ?Closure $pre, ?Closure $post): bool +{ + return false; +} + +/** + * This function is implemented by the extension + */ +function elastic_otel_is_enabled(): bool +{ + return false; +} diff --git a/tests/elastic_otel_extension_stubs/load.php b/tests/elastic_otel_extension_stubs/load.php new file mode 100644 index 0000000..6196091 --- /dev/null +++ b/tests/elastic_otel_extension_stubs/load.php @@ -0,0 +1,28 @@ += 8.3.0) +if (!class_exists('Override')) { + require __DIR__ . '/Override.php'; +} diff --git a/tests/substitutes/PHPUnit_Framework_AssertionFailedError/PHPUnitFrameworkAssertionFailedErrorAutoloader.php b/tests/substitutes/PHPUnit_Framework_AssertionFailedError/PHPUnitFrameworkAssertionFailedErrorAutoloader.php new file mode 100644 index 0000000..99090f3 --- /dev/null +++ b/tests/substitutes/PHPUnit_Framework_AssertionFailedError/PHPUnitFrameworkAssertionFailedErrorAutoloader.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PHPUnit\Framework; + +/** + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +class AssertionFailedError extends Exception implements SelfDescribing +{ + /** + * Wrapper for getMessage() which is declared as final. + */ + public function toString(): string + { + return $this->getMessage(); + } +} diff --git a/tests/substitutes/PHPUnit_Framework_AssertionFailedError/patched/AssertionFailedError.php b/tests/substitutes/PHPUnit_Framework_AssertionFailedError/patched/AssertionFailedError.php new file mode 100644 index 0000000..b9ba484 --- /dev/null +++ b/tests/substitutes/PHPUnit_Framework_AssertionFailedError/patched/AssertionFailedError.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** @noinspection PhpIllegalPsrClassPathInspection */ + +declare(strict_types=1); + +namespace PHPUnit\Framework; + +use Throwable; + +/** + * @phpstan-type PreProcessMessageCallback callable(AssertionFailedError $exceptionBeingConstructed, string $baseMessage, non-negative-int $numberOfStackFramesToSkip): string + */ +class AssertionFailedError extends Exception implements SelfDescribing +{ + /** @var ?PreProcessMessageCallback */ + public static mixed $preprocessMessage = null; + + public function __construct(string $message = '', int|string $code = 0, ?Throwable $previous = null) + { + if (self::$preprocessMessage !== null) { + $message = (self::$preprocessMessage)(/* exceptionBeingConstructed */ $this, /* baseMessage */ $message, /* numberOfStackFramesToSkip */ 1); + } + parent::__construct($message, $code, $previous); + } + + /** + * Wrapper for getMessage() which is declared as final. + */ + public function toString(): string + { + return $this->getMessage(); + } +} diff --git a/tests/substitutes/SubstitutesUtil.php b/tests/substitutes/SubstitutesUtil.php new file mode 100644 index 0000000..bdc66e2 --- /dev/null +++ b/tests/substitutes/SubstitutesUtil.php @@ -0,0 +1,77 @@ + $className + * + * @noinspection PhpDocMissingThrowsInspection + */ + public static function assertClassNotLoaded(string $className, bool $autoload): void + { + if (class_exists($className, $autoload)) { + throw new RuntimeException(self::appendStackTraceToMessage('Class ' . $className . ' IS loaded')); + } + } + + /** + * @param class-string $className + * + * @noinspection PhpDocMissingThrowsInspection + */ + public static function assertClassLoaded(string $className, bool $autoload): void + { + if (!class_exists($className, $autoload)) { + throw new RuntimeException(self::appendStackTraceToMessage('Class ' . $className . ' is NOT loaded')); + } + } + + /** + * @param class-string $className + * + * @noinspection PhpDocMissingThrowsInspection + */ + public static function assertClassHasProperty(string $className, string $propertyName): void + { + if (!property_exists($className, $propertyName)) { + throw new RuntimeException(self::appendStackTraceToMessage('Class ' . $className . ' does have property ' . $propertyName)); + } + } +} diff --git a/tests/substitutes/load.php b/tests/substitutes/load.php new file mode 100644 index 0000000..50ef6a5 --- /dev/null +++ b/tests/substitutes/load.php @@ -0,0 +1,25 @@ +