From db2e881e911ed2bae039f94f983371e6be62eb4e Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Thu, 14 May 2026 19:26:00 +0400 Subject: [PATCH 01/24] feat: add transcript capture for acceptance tests --- composer.json | 6 +- runtime/.gitignore | 1 + tests/Acceptance/.rr.yaml | 3 + .../Acceptance/App/Feature/WorkerFactory.php | 49 ++- .../TranscriptActivityInterceptor.php | 68 ++++ .../TranscriptWorkflowInterceptor.php | 108 ++++++ tests/Acceptance/App/Logger/FanoutLogger.php | 37 ++ tests/Acceptance/App/Logger/LoggerFactory.php | 74 +--- .../Logger/MalformedTranscriptException.php | 23 ++ .../App/Logger/TranscriptAdapter.php | 34 ++ .../Acceptance/App/Logger/TranscriptLine.php | 26 ++ .../App/Logger/TranscriptReader.php | 265 ++++++++++++++ tests/Acceptance/App/Logger/TranscriptRun.php | 58 +++ .../App/Logger/TranscriptSection.php | 22 ++ .../Acceptance/App/Logger/TranscriptStore.php | 185 ++++++++++ .../App/Logger/TranscriptWriter.php | 346 ++++++++++++++++++ tests/Acceptance/App/Runtime/FatalHandler.php | 97 +++++ tests/Acceptance/App/TestCase.php | 124 ++++++- .../App/Transport/RecordingHost.php | 46 +++ .../Transcript/TranscriptHappyPathTest.php | 61 +++ .../Extra/Transcript/TranscriptRetryTest.php | 84 +++++ .../TranscriptWorkflowFailureTest.php | 56 +++ tests/Acceptance/bootstrap.php | 16 + tests/Acceptance/transcript-merge.php | 72 ++++ tests/Acceptance/worker.php | 35 +- tests/Unit/Logger/FatalHandlerTestCase.php | 111 ++++++ .../Unit/Logger/TranscriptWriterTestCase.php | 215 +++++++++++ 27 files changed, 2157 insertions(+), 65 deletions(-) create mode 100644 tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php create mode 100644 tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php create mode 100644 tests/Acceptance/App/Logger/FanoutLogger.php create mode 100644 tests/Acceptance/App/Logger/MalformedTranscriptException.php create mode 100644 tests/Acceptance/App/Logger/TranscriptAdapter.php create mode 100644 tests/Acceptance/App/Logger/TranscriptLine.php create mode 100644 tests/Acceptance/App/Logger/TranscriptReader.php create mode 100644 tests/Acceptance/App/Logger/TranscriptRun.php create mode 100644 tests/Acceptance/App/Logger/TranscriptSection.php create mode 100644 tests/Acceptance/App/Logger/TranscriptStore.php create mode 100644 tests/Acceptance/App/Logger/TranscriptWriter.php create mode 100644 tests/Acceptance/App/Runtime/FatalHandler.php create mode 100644 tests/Acceptance/App/Transport/RecordingHost.php create mode 100644 tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php create mode 100644 tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php create mode 100644 tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php create mode 100644 tests/Acceptance/transcript-merge.php create mode 100644 tests/Unit/Logger/FatalHandlerTestCase.php create mode 100644 tests/Unit/Logger/TranscriptWriterTestCase.php diff --git a/composer.json b/composer.json index 701789190..8dd098366 100644 --- a/composer.json +++ b/composer.json @@ -104,7 +104,11 @@ "test:arch": "phpunit --testsuite=Arch --color=always --testdox", "test:accept": "tests/runner.php vendor/bin/phpunit --testsuite=Acceptance --color=always --testdox", "test:accept-slow": "tests/runner.php vendor/bin/phpunit --testsuite=\"Acceptance-Slow\" --color=always --testdox", - "test:accept-fast": "tests/runner.php vendor/bin/phpunit --testsuite=\"Acceptance-Fast\" --color=always --testdox" + "test:accept-fast": "tests/runner.php vendor/bin/phpunit --testsuite=\"Acceptance-Fast\" --color=always --testdox", + "transcripts:last": "php tests/Acceptance/transcript-merge.php", + "transcripts:list": "php tests/Acceptance/transcript-merge.php --list", + "transcripts:merge": "php tests/Acceptance/transcript-merge.php", + "transcripts:clean": "rm -rf runtime/tests/transcripts/*" }, "config": { "sort-packages": true, diff --git a/runtime/.gitignore b/runtime/.gitignore index 72e8ffc0d..d6b7ef32c 100644 --- a/runtime/.gitignore +++ b/runtime/.gitignore @@ -1 +1,2 @@ * +!.gitignore diff --git a/tests/Acceptance/.rr.yaml b/tests/Acceptance/.rr.yaml index 13e4a9df0..e26335039 100644 --- a/tests/Acceptance/.rr.yaml +++ b/tests/Acceptance/.rr.yaml @@ -4,6 +4,9 @@ rpc: server: command: "php worker.php" + env: + TEMPORAL_WIRE_TRACE: "1" + TEMPORAL_TRANSCRIPT_DIR: "runtime/tests/transcripts" # Workflow and activity mesh service temporal: diff --git a/tests/Acceptance/App/Feature/WorkerFactory.php b/tests/Acceptance/App/Feature/WorkerFactory.php index c2c14f741..4e1688efe 100644 --- a/tests/Acceptance/App/Feature/WorkerFactory.php +++ b/tests/Acceptance/App/Feature/WorkerFactory.php @@ -4,13 +4,23 @@ namespace Temporal\Tests\Acceptance\App\Feature; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Spiral\Core\Attribute\Singleton; +use Spiral\Core\Container\InjectorInterface; use Spiral\Core\InvokerInterface; +use Temporal\Client\WorkflowStubInterface; +use Temporal\Interceptor\PipelineProvider; +use Temporal\Interceptor\SimplePipelineProvider; +use Temporal\Plugin\CompositePipelineProvider; use Temporal\Tests\Acceptance\App\Attribute\Worker; +use Temporal\Tests\Acceptance\App\Interceptor\TranscriptActivityInterceptor; +use Temporal\Tests\Acceptance\App\Interceptor\TranscriptWorkflowInterceptor; use Temporal\Tests\Acceptance\App\Logger\LoggerFactory; +use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\Feature; -use Spiral\Core\Container\InjectorInterface; -use Temporal\Client\WorkflowStubInterface; +use Temporal\Worker\Logger\StderrLogger; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; use Temporal\Worker\WorkerOptions; @@ -36,7 +46,7 @@ public function createWorker( ...$feature->activities, ); $options = $attr?->options === null ? null : $this->invoker->invoke($attr->options); - $interceptorProvider = $attr?->pipelineProvider === null ? null : $this->invoker->invoke($attr->pipelineProvider); + $featureProvider = $attr?->pipelineProvider === null ? null : $this->invoker->invoke($attr->pipelineProvider); $logger = $attr?->logger === null ? null : $this->invoker->invoke($attr->logger); // Add plugins from the attribute to the factory's registry (already instantiated, no invoker needed) @@ -44,14 +54,45 @@ public function createWorker( $this->workerFactory->getPluginRegistry()->merge($attr->plugins); } + $interceptorProvider = $this->composeTranscriptProvider($featureProvider); + return $this->workerFactory->newWorker( $feature->taskQueue, $options ?? WorkerOptions::new()->withMaxConcurrentActivityExecutionSize(10), interceptorProvider: $interceptorProvider, - logger: $logger ?? LoggerFactory::createServerLogger($feature->taskQueue), + logger: $logger ?? $this->buildLoggerForFeature($feature), ); } + private function composeTranscriptProvider(?PipelineProvider $base): PipelineProvider + { + $transcriptInterceptors = [ + new TranscriptActivityInterceptor(), + new TranscriptWorkflowInterceptor(), + ]; + if ($base === null) { + return new SimplePipelineProvider($transcriptInterceptors); + } + return new CompositePipelineProvider($transcriptInterceptors, $base); + } + + private function buildLoggerForFeature(Feature $feature): LoggerInterface + { + $container = ContainerFacade::$container ?? null; + if ($container === null || !$container->has(TranscriptWriter::class)) { + return LoggerFactory::createServerLogger($feature->taskQueue); + } + try { + $transcript = $container->get(TranscriptWriter::class); + $stderr = $container->has(StderrLogger::class) + ? $container->get(StderrLogger::class) + : new NullLogger(); + return LoggerFactory::createServerLoggerWithTranscript($feature->taskQueue, $transcript, $stderr); + } catch (\Throwable) { + return LoggerFactory::createServerLogger($feature->taskQueue); + } + } + /** * Find {@see Worker} attribute in the classes collection. * If more than one attribute is found, an exception is thrown. diff --git a/tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php b/tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php new file mode 100644 index 000000000..4169eaca9 --- /dev/null +++ b/tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php @@ -0,0 +1,68 @@ +resolveWriter(); + $attributes = $this->buildAttributes(); + $writer?->writeMeta('activity_start', $attributes); + try { + $result = $next($input); + $writer?->writeMeta('activity_completed', $attributes); + return $result; + } catch (\Throwable $exception) { + $writer?->writeException('activity_throw', $attributes, $exception); + throw $exception; + } + } + + /** + * @return array + */ + private function buildAttributes(): array + { + try { + $info = Activity::getInfo(); + return [ + 'name' => $info->type->name, + 'attempt' => $info->attempt, + 'activity_id' => $info->id, + 'workflow_id' => $info->workflowExecution->getID(), + 'run_id' => $info->workflowExecution->getRunID(), + ]; + } catch (\Throwable) { + return ['name' => 'unknown', 'attempt' => 0]; + } + } + + private function resolveWriter(): ?TranscriptWriter + { + if ($this->writer !== null) { + return $this->writer; + } + try { + $container = ContainerFacade::$container ?? null; + if ($container !== null && $container->has(TranscriptWriter::class)) { + $this->writer = $container->get(TranscriptWriter::class); + } + } catch (\Throwable) { + } + return $this->writer; + } +} diff --git a/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php b/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php new file mode 100644 index 000000000..bf8d7eacc --- /dev/null +++ b/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php @@ -0,0 +1,108 @@ + $input->info->type->name, + 'workflow_id' => $input->info->execution->getID(), + 'run_id' => $input->info->execution->getRunID(), + 'is_replaying' => $input->isReplaying, + ]; + $this->runPhase('workflow_execute', $attributes, static fn() => $next($input)); + } + + public function handleSignal(SignalInput $input, callable $next): void + { + $attributes = [ + 'signal_name' => $input->signalName, + 'workflow_id' => $input->info->execution->getID(), + 'is_replaying' => $input->isReplaying, + ]; + $this->runPhase('workflow_signal', $attributes, static fn() => $next($input)); + } + + public function handleQuery(QueryInput $input, callable $next): mixed + { + $attributes = [ + 'query_name' => $input->queryName, + 'workflow_id' => $input->info->execution->getID(), + ]; + return $this->runPhase('workflow_query', $attributes, static fn() => $next($input)); + } + + public function handleUpdate(UpdateInput $input, callable $next): mixed + { + $attributes = [ + 'update_name' => $input->updateName, + 'update_id' => $input->updateId, + 'workflow_id' => $input->info->execution->getID(), + 'is_replaying' => $input->isReplaying, + ]; + return $this->runPhase('workflow_update', $attributes, static fn() => $next($input)); + } + + public function validateUpdate(UpdateInput $input, callable $next): void + { + $attributes = [ + 'update_name' => $input->updateName, + 'update_id' => $input->updateId, + 'workflow_id' => $input->info->execution->getID(), + 'is_replaying' => $input->isReplaying, + ]; + $this->runPhase('workflow_validate_update', $attributes, static fn() => $next($input)); + } + + /** + * @template T + * @param array $attributes + * @param callable(): T $execution + * @return T + */ + private function runPhase(string $phase, array $attributes, callable $execution): mixed + { + $writer = $this->resolveWriter(); + $writer?->writeMeta($phase . '_start', $attributes); + try { + $result = $execution(); + $writer?->writeMeta($phase . '_completed', $attributes); + return $result; + } catch (\Throwable $exception) { + $writer?->writeException($phase, $attributes, $exception); + throw $exception; + } + } + + private function resolveWriter(): ?TranscriptWriter + { + if ($this->writer !== null) { + return $this->writer; + } + try { + $container = ContainerFacade::$container ?? null; + if ($container !== null && $container->has(TranscriptWriter::class)) { + $this->writer = $container->get(TranscriptWriter::class); + } + } catch (\Throwable) { + } + return $this->writer; + } +} diff --git a/tests/Acceptance/App/Logger/FanoutLogger.php b/tests/Acceptance/App/Logger/FanoutLogger.php new file mode 100644 index 000000000..74316921c --- /dev/null +++ b/tests/Acceptance/App/Logger/FanoutLogger.php @@ -0,0 +1,37 @@ + */ + private readonly array $sinks; + + public function __construct( + private readonly LoggerInterface $stderr, + LoggerInterface ...$sinks, + ) { + $this->sinks = $sinks; + } + + public function log($level, \Stringable|string $message, array $context = []): void + { + foreach ($this->sinks as $sink) { + try { + $sink->log($level, $message, $context); + } catch (\Throwable $error) { + $this->stderr->error('fanout-logger-error', [ + 'sink' => $sink::class, + 'message' => $error->getMessage(), + ]); + } + } + } +} diff --git a/tests/Acceptance/App/Logger/LoggerFactory.php b/tests/Acceptance/App/Logger/LoggerFactory.php index 5dc4d4f95..3a8df7280 100644 --- a/tests/Acceptance/App/Logger/LoggerFactory.php +++ b/tests/Acceptance/App/Logger/LoggerFactory.php @@ -4,65 +4,29 @@ namespace Temporal\Tests\Acceptance\App\Logger; -/** - * Factory for creating logger instances used in acceptance tests. - * - * Provides methods to create both server-side and client-side loggers with appropriate - * configuration for test environments. - */ +use Psr\Log\LoggerInterface; + final class LoggerFactory { - /** @var non-empty-string Default relative path for test logs */ private const DEFAULT_LOG_DIR = 'runtime/tests/logs'; - /** - * Create a server-side file logger. - * - * @param non-empty-string $taskQueue Task queue name used to identify the log file - * @param non-empty-string|null $baseDir Optional base directory, defaults to project root - * @return FileLogger Configured file logger instance - */ public static function createServerLogger( string $taskQueue, ?string $baseDir = null, ): FileLogger { - $logDir = self::getLogDirectory($baseDir); - return new FileLogger($logDir, $taskQueue); + return new FileLogger(self::getLogDirectory($baseDir), $taskQueue); } - /** - * Create a client-side logger for assertions. - * - * The client logger provides methods for reading and analyzing log entries - * created by the server-side logger. - * - * @param non-empty-string $taskQueue Task queue name used to identify the log file - * @param non-empty-string|null $baseDir Optional base directory, defaults to project root - * @return ClientLogger Configured client logger instance - */ public static function createClientLogger( string $taskQueue, ?string $baseDir = null, ): ClientLogger { - $logDir = self::getLogDirectory($baseDir); - return new ClientLogger($logDir, $taskQueue); + return new ClientLogger(self::getLogDirectory($baseDir), $taskQueue); } - /** - * Generate the log filename for a specific task queue. - * - * Uses sha1 hash of task queue name to handle special characters. - * - * @param non-empty-string $dir Directory path - * @param non-empty-string $taskQueue Task queue name - * @return non-empty-string Full path to the log file - */ - public static function getLogFilename( - string $dir, - string $taskQueue, - ): string { + public static function getLogFilename(string $dir, string $taskQueue): string + { $filename = \sha1($taskQueue) . '.log'; - return \preg_replace( '#/{2,}#', '/', @@ -70,24 +34,26 @@ public static function getLogFilename( ); } - /** - * Get the absolute path to the log directory. - * - * Creates the directory if it doesn't exist. - * - * @param non-empty-string|null $baseDir Optional base directory, defaults to project root - * @return non-empty-string Absolute path to the log directory - */ + public static function createServerLoggerWithTranscript( + string $taskQueue, + TranscriptWriter $transcript, + LoggerInterface $stderr, + ?string $baseDir = null, + ): FanoutLogger { + return new FanoutLogger( + $stderr, + self::createServerLogger($taskQueue, $baseDir), + new TranscriptAdapter($transcript, $stderr), + ); + } + private static function getLogDirectory(?string $baseDir = null): string { - $baseDir ??= \dirname(__DIR__, 4); // Go up to project root + $baseDir ??= \dirname(__DIR__, 4); $logDir = $baseDir . '/' . self::DEFAULT_LOG_DIR; - - // Ensure directory exists if (!\is_dir($logDir)) { \mkdir($logDir, 0777, true); } - return $logDir; } } diff --git a/tests/Acceptance/App/Logger/MalformedTranscriptException.php b/tests/Acceptance/App/Logger/MalformedTranscriptException.php new file mode 100644 index 000000000..aa74c4490 --- /dev/null +++ b/tests/Acceptance/App/Logger/MalformedTranscriptException.php @@ -0,0 +1,23 @@ +stderr = $stderr ?? new NullLogger(); + } + + public function log($level, \Stringable|string $message, array $context = []): void + { + try { + $this->writer->writeLog((string) $level, (string) $message, $context); + } catch (\Throwable $error) { + $this->stderr->error('transcript-adapter-error', [ + 'message' => $error->getMessage(), + ]); + } + } +} diff --git a/tests/Acceptance/App/Logger/TranscriptLine.php b/tests/Acceptance/App/Logger/TranscriptLine.php new file mode 100644 index 000000000..06bff30ae --- /dev/null +++ b/tests/Acceptance/App/Logger/TranscriptLine.php @@ -0,0 +1,26 @@ + $attributes + */ + public function __construct( + public readonly \DateTimeImmutable $timestamp, + public readonly int $processId, + public readonly int $sequence, + public readonly TranscriptSection $section, + public readonly array $attributes, + public readonly ?array $payload, + public readonly string $rawLine, + ) {} + + public function getAttribute(string $key): string|int|float|bool|null + { + return $this->attributes[$key] ?? null; + } +} diff --git a/tests/Acceptance/App/Logger/TranscriptReader.php b/tests/Acceptance/App/Logger/TranscriptReader.php new file mode 100644 index 000000000..faf47dacc --- /dev/null +++ b/tests/Acceptance/App/Logger/TranscriptReader.php @@ -0,0 +1,265 @@ + */ + private array $files; + + public function __construct(string $directory) + { + $matches = \glob($directory . '/*.log'); + $this->files = \is_array($matches) ? $matches : []; + } + + /** + * @return list + */ + public function getLines(): array + { + $lines = []; + foreach ($this->files as $file) { + $lineNumber = 0; + $handle = @\fopen($file, 'rb'); + if ($handle === false) { + continue; + } + try { + while (($raw = \fgets($handle)) !== false) { + $lineNumber++; + $raw = \rtrim($raw, "\n"); + if ($raw === '') { + continue; + } + $parsed = $this->parseLine($raw, $file, $lineNumber); + if ($parsed !== null) { + $lines[] = $parsed; + } + } + } finally { + \fclose($handle); + } + } + \usort( + $lines, + static fn(TranscriptLine $a, TranscriptLine $b): int => + $a->timestamp <=> $b->timestamp + ?: $a->processId <=> $b->processId + ?: $a->sequence <=> $b->sequence, + ); + return $lines; + } + + /** + * @return list + */ + public function findBySection(TranscriptSection $section): array + { + return \array_values(\array_filter( + $this->getLines(), + static fn(TranscriptLine $line): bool => $line->section === $section, + )); + } + + /** + * @return list + */ + public function getFiles(): array + { + return $this->files; + } + + /** + * Return lines that fall between the TEST_START and TEST_END boundaries for a specific test. + * If multiple boundary pairs exist (re-runs), the latest pair wins. + * + * @return list + */ + public function linesForTest(string $class, string $method): array + { + $lines = $this->getLines(); + $startLine = null; + $endLine = null; + foreach ($lines as $line) { + if ($line->section === TranscriptSection::TEST_START + && ($line->attributes['class'] ?? null) === $class + && ($line->attributes['method'] ?? null) === $method + ) { + $startLine = $line; + $endLine = null; + continue; + } + if ($line->section === TranscriptSection::TEST_END + && ($line->attributes['class'] ?? null) === $class + && ($line->attributes['method'] ?? null) === $method + ) { + $endLine = $line; + } + } + if ($startLine === null) { + return []; + } + $startTimestamp = $startLine->timestamp; + $endTimestamp = $endLine?->timestamp; + return \array_values(\array_filter( + $lines, + static function (TranscriptLine $candidate) use ($startTimestamp, $endTimestamp): bool { + if ($candidate->timestamp < $startTimestamp) { + return false; + } + if ($endTimestamp !== null && $candidate->timestamp > $endTimestamp) { + return false; + } + return true; + }, + )); + } + + private function parseLine(string $raw, string $file, int $lineNumber): ?TranscriptLine + { + if (!\preg_match( + '/^(?P\S+)\s+(?P\d+)\s+(?P\d+)\s+\[(?P
[A-Z_]+)\](?P.*)$/', + $raw, + $matches, + )) { + throw new MalformedTranscriptException( + 'Line does not match transcript schema', + $raw, + $lineNumber, + $file, + ); + } + + $sectionEnum = TranscriptSection::tryFrom($matches['section']); + if ($sectionEnum === null) { + throw new MalformedTranscriptException( + 'Unknown section: ' . $matches['section'], + $raw, + $lineNumber, + $file, + ); + } + + $tail = \ltrim($matches['tail']); + $payload = null; + $attributesPart = $tail; + $payloadMarker = ' payload='; + $payloadPosition = \strpos($tail, $payloadMarker); + if ($payloadPosition !== false) { + $attributesPart = \substr($tail, 0, $payloadPosition); + $payloadJson = \substr($tail, $payloadPosition + \strlen($payloadMarker)); + $decoded = \json_decode($payloadJson, true); + if ($decoded !== null || \json_last_error() === \JSON_ERROR_NONE) { + $payload = \is_array($decoded) ? $decoded : ['value' => $decoded]; + } else { + $payload = ['raw' => $payloadJson]; + } + } elseif (\str_starts_with($tail, 'payload=')) { + $payloadJson = \substr($tail, 8); + $decoded = \json_decode($payloadJson, true); + $payload = \is_array($decoded) ? $decoded : ['raw' => $payloadJson]; + $attributesPart = ''; + } + + $attributes = $this->parseAttributes($attributesPart); + + try { + $timestamp = new \DateTimeImmutable($matches['timestamp']); + } catch (\Throwable) { + throw new MalformedTranscriptException( + 'Invalid timestamp', + $raw, + $lineNumber, + $file, + ); + } + + return new TranscriptLine( + timestamp: $timestamp, + processId: (int) $matches['processId'], + sequence: (int) $matches['sequence'], + section: $sectionEnum, + attributes: $attributes, + payload: $payload, + rawLine: $raw, + ); + } + + /** + * @return array + */ + private function parseAttributes(string $attributesPart): array + { + $attributes = []; + $position = 0; + $length = \strlen($attributesPart); + while ($position < $length) { + while ($position < $length && $attributesPart[$position] === ' ') { + $position++; + } + if ($position >= $length) { + break; + } + $equalsPosition = \strpos($attributesPart, '=', $position); + if ($equalsPosition === false) { + break; + } + $key = \substr($attributesPart, $position, $equalsPosition - $position); + $valuePosition = $equalsPosition + 1; + if ($valuePosition < $length && $attributesPart[$valuePosition] === '"') { + $valuePosition++; + $valueStart = $valuePosition; + $value = ''; + while ($valuePosition < $length) { + $character = $attributesPart[$valuePosition]; + if ($character === '\\' && $valuePosition + 1 < $length) { + $value .= $attributesPart[$valuePosition + 1]; + $valuePosition += 2; + continue; + } + if ($character === '"') { + $valuePosition++; + break; + } + $value .= $character; + $valuePosition++; + } + } else { + $spacePosition = \strpos($attributesPart, ' ', $valuePosition); + if ($spacePosition === false) { + $value = \substr($attributesPart, $valuePosition); + $valuePosition = $length; + } else { + $value = \substr($attributesPart, $valuePosition, $spacePosition - $valuePosition); + $valuePosition = $spacePosition; + } + } + $attributes[$key] = $this->coerceAttributeValue($value); + $position = $valuePosition; + } + return $attributes; + } + + private function coerceAttributeValue(string $value): string|int|float|bool|null + { + if ($value === 'null') { + return null; + } + if ($value === 'true') { + return true; + } + if ($value === 'false') { + return false; + } + if ($value !== '' && \preg_match('/^-?\d+$/', $value) === 1) { + return (int) $value; + } + if ($value !== '' && \preg_match('/^-?\d+\.\d+$/', $value) === 1) { + return (float) $value; + } + return $value; + } +} diff --git a/tests/Acceptance/App/Logger/TranscriptRun.php b/tests/Acceptance/App/Logger/TranscriptRun.php new file mode 100644 index 000000000..d2d8c145d --- /dev/null +++ b/tests/Acceptance/App/Logger/TranscriptRun.php @@ -0,0 +1,58 @@ + + */ + public function files(): array + { + $files = \glob($this->directory . '/*.log'); + return $files === false ? [] : \array_values($files); + } + + public function totalBytes(): int + { + $bytes = 0; + foreach ($this->files() as $file) { + $bytes += (int) @\filesize($file); + } + return $bytes; + } + + public function reader(): TranscriptReader + { + return new TranscriptReader($this->directory); + } + + public function merge(): string + { + $mergedDirectory = $this->directory . '/_merged'; + if (!\is_dir($mergedDirectory)) { + @\mkdir($mergedDirectory, 0777, true); + } + $path = $mergedDirectory . '/transcript.log'; + $handle = \fopen($path, 'wb'); + if ($handle === false) { + throw new \RuntimeException("Failed to open merged file: {$path}"); + } + try { + foreach ($this->reader()->getLines() as $line) { + \fwrite($handle, $line->rawLine . "\n"); + } + } finally { + \fclose($handle); + } + return $path; + } +} diff --git a/tests/Acceptance/App/Logger/TranscriptSection.php b/tests/Acceptance/App/Logger/TranscriptSection.php new file mode 100644 index 000000000..78f17247f --- /dev/null +++ b/tests/Acceptance/App/Logger/TranscriptSection.php @@ -0,0 +1,22 @@ +stderr = $stderr ?? new NullLogger(); + if (!\is_dir($this->baseDirectory)) { + @\mkdir($this->baseDirectory, 0777, true); + } + } + + public static function create(?string $projectRoot = null, ?LoggerInterface $stderr = null): self + { + $projectRoot ??= \dirname(__DIR__, 4); + $configured = \getenv(self::BASE_DIR_ENV); + if (\is_string($configured) && $configured !== '') { + $base = \str_starts_with($configured, '/') + ? $configured + : $projectRoot . '/' . $configured; + return new self($base, $stderr); + } + return new self($projectRoot . '/' . self::DEFAULT_BASE_RELATIVE, $stderr); + } + + public static function generateRunId(): string + { + return \date('Ymd-His') . '-' . \bin2hex(\random_bytes(2)); + } + + public static function currentRunIdFromEnvironment(): ?string + { + $runId = \getenv(self::RUN_ID_ENV); + return \is_string($runId) && $runId !== '' ? $runId : null; + } + + public function runDirectory(string $runId): string + { + return $this->baseDirectory . '/' . self::sanitizeRunId($runId); + } + + public function ensureRunDirectory(string $runId): string + { + $directory = $this->runDirectory($runId); + if (!\is_dir($directory)) { + @\mkdir($directory, 0777, true); + } + return $directory; + } + + /** + * @return list + */ + public function listRuns(): array + { + $entries = @\scandir($this->baseDirectory); + if ($entries === false) { + return []; + } + $runs = []; + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..' || \str_starts_with($entry, '_')) { + continue; + } + $path = $this->baseDirectory . '/' . $entry; + if (!\is_dir($path)) { + continue; + } + $mtime = @\filemtime($path); + $runs[] = new TranscriptRun( + id: $entry, + directory: $path, + mtime: $mtime === false ? null : $mtime, + ); + } + \usort( + $runs, + static fn(TranscriptRun $a, TranscriptRun $b): int => ($b->mtime ?? 0) <=> ($a->mtime ?? 0), + ); + return $runs; + } + + public function latestRun(): ?TranscriptRun + { + return $this->listRuns()[0] ?? null; + } + + public function findRun(string $runId): ?TranscriptRun + { + $sanitized = self::sanitizeRunId($runId); + $directory = $this->baseDirectory . '/' . $sanitized; + if (!\is_dir($directory)) { + return null; + } + $mtime = @\filemtime($directory); + return new TranscriptRun( + id: $sanitized, + directory: $directory, + mtime: $mtime === false ? null : $mtime, + ); + } + + public function currentRun(): ?TranscriptRun + { + $runId = self::currentRunIdFromEnvironment(); + return $runId === null ? $this->latestRun() : $this->findRun($runId); + } + + public function pruneOldRuns(int $keep): int + { + $keep = \max(0, $keep); + $stale = \array_slice($this->listRuns(), $keep); + $deleted = 0; + foreach ($stale as $run) { + if ($this->removeDirectoryRecursive($run->directory)) { + $deleted++; + } + } + return $deleted; + } + + public function createWriter(string $runId, string $processLabel): TranscriptWriter + { + $directory = $this->ensureRunDirectory($runId); + return new TranscriptWriter(self::buildFilename($directory, $processLabel), $this->stderr); + } + + private static function sanitizeRunId(string $runId): string + { + $slug = \preg_replace('~[^A-Za-z0-9_-]~', '_', $runId) ?? ''; + if ($slug === '') { + return 'run'; + } + return \strlen($slug) > 64 ? \substr($slug, 0, 64) : $slug; + } + + private static function buildFilename(string $directory, string $processLabel): string + { + $slug = \preg_replace('~[^A-Za-z0-9_-]~', '_', $processLabel) ?? 'process'; + if (\strlen($slug) > 40) { + $slug = \substr($slug, 0, 40); + } + $processId = \getmypid() ?: 0; + $startMs = (int) (\microtime(true) * 1000); + return $directory . '/' . $slug . '__pid' . $processId . '__' . $startMs . '.log'; + } + + private function removeDirectoryRecursive(string $path): bool + { + if (!\is_dir($path)) { + return false; + } + $entries = @\scandir($path); + if ($entries === false) { + return false; + } + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $child = $path . '/' . $entry; + if (\is_dir($child)) { + $this->removeDirectoryRecursive($child); + continue; + } + @\unlink($child); + } + return @\rmdir($path); + } +} diff --git a/tests/Acceptance/App/Logger/TranscriptWriter.php b/tests/Acceptance/App/Logger/TranscriptWriter.php new file mode 100644 index 000000000..0cd80aa51 --- /dev/null +++ b/tests/Acceptance/App/Logger/TranscriptWriter.php @@ -0,0 +1,346 @@ +processId = \getmypid() ?: 0; + $this->currentPath = $path; + $this->stderr = $stderr ?? new NullLogger(); + $this->openFileDescriptor($path); + $this->writeMeta('writer_initialized', [ + 'path' => $path, + 'worker_start_epoch_ms' => (int) (\microtime(true) * 1000), + ]); + \register_shutdown_function(function (): void { + if ($this->fileDescriptor !== null) { + @\fflush($this->fileDescriptor); + } + }); + } + + public function getPath(): string + { + return $this->currentPath; + } + + /** + * @param array $attributes + */ + public function write( + TranscriptSection $section, + array $attributes = [], + mixed $payload = null, + ): void { + if ($this->inWrite) { + return; + } + $this->inWrite = true; + try { + $this->doWrite($section, $attributes, $payload); + } catch (\Throwable $e) { + $this->stderr->error('transcript-writer-internal-error', [ + 'class' => $e::class, + 'message' => $e->getMessage(), + ]); + } finally { + $this->inWrite = false; + } + } + + public function writeLog(string $level, string $message, array $context = []): void + { + $this->write(TranscriptSection::LOG, [ + 'level' => $level, + 'message' => $this->oneLine($message), + ], $context === [] ? null : $context); + } + + public function writeWireInbound(string $frame, array $headers, int $frameId): void + { + $this->write(TranscriptSection::WIRE_INBOUND, [ + 'frame_id' => $frameId, + 'bytes' => \strlen($frame), + ], [ + 'headers' => $headers, + 'body' => $this->safeDecodeFrame($frame), + ]); + } + + public function writeWireOutbound(string $frame, int $frameId): void + { + $this->write(TranscriptSection::WIRE_OUTBOUND, [ + 'frame_id' => $frameId, + 'bytes' => \strlen($frame), + ], [ + 'body' => $this->safeDecodeFrame($frame), + ]); + } + + public function writeWireError(\Throwable $error): void + { + $this->write(TranscriptSection::WIRE_ERROR, [ + 'class' => $error::class, + ], [ + 'message' => $error->getMessage(), + 'trace' => $error->getTraceAsString(), + ]); + } + + public function writeException(string $phase, array $attributes, \Throwable $exception): void + { + $this->write(TranscriptSection::EXCEPTION, ['phase' => $phase] + $attributes, [ + 'class' => $exception::class, + 'message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + 'previous' => $exception->getPrevious()?->getMessage(), + ]); + } + + public function writeFatal(\Throwable $throwable): void + { + $this->write(TranscriptSection::FATAL, [ + 'class' => $throwable::class, + ], [ + 'message' => $throwable->getMessage(), + 'trace' => $throwable->getTraceAsString(), + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + ]); + } + + /** + * @param array $errorRecord + */ + public function writeFatalFromError(array $errorRecord): void + { + $this->write(TranscriptSection::FATAL, [ + 'type' => (int) ($errorRecord['type'] ?? 0), + 'file' => (string) ($errorRecord['file'] ?? ''), + 'line' => (int) ($errorRecord['line'] ?? 0), + ], [ + 'message' => (string) ($errorRecord['message'] ?? ''), + ]); + } + + public function writeError(int $type, string $message, string $file, int $line): void + { + $this->write(TranscriptSection::ERROR, [ + 'type' => $type, + 'file' => $file, + 'line' => $line, + ], [ + 'message' => $message, + ]); + } + + public function writeTestBoundary(TranscriptSection $boundary, array $attributes): void + { + if ($boundary !== TranscriptSection::TEST_START && $boundary !== TranscriptSection::TEST_END) { + return; + } + $this->write($boundary, $attributes); + } + + public function writeHistoryEvent(string $workflowId, string $runId, array $eventAttributes, string $attributesJson): void + { + $this->write(TranscriptSection::HISTORY, [ + 'workflow_id' => $workflowId, + 'run_id' => $runId, + ] + $eventAttributes, [ + 'attrs' => $attributesJson, + ]); + } + + public function writeHistoryError(string $workflowId, \Throwable $error): void + { + $this->write(TranscriptSection::HISTORY_ERROR, [ + 'workflow_id' => $workflowId, + 'class' => $error::class, + ], [ + 'message' => $error->getMessage(), + ]); + } + + public function writeMeta(string $event, array $attributes = []): void + { + $this->write(TranscriptSection::META, ['event' => $event] + $attributes); + } + + public function flush(): void + { + if ($this->fileDescriptor === null) { + return; + } + @\fflush($this->fileDescriptor); + } + + /** + * @param array $attributes + */ + private function doWrite(TranscriptSection $section, array $attributes, mixed $payload): void + { + if ($this->fileDescriptor === null) { + return; + } + $this->rotateIfNeeded(); + + $this->sequence++; + $timestamp = (new \DateTimeImmutable('now'))->format('Y-m-d\TH:i:s.uP'); + $line = $timestamp . ' ' + . $this->processId . ' ' + . $this->sequence . ' ' + . '[' . $section->value . '] ' + . $this->formatAttributes($attributes); + + if ($payload !== null) { + $line .= ' payload=' . $this->encodePayload($payload); + } + + $line .= "\n"; + + if (!\flock($this->fileDescriptor, \LOCK_EX)) { + $this->stderr->error('transcript-writer-internal-error: flock failed'); + return; + } + try { + \fwrite($this->fileDescriptor, $line); + \fflush($this->fileDescriptor); + } finally { + \flock($this->fileDescriptor, \LOCK_UN); + } + } + + private function rotateIfNeeded(): void + { + $stat = @\fstat($this->fileDescriptor); + if ($stat === false) { + return; + } + if ($stat['size'] < self::SIZE_CAP_BYTES) { + return; + } + $this->rotationCounter++; + $rotated = $this->currentPath . '.' . $this->rotationCounter; + @\rename($this->currentPath, $rotated); + $this->openFileDescriptor($this->currentPath); + $this->writeMeta('writer_rotated', [ + 'from' => $rotated, + 'to' => $this->currentPath, + 'reason' => 'size_cap', + ]); + } + + private function openFileDescriptor(string $path): void + { + $directory = \dirname($path); + if (!\is_dir($directory)) { + @\mkdir($directory, 0777, true); + } + $resource = @\fopen($path, 'ab'); + if ($resource === false) { + $this->stderr->error('transcript-writer-internal-error: fopen failed', ['path' => $path]); + $this->fileDescriptor = null; + return; + } + $this->fileDescriptor = $resource; + } + + /** + * @param array $attributes + */ + private function formatAttributes(array $attributes): string + { + if ($attributes === []) { + return ''; + } + $parts = []; + foreach ($attributes as $key => $value) { + $parts[] = $key . '=' . $this->encodeAttributeValue($value); + } + return \implode(' ', $parts); + } + + private function encodeAttributeValue(mixed $value): string + { + if ($value === null) { + return 'null'; + } + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (\is_int($value) || \is_float($value)) { + return (string) $value; + } + $stringValue = (string) $value; + if (\preg_match('/[\s"]/', $stringValue) === 1) { + return '"' . \str_replace(['\\', '"'], ['\\\\', '\\"'], $stringValue) . '"'; + } + return $stringValue; + } + + private function encodePayload(mixed $payload): string + { + $encoded = \json_encode($payload, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES | \JSON_PARTIAL_OUTPUT_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE); + if ($encoded === false) { + return '""'; + } + return $encoded; + } + + private function oneLine(string $value): string + { + return \strtr($value, ["\n" => '\\n', "\r" => '\\r', "\t" => '\\t']); + } + + private function safeDecodeFrame(string $frame): mixed + { + $trimmed = \ltrim($frame); + if ($trimmed === '' || ($trimmed[0] !== '{' && $trimmed[0] !== '[')) { + return [ + 'encoding' => 'raw', + 'preview_base64' => \base64_encode(\substr($frame, 0, 512)), + ]; + } + $decoded = \json_decode($frame, true); + if ($decoded === null && \json_last_error() !== \JSON_ERROR_NONE) { + return [ + 'encoding' => 'raw', + 'preview_base64' => \base64_encode(\substr($frame, 0, 512)), + ]; + } + return ['encoding' => 'json', 'value' => $decoded]; + } +} diff --git a/tests/Acceptance/App/Runtime/FatalHandler.php b/tests/Acceptance/App/Runtime/FatalHandler.php new file mode 100644 index 000000000..7b069a574 --- /dev/null +++ b/tests/Acceptance/App/Runtime/FatalHandler.php @@ -0,0 +1,97 @@ +writeError($type, $message, $file, $line); + } finally { + self::$inHandler = false; + } + return false; + }); + + \set_exception_handler(static function (\Throwable $throwable): void { + if (self::$inHandler) { + return; + } + self::$inHandler = true; + try { + self::$writer?->writeFatal($throwable); + self::$writer?->flush(); + } finally { + self::$inHandler = false; + } + self::$stderr?->critical('fatal', [ + 'class' => $throwable::class, + 'message' => $throwable->getMessage(), + ]); + exit(1); + }); + + \register_shutdown_function(static function (): void { + $error = \error_get_last(); + if ($error === null) { + self::$writer?->flush(); + return; + } + if (!\in_array((int) $error['type'], self::FATAL_ERROR_TYPES, true)) { + self::$writer?->flush(); + return; + } + if (self::$inHandler) { + return; + } + self::$inHandler = true; + try { + self::$writer?->writeFatalFromError($error); + self::$writer?->flush(); + } finally { + self::$inHandler = false; + } + }); + + $writer->writeMeta('fatal_handler_registered', [ + 'pid' => \getmypid() ?: 0, + ]); + } + + public static function rebindWriter(TranscriptWriter $writer): void + { + self::$writer = $writer; + $writer->writeMeta('fatal_handler_rebound', [ + 'pid' => \getmypid() ?: 0, + ]); + } +} diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index e6f4675a6..012f9f601 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -22,14 +22,21 @@ use Temporal\Tests\Acceptance\App\Feature\WorkerFactory; use Temporal\Tests\Acceptance\App\Logger\ClientLogger; use Temporal\Tests\Acceptance\App\Logger\LoggerFactory; +use Temporal\Tests\Acceptance\App\Logger\TranscriptLine; +use Temporal\Tests\Acceptance\App\Logger\TranscriptSection; +use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; +use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Tests\Acceptance\App\Runtime\RRStarter; use Temporal\Tests\Acceptance\App\Runtime\State; use Temporal\Tests\Acceptance\App\Runtime\TemporalStarter; +use Temporal\Worker\Logger\StderrLogger; abstract class TestCase extends \Temporal\Tests\TestCase { + private const TRANSCRIPT_FLUSH_USLEEP = 500_000; + protected function setUp(): void { parent::setUp(); @@ -79,13 +86,26 @@ protected function runTest(): mixed return $container->runScope( new Scope(name: 'feature', bindings: $bindings), function (Container $container): mixed { - $reflection = new \ReflectionMethod($this, $this->name()); - $args = $container->resolveArguments($reflection); - $this->setDependencyInput($args); + $args = []; + $caughtException = null; + $startedAt = \microtime(true); + + $transcript = $container->has(TranscriptWriter::class) + ? $container->get(TranscriptWriter::class) + : null; + $transcript?->writeTestBoundary(TranscriptSection::TEST_START, [ + 'class' => static::class, + 'method' => $this->name(), + ]); try { + $reflection = new \ReflectionMethod($this, $this->name()); + $args = $container->resolveArguments($reflection); + $this->setDependencyInput($args); + return parent::runTest(); } catch (\Throwable $e) { + $caughtException = $e; if ($e instanceof TemporalException) { echo \sprintf( "\n=== En error occurred while testing %s: %s (%s) ===\n", @@ -123,6 +143,14 @@ function (Container $container): mixed { throw $e; } finally { + if ($transcript !== null) { + $this->dumpHistoryToTranscript( + $transcript, + $container->get(WorkflowClientInterface::class), + $args, + $caughtException, + ); + } // Cleanup: terminate injected workflow if any foreach ($args as $arg) { if ($arg instanceof WorkflowStubInterface) { @@ -133,11 +161,101 @@ function (Container $container): mixed { } } } + if ($transcript !== null) { + $status = $caughtException === null + ? 'passed' + : ($caughtException instanceof SkippedTest ? 'skipped' : 'failed'); + $endAttributes = [ + 'class' => static::class, + 'method' => $this->name(), + 'status' => $status, + 'duration_ms' => (int) ((\microtime(true) - $startedAt) * 1000), + ]; + if ($caughtException !== null) { + $endAttributes['exception_class'] = $caughtException::class; + } + $transcript->writeTestBoundary(TranscriptSection::TEST_END, $endAttributes); + $transcript->flush(); + if ($caughtException !== null && !$caughtException instanceof SkippedTest) { + $stderr = $container->has(StderrLogger::class) + ? $container->get(StderrLogger::class) + : null; + $stderr?->error('transcript', ['path' => $transcript->getPath()]); + $stderr?->info('run `composer transcripts:last` to view the merged stream'); + } + } } }, ); } + /** + * @return list + */ + protected function readCurrentTestTranscript(): array + { + \usleep(self::TRANSCRIPT_FLUSH_USLEEP); + $run = TranscriptStore::create()->currentRun(); + if ($run === null) { + return []; + } + return $run->reader()->linesForTest(static::class, $this->name()); + } + + private function dumpHistoryToTranscript( + TranscriptWriter $transcript, + WorkflowClientInterface $workflowClient, + array $args, + ?\Throwable $exception, + ): void { + $executions = []; + foreach ($args as $arg) { + if ($arg instanceof WorkflowStubInterface) { + $execution = $arg->getExecution(); + $executions[$execution->getID()] = $execution; + } + } + if ($executions === []) { + $transcript->writeMeta('history_skipped', ['reason' => 'no_executions_inspected']); + return; + } + foreach ($executions as $execution) { + try { + $eventCount = 0; + foreach ($workflowClient->getWorkflowHistory($execution) as $event) { + $eventCount++; + $eventAttributes = [ + 'event_id' => (int) $event->getEventId(), + 'event_type' => EventType::name($event->getEventType()), + ]; + $eventTime = $event->getEventTime(); + if ($eventTime !== null) { + $eventAttributes['event_time'] = $eventTime->getSeconds() . '.' . $eventTime->getNanos(); + } + $payloadJson = '{}'; + try { + $payloadJson = $event->serializeToJsonString(); + } catch (\Throwable $serializationError) { + $eventAttributes['serialize_error'] = $serializationError->getMessage(); + } + $transcript->writeHistoryEvent( + $execution->getID(), + $execution->getRunID(), + $eventAttributes, + $payloadJson, + ); + } + $transcript->writeMeta('history_dumped', [ + 'workflow_id' => $execution->getID(), + 'run_id' => $execution->getRunID(), + 'event_count' => $eventCount, + ]); + } catch (\Throwable $historyError) { + $transcript->writeHistoryError($execution->getID(), $historyError); + } + } + } + private function printWorkflowHistory(WorkflowClientInterface $workflowClient, array $args): void { foreach ($args as $arg) { diff --git a/tests/Acceptance/App/Transport/RecordingHost.php b/tests/Acceptance/App/Transport/RecordingHost.php new file mode 100644 index 000000000..d0ff7f983 --- /dev/null +++ b/tests/Acceptance/App/Transport/RecordingHost.php @@ -0,0 +1,46 @@ +transcript->writeMeta('host_recording_started', [ + 'inner' => $inner::class, + ]); + } + + public function waitBatch(): ?CommandBatch + { + $batch = $this->inner->waitBatch(); + if ($batch === null) { + return null; + } + $this->frameCounter++; + $this->transcript->writeWireInbound($batch->messages, $batch->context, $this->frameCounter); + return $batch; + } + + public function send(string $frame): void + { + $this->transcript->writeWireOutbound($frame, $this->frameCounter); + $this->inner->send($frame); + } + + public function error(\Throwable $error): void + { + $this->transcript->writeWireError($error); + $this->inner->error($error); + } +} diff --git a/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php b/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php new file mode 100644 index 000000000..aa1187622 --- /dev/null +++ b/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php @@ -0,0 +1,61 @@ +getResult('string'); + self::assertSame('hello-from-activity', $result); + + $lines = $this->readCurrentTestTranscript(); + self::assertNotEmpty($lines, 'No transcript lines were captured for this test'); + + $wireInbound = \array_filter($lines, static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::WIRE_INBOUND); + $wireOutbound = \array_filter($lines, static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::WIRE_OUTBOUND); + + self::assertGreaterThan(0, \count($wireInbound), 'Expected at least one WIRE_INBOUND frame from the worker'); + self::assertGreaterThan(0, \count($wireOutbound), 'Expected at least one WIRE_OUTBOUND frame from the worker'); + } +} + +#[WorkflowInterface] +class HappyPathWorkflow +{ + #[WorkflowMethod(name: 'Extra_Transcript_TranscriptHappyPath_run')] + public function run(): \Generator + { + $activity = Workflow::newActivityStub( + HappyPathActivity::class, + Activity\ActivityOptions::new()->withScheduleToCloseTimeout(10), + ); + return yield $activity->greet(); + } +} + +#[ActivityInterface(prefix: 'Extra_Transcript_TranscriptHappyPath.')] +class HappyPathActivity +{ + #[ActivityMethod] + public function greet(): string + { + return 'hello-from-activity'; + } +} diff --git a/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php b/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php new file mode 100644 index 000000000..170b82098 --- /dev/null +++ b/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php @@ -0,0 +1,84 @@ +getResult('string'); + self::assertSame('eventually-ok', $result); + + $lines = $this->readCurrentTestTranscript(); + + $throwsByAttempt = []; + foreach ($lines as $line) { + if ($line->section !== TranscriptSection::EXCEPTION) { + continue; + } + if (($line->attributes['phase'] ?? null) !== 'activity_throw') { + continue; + } + $attempt = (int) ($line->attributes['attempt'] ?? 0); + $throwsByAttempt[$attempt] = ($throwsByAttempt[$attempt] ?? 0) + 1; + } + self::assertArrayHasKey(1, $throwsByAttempt, 'Expected exception line for attempt=1'); + self::assertArrayHasKey(2, $throwsByAttempt, 'Expected exception line for attempt=2'); + self::assertArrayNotHasKey(3, $throwsByAttempt, 'Attempt 3 should succeed without throw'); + + $wireOutbound = \array_filter($lines, static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::WIRE_OUTBOUND); + self::assertGreaterThanOrEqual(3, \count($wireOutbound), 'Expected at least 3 worker outbound frames covering retries'); + } +} + +#[WorkflowInterface] +class RetryWorkflow +{ + #[WorkflowMethod(name: 'Extra_Transcript_TranscriptRetry_run')] + public function run(): \Generator + { + $activity = Workflow::newActivityStub( + RetryActivity::class, + Activity\ActivityOptions::new() + ->withScheduleToCloseTimeout(30) + ->withRetryOptions(RetryOptions::new()->withMaximumAttempts(3)->withInitialInterval(1)), + ); + return yield $activity->flaky(); + } +} + +#[ActivityInterface(prefix: 'Extra_Transcript_TranscriptRetry.')] +class RetryActivity +{ + #[ActivityMethod] + public function flaky(): string + { + $attempt = Activity::getInfo()->attempt; + if ($attempt < 3) { + throw new ApplicationFailure( + "boom-attempt-{$attempt}", + 'TestFailure', + false, + ); + } + return 'eventually-ok'; + } +} diff --git a/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php b/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php new file mode 100644 index 000000000..ec9c5c33d --- /dev/null +++ b/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php @@ -0,0 +1,56 @@ +getResult('string'); + } catch (\Throwable $exception) { + $thrown = $exception; + } + self::assertInstanceOf(WorkflowFailedException::class, $thrown); + + $lines = $this->readCurrentTestTranscript(); + + // The workflow execute interceptor wraps the synchronous setup of the workflow scope; + // the generator body's throw is delivered asynchronously, so it surfaces via WIRE_OUTBOUND + // (failure response to RoadRunner) rather than via the interceptor's catch. + $executeMarkers = \array_filter( + $lines, + static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::META + && ($line->attributes['event'] ?? null) === 'workflow_execute_start', + ); + $outbound = \array_filter($lines, static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::WIRE_OUTBOUND); + self::assertNotEmpty($executeMarkers, 'Expected workflow_execute_start META marker'); + self::assertNotEmpty($outbound, 'Expected at least one WIRE_OUTBOUND frame'); + } +} + +#[WorkflowInterface] +class FailingWorkflow +{ + #[WorkflowMethod(name: 'Extra_Transcript_TranscriptWorkflowFailure_run')] + public function run(): \Generator + { + yield; + throw new ApplicationFailure('workflow-boom', 'TestWorkflowFailure', false); + } +} diff --git a/tests/Acceptance/bootstrap.php b/tests/Acceptance/bootstrap.php index a59176fb8..1dcf9cbd0 100644 --- a/tests/Acceptance/bootstrap.php +++ b/tests/Acceptance/bootstrap.php @@ -21,18 +21,32 @@ use Temporal\Testing\Command; use Temporal\Testing\Environment; use Temporal\Tests\Acceptance\App\Feature\WorkflowStubInjector; +use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; +use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\RRStarter; use Temporal\Tests\Acceptance\App\Runtime\State; use Temporal\Tests\Acceptance\App\Runtime\TemporalStarter; use Temporal\Tests\Acceptance\App\RuntimeBuilder; use Temporal\Tests\Acceptance\App\Support; +use Temporal\Worker\Logger\StderrLogger; \chdir(__DIR__ . '/../..'); require './vendor/autoload.php'; RuntimeBuilder::init(); +$stderr = new StderrLogger(); +$transcriptStore = TranscriptStore::create(stderr: $stderr); +$transcriptRunId = TranscriptStore::generateRunId(); +\putenv('TEMPORAL_TRANSCRIPT_RUN_ID=' . $transcriptRunId); +$_ENV['TEMPORAL_TRANSCRIPT_RUN_ID'] = $transcriptRunId; + +$transcriptStore->pruneOldRuns(10); + +$phpunitTranscript = $transcriptStore->createWriter($transcriptRunId, 'phpunit'); +echo "[transcript] run_id={$transcriptRunId} dir={$transcriptStore->runDirectory($transcriptRunId)} merge=\"composer transcripts:last\" list=\"composer transcripts:list\"\n"; + $environment = Environment::create(); $runtime = RuntimeBuilder::createEmpty($environment->command, \getcwd(), [ 'Temporal\Tests\Acceptance\Harness' => __DIR__ . '/Harness', @@ -93,6 +107,8 @@ $container->bindSingleton(ScheduleClientInterface::class, $scheduleClient); $container->bindInjector(WorkflowStubInterface::class, WorkflowStubInjector::class); $container->bindSingleton(DataConverterInterface::class, $converter); +$container->bindSingleton(TranscriptWriter::class, $phpunitTranscript); +$container->bindSingleton(StderrLogger::class, $stderr); $container->bind(RPCInterface::class, static fn() => RPC::create(\getenv('RR_RPC_ADDRESS') ?: 'tcp://127.0.0.1:6001')); $container->bind( StorageInterface::class, diff --git a/tests/Acceptance/transcript-merge.php b/tests/Acceptance/transcript-merge.php new file mode 100644 index 000000000..0dd49edc3 --- /dev/null +++ b/tests/Acceptance/transcript-merge.php @@ -0,0 +1,72 @@ +error('unknown flag', ['flag' => $arg]); + exit(2); + } + $selector = $arg; +} + +if ($listMode) { + exit(printRuns($store, $stderr)); +} + +$run = $selector === null ? $store->latestRun() : $store->findRun($selector); +if ($run === null) { + $stderr->error( + $selector === null ? 'no transcript runs found' : 'transcript run not found', + ['base_directory' => $store->baseDirectory, 'selector' => $selector], + ); + $stderr->info('try `composer transcripts:list` to see known runs'); + exit(1); +} + +if ($run->files() === []) { + $stderr->error('no transcript files in run', ['directory' => $run->directory]); + exit(1); +} + +\fwrite(\STDOUT, $run->merge() . "\n"); +exit(0); + +function printRuns(TranscriptStore $store, StderrLogger $stderr): int +{ + $runs = $store->listRuns(); + if ($runs === []) { + $stderr->error('no transcript runs found', ['base_directory' => $store->baseDirectory]); + return 1; + } + \fwrite(\STDOUT, "Known transcript runs (newest first):\n"); + foreach ($runs as $run) { + \fwrite(\STDOUT, \sprintf( + " %s %s %d files %d bytes\n", + $run->id, + $run->mtime === null ? 'unknown' : \date('Y-m-d H:i:s', $run->mtime), + \count($run->files()), + $run->totalBytes(), + )); + } + return 0; +} diff --git a/tests/Acceptance/worker.php b/tests/Acceptance/worker.php index f8a3e2c6c..cecaca820 100644 --- a/tests/Acceptance/worker.php +++ b/tests/Acceptance/worker.php @@ -23,15 +23,30 @@ use Temporal\DataConverter\ProtoJsonConverter; use Temporal\Internal\Support\StackRenderer; use Temporal\Testing\Command; +use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; +use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; +use Temporal\Tests\Acceptance\App\Runtime\FatalHandler; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Tests\Acceptance\App\Runtime\State; use Temporal\Tests\Acceptance\App\RuntimeBuilder; +use Temporal\Tests\Acceptance\App\Transport\RecordingHost; +use Temporal\Worker\Logger\StderrLogger; +use Temporal\Worker\Transport\RoadRunner; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; use Temporal\WorkerFactory; \chdir(__DIR__ . '/../..'); require './vendor/autoload.php'; + +$stderr = new StderrLogger(); +$workerTranscript = TranscriptStore::create(stderr: $stderr)->createWriter( + TranscriptStore::currentRunIdFromEnvironment() ?? ('orphan-' . (\getmypid() ?: 0)), + 'worker', +); +FatalHandler::register($workerTranscript, $stderr); + RuntimeBuilder::init(); StackRenderer::addIgnoredPath(__FILE__); @@ -48,6 +63,9 @@ $run = $runtime->command; // Init container $container = new Spiral\Core\Container(); + ContainerFacade::$container = $container; + $container->bindSingleton(TranscriptWriter::class, $workerTranscript); + $container->bindSingleton(StderrLogger::class, $stderr); $converters = [ new NullConverter(), @@ -64,7 +82,7 @@ $container->bindSingleton(DataConverter::class, $converter); $container->bindSingleton(WorkerFactoryInterface::class, WorkerFactory::create(converter: $converter)); - $workerFactory = $container->get(\Temporal\Tests\Acceptance\App\Feature\WorkerFactory::class); + $workerFactory = $container->get(\Temporal\Tests\Acceptance\App\Feature\WorkerFactory::class); $getWorker = static function (Feature $feature) use (&$workers, $workerFactory): WorkerInterface { return $workers[$feature->taskQueue] ??= $workerFactory->createWorker($feature); }; @@ -102,7 +120,18 @@ $getWorker($feature)->registerActivityImplementations($container->make($activity)); } - $container->get(WorkerFactoryInterface::class)->run(); + $host = RoadRunner::create(); + if (\getenv('TEMPORAL_WIRE_TRACE') !== false && \getenv('TEMPORAL_WIRE_TRACE') !== '0') { + $host = new RecordingHost($host, $workerTranscript); + } + $container->get(WorkerFactoryInterface::class)->run($host); } catch (\Throwable $e) { - td($e); + $workerTranscript->writeFatal($e); + $workerTranscript->flush(); + $stderr->critical('fatal', [ + 'class' => $e::class, + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + exit(1); } diff --git a/tests/Unit/Logger/FatalHandlerTestCase.php b/tests/Unit/Logger/FatalHandlerTestCase.php new file mode 100644 index 000000000..68376d031 --- /dev/null +++ b/tests/Unit/Logger/FatalHandlerTestCase.php @@ -0,0 +1,111 @@ +directory = \sys_get_temp_dir() . '/fatal-handler-' . \getmypid() . '-' . \uniqid(); + \mkdir($this->directory, 0777, true); + } + + protected function tearDown(): void + { + foreach (\glob($this->directory . '/*') ?: [] as $path) { + if (\is_file($path)) { + \unlink($path); + } + } + @\rmdir($this->directory); + } + + public function testUserErrorIsRecordedAsFatalViaShutdownFunction(): void + { + $logFile = $this->directory . '/fatal.log'; + $script = $this->buildFixtureScript($logFile, "trigger_error('intentional fatal', E_USER_ERROR);"); + $this->executeFixture($script); + + $reader = new TranscriptReader($this->directory); + $fatal = $reader->findBySection(TranscriptSection::FATAL); + self::assertNotEmpty($fatal, 'Expected a [FATAL] line; transcript content: ' . \file_get_contents($logFile)); + self::assertStringContainsString('intentional fatal', (string) ($fatal[0]->payload['message'] ?? '')); + } + + public function testUncaughtErrorIsRecordedAsFatalViaExceptionHandler(): void + { + $logFile = $this->directory . '/uncaught.log'; + $script = $this->buildFixtureScript($logFile, "throw new \\Error('uncaught fatal');"); + $this->executeFixture($script); + + $reader = new TranscriptReader($this->directory); + $fatal = $reader->findBySection(TranscriptSection::FATAL); + self::assertNotEmpty($fatal); + self::assertSame(\Error::class, $fatal[0]->attributes['class']); + self::assertSame('uncaught fatal', (string) $fatal[0]->payload['message']); + } + + public function testWritesPriorToFatalArePreserved(): void + { + $logFile = $this->directory . '/preserved.log'; + $script = $this->buildFixtureScript( + $logFile, + "\$writer->writeTestBoundary(\\Temporal\\Tests\\Acceptance\\App\\Logger\\TranscriptSection::TEST_START, ['name' => 'pre-fatal']);\n" + . "\$writer->writeLog('info', 'about to die', []);\n" + . "trigger_error('boom', E_USER_ERROR);", + ); + $this->executeFixture($script); + + $reader = new TranscriptReader($this->directory); + $boundaries = $reader->findBySection(TranscriptSection::TEST_START); + $logs = $reader->findBySection(TranscriptSection::LOG); + $fatal = $reader->findBySection(TranscriptSection::FATAL); + self::assertNotEmpty($boundaries, 'TEST_START not preserved across fatal'); + self::assertNotEmpty($logs, 'LOG not preserved across fatal'); + self::assertNotEmpty($fatal, 'FATAL marker missing'); + } + + private function buildFixtureScript(string $logFile, string $body): string + { + $baseDir = \dirname(__DIR__, 3); + $autoloadPath = \var_export($baseDir . '/vendor/autoload.php', true); + $logFileExport = \var_export($logFile, true); + return <<directory . '/fixture-' . \uniqid() . '.php'; + \file_put_contents($scriptPath, $script); + $command = 'php ' . \escapeshellarg($scriptPath) . ' 2>&1'; + \exec($command, $output, $exitCode); + self::assertNotSame(0, $exitCode, 'Fixture process should exit non-zero on fatal; output: ' . \implode("\n", $output)); + } +} diff --git a/tests/Unit/Logger/TranscriptWriterTestCase.php b/tests/Unit/Logger/TranscriptWriterTestCase.php new file mode 100644 index 000000000..2c5026d70 --- /dev/null +++ b/tests/Unit/Logger/TranscriptWriterTestCase.php @@ -0,0 +1,215 @@ +directory = \sys_get_temp_dir() . '/temporal-transcript-test-' . \getmypid() . '-' . \uniqid(); + \mkdir($this->directory, 0777, true); + } + + protected function tearDown(): void + { + foreach (\glob($this->directory . '/*') ?: [] as $path) { + if (\is_file($path)) { + \unlink($path); + } + } + @\rmdir($this->directory); + } + + public function testWriteLogProducesParseableLine(): void + { + $writer = new TranscriptWriter($this->directory . '/log.log'); + $writer->writeLog('info', 'hello world', ['key' => 'value']); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $logs = $reader->findBySection(TranscriptSection::LOG); + self::assertCount(1, $logs); + self::assertSame('info', $logs[0]->attributes['level']); + self::assertSame('hello world', $logs[0]->attributes['message']); + self::assertSame(['key' => 'value'], $logs[0]->payload); + } + + public function testMultiLineContextIsEscapedOnOneLine(): void + { + $writer = new TranscriptWriter($this->directory . '/log.log'); + $writer->writeLog('warning', "line1\nline2\rline3", []); + $writer->flush(); + + $raw = \file_get_contents($writer->getPath()); + $bodyLines = \array_values(\array_filter(\explode("\n", $raw), static fn(string $l): bool => $l !== '')); + $logLine = null; + foreach ($bodyLines as $line) { + if (\str_contains($line, '[LOG]')) { + $logLine = $line; + break; + } + } + self::assertNotNull($logLine); + self::assertStringNotContainsString("\n", $logLine); + self::assertStringContainsString('line1\\nline2\\rline3', $logLine); + } + + public function testWriteWireRoundTripsFrameBytes(): void + { + $writer = new TranscriptWriter($this->directory . '/wire.log'); + $frame = '{"command":"InvokeActivity","payloads":["abc"]}'; + $writer->writeWireInbound($frame, ['tickTime' => '2026-05-13'], 42); + $writer->writeWireOutbound($frame, 42); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $inbound = $reader->findBySection(TranscriptSection::WIRE_INBOUND); + $outbound = $reader->findBySection(TranscriptSection::WIRE_OUTBOUND); + self::assertCount(1, $inbound); + self::assertCount(1, $outbound); + self::assertSame(42, $inbound[0]->attributes['frame_id']); + self::assertSame(\strlen($frame), $inbound[0]->attributes['bytes']); + $decoded = $inbound[0]->payload['body']['value'] ?? null; + self::assertIsArray($decoded); + self::assertSame('InvokeActivity', $decoded['command']); + } + + public function testWriteExceptionCarriesClassAndTrace(): void + { + $writer = new TranscriptWriter($this->directory . '/exc.log'); + $writer->writeException('activity_throw', ['attempt' => 2], new \RuntimeException('boom')); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $exceptions = $reader->findBySection(TranscriptSection::EXCEPTION); + self::assertCount(1, $exceptions); + self::assertSame('activity_throw', $exceptions[0]->attributes['phase']); + self::assertSame(2, $exceptions[0]->attributes['attempt']); + self::assertSame(\RuntimeException::class, $exceptions[0]->payload['class']); + self::assertSame('boom', $exceptions[0]->payload['message']); + self::assertNotSame('', $exceptions[0]->payload['trace']); + } + + public function testWriteFatalCarriesThrowableMetadata(): void + { + $writer = new TranscriptWriter($this->directory . '/fatal.log'); + $writer->writeFatal(new \Error('boom')); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $fatal = $reader->findBySection(TranscriptSection::FATAL); + self::assertCount(1, $fatal); + self::assertSame(\Error::class, $fatal[0]->attributes['class']); + self::assertSame('boom', $fatal[0]->payload['message']); + } + + public function testWriteHistoryEventSerializesProtoJson(): void + { + $writer = new TranscriptWriter($this->directory . '/history.log'); + $writer->writeHistoryEvent('wf-1', 'run-1', ['event_id' => 5, 'event_type' => 'ActivityTaskScheduled'], '{"event":"abc"}'); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $history = $reader->findBySection(TranscriptSection::HISTORY); + self::assertCount(1, $history); + self::assertSame('wf-1', $history[0]->attributes['workflow_id']); + self::assertSame(5, $history[0]->attributes['event_id']); + self::assertSame('ActivityTaskScheduled', $history[0]->attributes['event_type']); + self::assertSame('{"event":"abc"}', $history[0]->payload['attrs']); + } + + public function testEveryLineCarriesPidAndIsoTimestamp(): void + { + $writer = new TranscriptWriter($this->directory . '/all.log'); + $writer->writeLog('info', 'one', []); + $writer->writeMeta('event_two', ['k' => 'v']); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $lines = $reader->getLines(); + self::assertGreaterThanOrEqual(2, \count($lines)); + $processId = \getmypid(); + foreach ($lines as $line) { + self::assertSame($processId, $line->processId); + self::assertNotNull($line->timestamp); + } + } + + public function testConcurrentWritersUnderLockExProduceWellFormedLines(): void + { + $path = $this->directory . '/concurrent.log'; + $childCount = 2; + $writesPerChild = 50; + $baseDir = \dirname(__DIR__, 3); + $autoloadPath = \var_export($baseDir . '/vendor/autoload.php', true); + $childPaths = []; + $processes = []; + for ($i = 0; $i < $childCount; $i++) { + $script = $this->directory . "/child-{$i}.php"; + \file_put_contents($script, <<writeLog('info', "child-{$i}-write-\$j", []); + } + \$writer->flush(); + PHP); + $childPaths[] = $script; + $processes[] = \proc_open(['php', $script], [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + // close pipes immediately to let child exit + foreach ($pipes as $pipe) { + if (\is_resource($pipe)) { + \fclose($pipe); + } + } + } + foreach ($processes as $process) { + if (\is_resource($process)) { + \proc_close($process); + } + } + foreach ($childPaths as $script) { + @\unlink($script); + } + $reader = new TranscriptReader($this->directory); + $logs = $reader->findBySection(TranscriptSection::LOG); + self::assertSame($childCount * $writesPerChild, \count($logs)); + foreach ($logs as $line) { + self::assertStringStartsWith('child-', (string) $line->attributes['message']); + } + } + + public function testReaderRejectsMalformedLine(): void + { + $path = $this->directory . '/bad.log'; + \file_put_contents($path, "this line does not match the schema\n"); + $reader = new TranscriptReader($this->directory); + + $this->expectException(MalformedTranscriptException::class); + $reader->getLines(); + } + +} From ab0f7a0abd3f863b0504d944b04b03c0c7379654 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 13:05:40 +0400 Subject: [PATCH 02/24] refactor: remove redundant setUp method in TestCase --- tests/Acceptance/App/Feature/WorkerFactory.php | 5 ++--- tests/Acceptance/App/TestCase.php | 13 +++++++------ tests/Acceptance/worker.php | 18 ++++++++---------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/tests/Acceptance/App/Feature/WorkerFactory.php b/tests/Acceptance/App/Feature/WorkerFactory.php index 4e1688efe..589a2df0b 100644 --- a/tests/Acceptance/App/Feature/WorkerFactory.php +++ b/tests/Acceptance/App/Feature/WorkerFactory.php @@ -20,7 +20,6 @@ use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\Feature; -use Temporal\Worker\Logger\StderrLogger; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; use Temporal\Worker\WorkerOptions; @@ -84,8 +83,8 @@ private function buildLoggerForFeature(Feature $feature): LoggerInterface } try { $transcript = $container->get(TranscriptWriter::class); - $stderr = $container->has(StderrLogger::class) - ? $container->get(StderrLogger::class) + $stderr = $container->has(LoggerInterface::class) + ? $container->get(LoggerInterface::class) : new NullLogger(); return LoggerFactory::createServerLoggerWithTranscript($feature->taskQueue, $transcript, $stderr); } catch (\Throwable) { diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index 634e89b62..423f0bc17 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -31,7 +31,6 @@ use Temporal\Tests\Acceptance\App\Runtime\RRStarter; use Temporal\Tests\Acceptance\App\Runtime\State; use Temporal\Tests\Acceptance\App\Runtime\TemporalStarter; -use Temporal\Worker\Logger\StderrLogger; abstract class TestCase extends \Temporal\Tests\TestCase { @@ -153,9 +152,11 @@ function (Container $container): mixed { } } if ($transcript !== null) { - $status = $caughtException === null - ? 'passed' - : ($caughtException instanceof SkippedTest ? 'skipped' : 'failed'); + $status = match (true) { + $caughtException === null => 'passed', + $caughtException instanceof SkippedTest => 'skipped', + default => 'failed', + }; $endAttributes = [ 'class' => static::class, 'method' => $this->name(), @@ -168,8 +169,8 @@ function (Container $container): mixed { $transcript->writeTestBoundary(TranscriptSection::TEST_END, $endAttributes); $transcript->flush(); if ($caughtException !== null && !$caughtException instanceof SkippedTest) { - $stderr = $container->has(StderrLogger::class) - ? $container->get(StderrLogger::class) + $stderr = $container->has(LoggerInterface::class) + ? $container->get(LoggerInterface::class) : null; $stderr?->error('transcript', ['path' => $transcript->getPath()]); $stderr?->info('run `composer transcripts:last` to view the merged stream'); diff --git a/tests/Acceptance/worker.php b/tests/Acceptance/worker.php index 9ea1594fa..755ae8174 100644 --- a/tests/Acceptance/worker.php +++ b/tests/Acceptance/worker.php @@ -41,17 +41,17 @@ \chdir(__DIR__ . '/../..'); require './vendor/autoload.php'; -$stderr = new StderrLogger(); -$workerTranscript = TranscriptStore::create(stderr: $stderr)->createWriter( - TranscriptStore::currentRunIdFromEnvironment() ?? ('orphan-' . (\getmypid() ?: 0)), - 'worker', -); -FatalHandler::register($workerTranscript, $stderr); +$logger = new StderrLogger(); +$workerTranscript = TranscriptStore::create(stderr: $logger) + ->createWriter( + TranscriptStore::currentRunIdFromEnvironment() ?? ('orphan-' . (\getmypid() ?: 0)), + 'worker', + ); +FatalHandler::register($workerTranscript, $logger); RuntimeBuilder::init(); StackRenderer::addIgnoredPath(__FILE__); -$logger = new StderrLogger(); /** @var list $allowedTestClasses */ $allowedTestClasses = []; @@ -83,9 +83,7 @@ ); $run = $runtime->command; $container = new Spiral\Core\Container(); - ContainerFacade::$container = $container; $container->bindSingleton(TranscriptWriter::class, $workerTranscript); - $container->bindSingleton(StderrLogger::class, $stderr); $converters = [ new NullConverter(), @@ -144,7 +142,7 @@ } catch (\Throwable $e) { $workerTranscript->writeFatal($e); $workerTranscript->flush(); - $stderr->critical('fatal', [ + $logger->critical('fatal', [ 'class' => $e::class, 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString(), From 04ef0d9b2fb107e3469305ca3b6b3f57e5b98973 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 13:20:25 +0400 Subject: [PATCH 03/24] refactor: migrate transcript writer and reader to JSON format, extract WorkflowHistoryDumper --- .../App/Logger/TranscriptReader.php | 150 ++--------- .../App/Logger/TranscriptWriter.php | 83 ++---- .../App/Logger/WorkflowHistoryDumper.php | 88 +++++++ tests/Acceptance/App/TestCase.php | 60 +---- .../Unit/Logger/TranscriptWriterTestCase.php | 2 +- .../Logger/WorkflowHistoryDumperTestCase.php | 241 ++++++++++++++++++ 6 files changed, 384 insertions(+), 240 deletions(-) create mode 100644 tests/Acceptance/App/Logger/WorkflowHistoryDumper.php create mode 100644 tests/Unit/Logger/WorkflowHistoryDumperTestCase.php diff --git a/tests/Acceptance/App/Logger/TranscriptReader.php b/tests/Acceptance/App/Logger/TranscriptReader.php index faf47dacc..5542554ad 100644 --- a/tests/Acceptance/App/Logger/TranscriptReader.php +++ b/tests/Acceptance/App/Logger/TranscriptReader.php @@ -34,10 +34,7 @@ public function getLines(): array if ($raw === '') { continue; } - $parsed = $this->parseLine($raw, $file, $lineNumber); - if ($parsed !== null) { - $lines[] = $parsed; - } + $lines[] = $this->parseLine($raw, $file, $lineNumber); } } finally { \fclose($handle); @@ -118,148 +115,51 @@ static function (TranscriptLine $candidate) use ($startTimestamp, $endTimestamp) )); } - private function parseLine(string $raw, string $file, int $lineNumber): ?TranscriptLine + private function parseLine(string $raw, string $file, int $lineNumber): TranscriptLine { - if (!\preg_match( - '/^(?P\S+)\s+(?P\d+)\s+(?P\d+)\s+\[(?P
[A-Z_]+)\](?P.*)$/', - $raw, - $matches, - )) { + $decoded = \json_decode($raw, true); + if (!\is_array($decoded)) { throw new MalformedTranscriptException( - 'Line does not match transcript schema', + 'Line is not a valid JSON object: ' . \json_last_error_msg(), $raw, $lineNumber, $file, ); } - $sectionEnum = TranscriptSection::tryFrom($matches['section']); + $sectionValue = $decoded['section'] ?? null; + if (!\is_string($sectionValue)) { + throw new MalformedTranscriptException('Missing section', $raw, $lineNumber, $file); + } + $sectionEnum = TranscriptSection::tryFrom($sectionValue); if ($sectionEnum === null) { - throw new MalformedTranscriptException( - 'Unknown section: ' . $matches['section'], - $raw, - $lineNumber, - $file, - ); + throw new MalformedTranscriptException('Unknown section: ' . $sectionValue, $raw, $lineNumber, $file); } - $tail = \ltrim($matches['tail']); - $payload = null; - $attributesPart = $tail; - $payloadMarker = ' payload='; - $payloadPosition = \strpos($tail, $payloadMarker); - if ($payloadPosition !== false) { - $attributesPart = \substr($tail, 0, $payloadPosition); - $payloadJson = \substr($tail, $payloadPosition + \strlen($payloadMarker)); - $decoded = \json_decode($payloadJson, true); - if ($decoded !== null || \json_last_error() === \JSON_ERROR_NONE) { - $payload = \is_array($decoded) ? $decoded : ['value' => $decoded]; - } else { - $payload = ['raw' => $payloadJson]; - } - } elseif (\str_starts_with($tail, 'payload=')) { - $payloadJson = \substr($tail, 8); - $decoded = \json_decode($payloadJson, true); - $payload = \is_array($decoded) ? $decoded : ['raw' => $payloadJson]; - $attributesPart = ''; + try { + $timestamp = new \DateTimeImmutable((string) ($decoded['ts'] ?? '')); + } catch (\Throwable) { + throw new MalformedTranscriptException('Invalid timestamp', $raw, $lineNumber, $file); } - $attributes = $this->parseAttributes($attributesPart); + $attrs = $decoded['attrs'] ?? []; + if (!\is_array($attrs)) { + $attrs = []; + } - try { - $timestamp = new \DateTimeImmutable($matches['timestamp']); - } catch (\Throwable) { - throw new MalformedTranscriptException( - 'Invalid timestamp', - $raw, - $lineNumber, - $file, - ); + $payload = $decoded['payload'] ?? null; + if ($payload !== null && !\is_array($payload)) { + $payload = ['value' => $payload]; } return new TranscriptLine( timestamp: $timestamp, - processId: (int) $matches['processId'], - sequence: (int) $matches['sequence'], + processId: (int) ($decoded['pid'] ?? 0), + sequence: (int) ($decoded['seq'] ?? 0), section: $sectionEnum, - attributes: $attributes, + attributes: $attrs, payload: $payload, rawLine: $raw, ); } - - /** - * @return array - */ - private function parseAttributes(string $attributesPart): array - { - $attributes = []; - $position = 0; - $length = \strlen($attributesPart); - while ($position < $length) { - while ($position < $length && $attributesPart[$position] === ' ') { - $position++; - } - if ($position >= $length) { - break; - } - $equalsPosition = \strpos($attributesPart, '=', $position); - if ($equalsPosition === false) { - break; - } - $key = \substr($attributesPart, $position, $equalsPosition - $position); - $valuePosition = $equalsPosition + 1; - if ($valuePosition < $length && $attributesPart[$valuePosition] === '"') { - $valuePosition++; - $valueStart = $valuePosition; - $value = ''; - while ($valuePosition < $length) { - $character = $attributesPart[$valuePosition]; - if ($character === '\\' && $valuePosition + 1 < $length) { - $value .= $attributesPart[$valuePosition + 1]; - $valuePosition += 2; - continue; - } - if ($character === '"') { - $valuePosition++; - break; - } - $value .= $character; - $valuePosition++; - } - } else { - $spacePosition = \strpos($attributesPart, ' ', $valuePosition); - if ($spacePosition === false) { - $value = \substr($attributesPart, $valuePosition); - $valuePosition = $length; - } else { - $value = \substr($attributesPart, $valuePosition, $spacePosition - $valuePosition); - $valuePosition = $spacePosition; - } - } - $attributes[$key] = $this->coerceAttributeValue($value); - $position = $valuePosition; - } - return $attributes; - } - - private function coerceAttributeValue(string $value): string|int|float|bool|null - { - if ($value === 'null') { - return null; - } - if ($value === 'true') { - return true; - } - if ($value === 'false') { - return false; - } - if ($value !== '' && \preg_match('/^-?\d+$/', $value) === 1) { - return (int) $value; - } - if ($value !== '' && \preg_match('/^-?\d+\.\d+$/', $value) === 1) { - return (float) $value; - } - return $value; - } } diff --git a/tests/Acceptance/App/Logger/TranscriptWriter.php b/tests/Acceptance/App/Logger/TranscriptWriter.php index 0cd80aa51..d247d09d8 100644 --- a/tests/Acceptance/App/Logger/TranscriptWriter.php +++ b/tests/Acceptance/App/Logger/TranscriptWriter.php @@ -11,6 +11,11 @@ final class TranscriptWriter { private const SIZE_CAP_BYTES = 50 * 1024 * 1024; + private const JSON_FLAGS = \JSON_UNESCAPED_UNICODE + | \JSON_UNESCAPED_SLASHES + | \JSON_PARTIAL_OUTPUT_ON_ERROR + | \JSON_INVALID_UTF8_SUBSTITUTE; + /** @var resource|null */ private $fileDescriptor; @@ -84,7 +89,7 @@ public function writeLog(string $level, string $message, array $context = []): v { $this->write(TranscriptSection::LOG, [ 'level' => $level, - 'message' => $this->oneLine($message), + 'message' => $message, ], $context === [] ? null : $context); } @@ -218,18 +223,29 @@ private function doWrite(TranscriptSection $section, array $attributes, mixed $p $this->rotateIfNeeded(); $this->sequence++; - $timestamp = (new \DateTimeImmutable('now'))->format('Y-m-d\TH:i:s.uP'); - $line = $timestamp . ' ' - . $this->processId . ' ' - . $this->sequence . ' ' - . '[' . $section->value . '] ' - . $this->formatAttributes($attributes); - + $record = [ + 'ts' => (new \DateTimeImmutable('now'))->format('Y-m-d\TH:i:s.uP'), + 'pid' => $this->processId, + 'seq' => $this->sequence, + 'section' => $section->value, + 'attrs' => (object) $attributes, + ]; if ($payload !== null) { - $line .= ' payload=' . $this->encodePayload($payload); + $record['payload'] = $payload; } - $line .= "\n"; + $encoded = \json_encode($record, self::JSON_FLAGS); + if ($encoded === false) { + $encoded = \json_encode([ + 'ts' => $record['ts'], + 'pid' => $this->processId, + 'seq' => $this->sequence, + 'section' => $section->value, + 'attrs' => new \stdClass(), + 'payload' => ['error' => 'json_encode_failed'], + ], self::JSON_FLAGS); + } + $line = $encoded . "\n"; if (!\flock($this->fileDescriptor, \LOCK_EX)) { $this->stderr->error('transcript-writer-internal-error: flock failed'); @@ -278,53 +294,6 @@ private function openFileDescriptor(string $path): void $this->fileDescriptor = $resource; } - /** - * @param array $attributes - */ - private function formatAttributes(array $attributes): string - { - if ($attributes === []) { - return ''; - } - $parts = []; - foreach ($attributes as $key => $value) { - $parts[] = $key . '=' . $this->encodeAttributeValue($value); - } - return \implode(' ', $parts); - } - - private function encodeAttributeValue(mixed $value): string - { - if ($value === null) { - return 'null'; - } - if (\is_bool($value)) { - return $value ? 'true' : 'false'; - } - if (\is_int($value) || \is_float($value)) { - return (string) $value; - } - $stringValue = (string) $value; - if (\preg_match('/[\s"]/', $stringValue) === 1) { - return '"' . \str_replace(['\\', '"'], ['\\\\', '\\"'], $stringValue) . '"'; - } - return $stringValue; - } - - private function encodePayload(mixed $payload): string - { - $encoded = \json_encode($payload, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES | \JSON_PARTIAL_OUTPUT_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE); - if ($encoded === false) { - return '""'; - } - return $encoded; - } - - private function oneLine(string $value): string - { - return \strtr($value, ["\n" => '\\n', "\r" => '\\r', "\t" => '\\t']); - } - private function safeDecodeFrame(string $frame): mixed { $trimmed = \ltrim($frame); diff --git a/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php b/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php new file mode 100644 index 000000000..5e35a8847 --- /dev/null +++ b/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php @@ -0,0 +1,88 @@ + $args Call arguments; WorkflowStubInterface entries contribute their execution. + */ + public function dump( + TranscriptWriter $transcript, + WorkflowClientInterface $workflowClient, + array $args, + ): void { + $executions = $this->collectExecutions($args); + if ($executions === []) { + $transcript->writeMeta('history_skipped', ['reason' => 'no_executions_inspected']); + return; + } + + foreach ($executions as $execution) { + $this->dumpExecution($transcript, $workflowClient, $execution); + } + } + + /** + * @param array $args + * @return array + */ + private function collectExecutions(array $args): array + { + $executions = []; + foreach ($args as $arg) { + if ($arg instanceof WorkflowStubInterface) { + $execution = $arg->getExecution(); + $executions[$execution->getID()] = $execution; + } + } + return $executions; + } + + private function dumpExecution( + TranscriptWriter $transcript, + WorkflowClientInterface $workflowClient, + WorkflowExecution $execution, + ): void { + try { + $eventCount = 0; + foreach ($workflowClient->getWorkflowHistory($execution) as $event) { + $eventCount++; + $eventAttributes = [ + 'event_id' => (int) $event->getEventId(), + 'event_type' => EventType::name($event->getEventType()), + ]; + $eventTime = $event->getEventTime(); + if ($eventTime !== null) { + $eventAttributes['event_time'] = $eventTime->getSeconds() . '.' . $eventTime->getNanos(); + } + $payloadJson = '{}'; + try { + $payloadJson = $event->serializeToJsonString(); + } catch (\Throwable $serializationError) { + $eventAttributes['serialize_error'] = $serializationError->getMessage(); + } + $transcript->writeHistoryEvent( + $execution->getID(), + $execution->getRunID(), + $eventAttributes, + $payloadJson, + ); + } + $transcript->writeMeta('history_dumped', [ + 'workflow_id' => $execution->getID(), + 'run_id' => $execution->getRunID(), + 'event_count' => $eventCount, + ]); + } catch (\Throwable $historyError) { + $transcript->writeHistoryError($execution->getID(), $historyError); + } + } +} diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index 423f0bc17..99b884987 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -26,6 +26,7 @@ use Temporal\Tests\Acceptance\App\Logger\TranscriptSection; use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Tests\Acceptance\App\Logger\WorkflowHistoryDumper; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Tests\Acceptance\App\Runtime\RRStarter; @@ -134,11 +135,10 @@ function (Container $container): mixed { throw $e; } finally { if ($transcript !== null) { - $this->dumpHistoryToTranscript( + (new WorkflowHistoryDumper())->dump( $transcript, $container->get(WorkflowClientInterface::class), $args, - $caughtException, ); } // Cleanup: terminate injected workflow if any @@ -194,61 +194,7 @@ protected function readCurrentTestTranscript(): array return $run->reader()->linesForTest(static::class, $this->name()); } - private function dumpHistoryToTranscript( - TranscriptWriter $transcript, - WorkflowClientInterface $workflowClient, - array $args, - ?\Throwable $exception, - ): void { - $executions = []; - foreach ($args as $arg) { - if ($arg instanceof WorkflowStubInterface) { - $execution = $arg->getExecution(); - $executions[$execution->getID()] = $execution; - } - } - if ($executions === []) { - $transcript->writeMeta('history_skipped', ['reason' => 'no_executions_inspected']); - return; - } - foreach ($executions as $execution) { - try { - $eventCount = 0; - foreach ($workflowClient->getWorkflowHistory($execution) as $event) { - $eventCount++; - $eventAttributes = [ - 'event_id' => (int) $event->getEventId(), - 'event_type' => EventType::name($event->getEventType()), - ]; - $eventTime = $event->getEventTime(); - if ($eventTime !== null) { - $eventAttributes['event_time'] = $eventTime->getSeconds() . '.' . $eventTime->getNanos(); - } - $payloadJson = '{}'; - try { - $payloadJson = $event->serializeToJsonString(); - } catch (\Throwable $serializationError) { - $eventAttributes['serialize_error'] = $serializationError->getMessage(); - } - $transcript->writeHistoryEvent( - $execution->getID(), - $execution->getRunID(), - $eventAttributes, - $payloadJson, - ); - } - $transcript->writeMeta('history_dumped', [ - 'workflow_id' => $execution->getID(), - 'run_id' => $execution->getRunID(), - 'event_count' => $eventCount, - ]); - } catch (\Throwable $historyError) { - $transcript->writeHistoryError($execution->getID(), $historyError); - } - } - } - - private function printWorkflowHistory(WorkflowClientInterface $workflowClient, array $args): void +private function printWorkflowHistory(WorkflowClientInterface $workflowClient, array $args): void { foreach ($args as $arg) { if (!$arg instanceof WorkflowStubInterface) { diff --git a/tests/Unit/Logger/TranscriptWriterTestCase.php b/tests/Unit/Logger/TranscriptWriterTestCase.php index 2c5026d70..9ce30aa68 100644 --- a/tests/Unit/Logger/TranscriptWriterTestCase.php +++ b/tests/Unit/Logger/TranscriptWriterTestCase.php @@ -62,7 +62,7 @@ public function testMultiLineContextIsEscapedOnOneLine(): void $bodyLines = \array_values(\array_filter(\explode("\n", $raw), static fn(string $l): bool => $l !== '')); $logLine = null; foreach ($bodyLines as $line) { - if (\str_contains($line, '[LOG]')) { + if (\str_contains($line, '"section":"LOG"')) { $logLine = $line; break; } diff --git a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php new file mode 100644 index 000000000..ccf17a874 --- /dev/null +++ b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php @@ -0,0 +1,241 @@ +directory = \sys_get_temp_dir() . '/history-dumper-' . \getmypid() . '-' . \uniqid(); + \mkdir($this->directory, 0777, true); + } + + protected function tearDown(): void + { + foreach (\glob($this->directory . '/*') ?: [] as $path) { + if (\is_file($path)) { + \unlink($path); + } + } + @\rmdir($this->directory); + } + + public function testWritesHistorySkippedMetaWhenArgsAreEmpty(): void + { + $writer = $this->newWriter('empty.log'); + $client = $this->createMock(WorkflowClientInterface::class); + $client->expects(self::never())->method('getWorkflowHistory'); + + (new WorkflowHistoryDumper())->dump($writer, $client, []); + $writer->flush(); + + $meta = $this->readMeta(); + self::assertCount(1, $meta); + self::assertSame('history_skipped', $meta[0]->attributes['event']); + self::assertSame('no_executions_inspected', $meta[0]->attributes['reason']); + } + + public function testWritesHistorySkippedWhenNoStubsPresent(): void + { + $writer = $this->newWriter('nonstub.log'); + $client = $this->createMock(WorkflowClientInterface::class); + $client->expects(self::never())->method('getWorkflowHistory'); + + (new WorkflowHistoryDumper())->dump($writer, $client, ['just-a-string', 42, new \stdClass()]); + $writer->flush(); + + $meta = $this->readMeta(); + self::assertSame('history_skipped', $meta[0]->attributes['event']); + } + + public function testWritesHistoryEventsAndDumpedMetaForSingleExecution(): void + { + $writer = $this->newWriter('single.log'); + $execution = new WorkflowExecution('wf-1', 'run-1'); + $stub = $this->createMock(WorkflowStubInterface::class); + $stub->method('getExecution')->willReturn($execution); + + $events = [ + $this->newEvent(1, EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, 1700000000, 123), + $this->newEvent(2, EventType::EVENT_TYPE_WORKFLOW_TASK_SCHEDULED, 1700000001, 456), + ]; + $client = $this->createMock(WorkflowClientInterface::class); + $client->method('getWorkflowHistory')->willReturn($this->makeHistory($events)); + + (new WorkflowHistoryDumper())->dump($writer, $client, [$stub]); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $history = $reader->findBySection(TranscriptSection::HISTORY); + self::assertCount(2, $history); + self::assertSame(1, $history[0]->attributes['event_id']); + self::assertSame('EVENT_TYPE_WORKFLOW_EXECUTION_STARTED', $history[0]->attributes['event_type']); + self::assertSame('wf-1', $history[0]->attributes['workflow_id']); + self::assertSame('run-1', $history[0]->attributes['run_id']); + self::assertSame('1700000000.123', $history[0]->attributes['event_time']); + self::assertSame(2, $history[1]->attributes['event_id']); + + $dumpedMetas = \array_values(\array_filter( + $reader->findBySection(TranscriptSection::META), + static fn(TranscriptLine $line): bool => ($line->attributes['event'] ?? null) === 'history_dumped', + )); + self::assertCount(1, $dumpedMetas); + self::assertSame('wf-1', $dumpedMetas[0]->attributes['workflow_id']); + self::assertSame(2, $dumpedMetas[0]->attributes['event_count']); + } + + public function testDeduplicatesExecutionsWithSameId(): void + { + $writer = $this->newWriter('dedup.log'); + $execution = new WorkflowExecution('wf-dup', 'run-1'); + $stubA = $this->createMock(WorkflowStubInterface::class); + $stubA->method('getExecution')->willReturn($execution); + $stubB = $this->createMock(WorkflowStubInterface::class); + $stubB->method('getExecution')->willReturn(new WorkflowExecution('wf-dup', 'run-1')); + + $client = $this->createMock(WorkflowClientInterface::class); + $client->expects(self::once()) + ->method('getWorkflowHistory') + ->willReturn($this->makeHistory([ + $this->newEvent(1, EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + ])); + + (new WorkflowHistoryDumper())->dump($writer, $client, [$stubA, $stubB]); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $dumpedMetas = \array_values(\array_filter( + $reader->findBySection(TranscriptSection::META), + static fn(TranscriptLine $line): bool => ($line->attributes['event'] ?? null) === 'history_dumped', + )); + self::assertCount(1, $dumpedMetas, 'Same execution id should be dumped once'); + } + + public function testWritesHistoryErrorWhenClientThrows(): void + { + $writer = $this->newWriter('err.log'); + $stub = $this->createMock(WorkflowStubInterface::class); + $stub->method('getExecution')->willReturn(new WorkflowExecution('wf-err', 'run-x')); + + $client = $this->createMock(WorkflowClientInterface::class); + $client->method('getWorkflowHistory')->willThrowException(new \RuntimeException('temporal-unreachable')); + + (new WorkflowHistoryDumper())->dump($writer, $client, [$stub]); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $errors = $reader->findBySection(TranscriptSection::HISTORY_ERROR); + self::assertCount(1, $errors); + self::assertSame('wf-err', $errors[0]->attributes['workflow_id']); + self::assertSame(\RuntimeException::class, $errors[0]->attributes['class']); + self::assertSame('temporal-unreachable', $errors[0]->payload['message']); + + $dumped = \array_filter( + $reader->findBySection(TranscriptSection::META), + static fn(TranscriptLine $line): bool => ($line->attributes['event'] ?? null) === 'history_dumped', + ); + self::assertEmpty($dumped); + } + + public function testRecordsSerializeErrorAttributeWhenEventSerializationFails(): void + { + $writer = $this->newWriter('serr.log'); + $stub = $this->createMock(WorkflowStubInterface::class); + $stub->method('getExecution')->willReturn(new WorkflowExecution('wf-serr', 'run-1')); + + $event = $this->createMock(HistoryEvent::class); + $event->method('getEventId')->willReturn(7); + $event->method('getEventType')->willReturn(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED); + $event->method('getEventTime')->willReturn(null); + $event->method('serializeToJsonString')->willThrowException(new \RuntimeException('bad-utf8')); + + $client = $this->createMock(WorkflowClientInterface::class); + $client->method('getWorkflowHistory')->willReturn($this->makeHistory([$event])); + + (new WorkflowHistoryDumper())->dump($writer, $client, [$stub]); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $history = $reader->findBySection(TranscriptSection::HISTORY); + self::assertCount(1, $history); + self::assertSame(7, $history[0]->attributes['event_id']); + self::assertSame('bad-utf8', $history[0]->attributes['serialize_error']); + self::assertSame('{}', $history[0]->payload['attrs']); + } + + private function newWriter(string $name): TranscriptWriter + { + return new TranscriptWriter($this->directory . '/' . $name); + } + + /** + * @return list + */ + private function readMeta(): array + { + $reader = new TranscriptReader($this->directory); + return \array_values(\array_filter( + $reader->findBySection(TranscriptSection::META), + static fn(TranscriptLine $line): bool => ($line->attributes['event'] ?? null) !== 'writer_initialized', + )); + } + + private function newEvent(int $id, int $type, ?int $seconds = null, int $nanos = 0): HistoryEvent + { + $event = new HistoryEvent(); + $event->setEventId($id); + $event->setEventType($type); + if ($seconds !== null) { + $timestamp = new Timestamp(); + $timestamp->setSeconds($seconds); + $timestamp->setNanos($nanos); + $event->setEventTime($timestamp); + } + return $event; + } + + /** + * @param list $events + */ + private function makeHistory(array $events): WorkflowExecutionHistory + { + $history = new History(); + $history->setEvents($events); + $response = new GetWorkflowExecutionHistoryResponse(); + $response->setHistory($history); + $generator = (static function () use ($response): \Generator { + yield [$response]; + })(); + return new WorkflowExecutionHistory(Paginator::createFromGenerator($generator, null)); + } +} From 4f12794f8cd2f6d74f55f9392a2039c2d5a7fa5b Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 13:35:21 +0400 Subject: [PATCH 04/24] feat: introduce TranscriptPlugin for worker interceptors and plugin registry integration --- .../Acceptance/App/Feature/WorkerFactory.php | 27 +----- .../App/Plugin/TranscriptPlugin.php | 27 ++++++ tests/Acceptance/worker.php | 12 ++- .../Unit/Plugin/TranscriptPluginTestCase.php | 88 +++++++++++++++++++ 4 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 tests/Acceptance/App/Plugin/TranscriptPlugin.php create mode 100644 tests/Unit/Plugin/TranscriptPluginTestCase.php diff --git a/tests/Acceptance/App/Feature/WorkerFactory.php b/tests/Acceptance/App/Feature/WorkerFactory.php index 589a2df0b..f764e156c 100644 --- a/tests/Acceptance/App/Feature/WorkerFactory.php +++ b/tests/Acceptance/App/Feature/WorkerFactory.php @@ -10,14 +10,10 @@ use Spiral\Core\Container\InjectorInterface; use Spiral\Core\InvokerInterface; use Temporal\Client\WorkflowStubInterface; -use Temporal\Interceptor\PipelineProvider; -use Temporal\Interceptor\SimplePipelineProvider; -use Temporal\Plugin\CompositePipelineProvider; use Temporal\Tests\Acceptance\App\Attribute\Worker; -use Temporal\Tests\Acceptance\App\Interceptor\TranscriptActivityInterceptor; -use Temporal\Tests\Acceptance\App\Interceptor\TranscriptWorkflowInterceptor; use Temporal\Tests\Acceptance\App\Logger\LoggerFactory; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Tests\Acceptance\App\Plugin\TranscriptPlugin; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Worker\WorkerFactoryInterface; @@ -33,28 +29,25 @@ final class WorkerFactory public function __construct( private readonly WorkerFactoryInterface $workerFactory, private readonly InvokerInterface $invoker, - ) {} + ) { + } public function createWorker( Feature $feature, ): WorkerInterface { - // Find Worker attribute $attr = self::findAttribute( ...\array_map(static fn(array $check): string => $check[0], $feature->checks), ...$feature->workflows, ...$feature->activities, ); $options = $attr?->options === null ? null : $this->invoker->invoke($attr->options); - $featureProvider = $attr?->pipelineProvider === null ? null : $this->invoker->invoke($attr->pipelineProvider); + $interceptorProvider = $attr?->pipelineProvider === null ? null : $this->invoker->invoke($attr->pipelineProvider); $logger = $attr?->logger === null ? null : $this->invoker->invoke($attr->logger); - // Add plugins from the attribute to the factory's registry (already instantiated, no invoker needed) if ($attr?->plugins !== null) { $this->workerFactory->getPluginRegistry()->merge($attr->plugins); } - $interceptorProvider = $this->composeTranscriptProvider($featureProvider); - return $this->workerFactory->newWorker( $feature->taskQueue, $options ?? WorkerOptions::new()->withMaxConcurrentActivityExecutionSize(10), @@ -63,18 +56,6 @@ public function createWorker( ); } - private function composeTranscriptProvider(?PipelineProvider $base): PipelineProvider - { - $transcriptInterceptors = [ - new TranscriptActivityInterceptor(), - new TranscriptWorkflowInterceptor(), - ]; - if ($base === null) { - return new SimplePipelineProvider($transcriptInterceptors); - } - return new CompositePipelineProvider($transcriptInterceptors, $base); - } - private function buildLoggerForFeature(Feature $feature): LoggerInterface { $container = ContainerFacade::$container ?? null; diff --git a/tests/Acceptance/App/Plugin/TranscriptPlugin.php b/tests/Acceptance/App/Plugin/TranscriptPlugin.php new file mode 100644 index 000000000..f7ad9619f --- /dev/null +++ b/tests/Acceptance/App/Plugin/TranscriptPlugin.php @@ -0,0 +1,27 @@ +addInterceptor(new TranscriptActivityInterceptor()); + $context->addInterceptor(new TranscriptWorkflowInterceptor()); + $next($context); + } +} diff --git a/tests/Acceptance/worker.php b/tests/Acceptance/worker.php index 755ae8174..aab401213 100644 --- a/tests/Acceptance/worker.php +++ b/tests/Acceptance/worker.php @@ -23,9 +23,11 @@ use Temporal\DataConverter\ProtoConverter; use Temporal\DataConverter\ProtoJsonConverter; use Temporal\Internal\Support\StackRenderer; +use Temporal\Plugin\PluginRegistry; use Temporal\Testing\Command; use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Tests\Acceptance\App\Plugin\TranscriptPlugin; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\FatalHandler; use Temporal\Tests\Acceptance\App\Runtime\Feature; @@ -97,7 +99,15 @@ } $converter = new DataConverter(...$converters); $container->bindSingleton(DataConverter::class, $converter); - $container->bindSingleton(WorkerFactoryInterface::class, WorkerFactory::create(converter: $converter)); + + $plugins = [new TranscriptPlugin]; + $container->bindSingleton( + WorkerFactoryInterface::class, + WorkerFactory::create( + converter: $converter, + pluginRegistry: new PluginRegistry($plugins), + ) + ); $workerFactory = $container->get(\Temporal\Tests\Acceptance\App\Feature\WorkerFactory::class); $getWorker = static function (Feature $feature) use (&$workers, $workerFactory): WorkerInterface { diff --git a/tests/Unit/Plugin/TranscriptPluginTestCase.php b/tests/Unit/Plugin/TranscriptPluginTestCase.php new file mode 100644 index 000000000..f21f5646c --- /dev/null +++ b/tests/Unit/Plugin/TranscriptPluginTestCase.php @@ -0,0 +1,88 @@ +getName()); + self::assertSame(TranscriptPlugin::NAME, $plugin->getName()); + } + + public function testConfigureWorkerAddsActivityAndWorkflowInterceptors(): void + { + $plugin = new TranscriptPlugin(); + $context = new WorkerPluginContext('test-queue', WorkerOptions::new()); + $nextCalled = false; + + $plugin->configureWorker($context, static function (WorkerPluginContext $received) use (&$nextCalled, $context): void { + $nextCalled = true; + self::assertSame($context, $received); + }); + + self::assertTrue($nextCalled, 'next callback must be invoked'); + $interceptors = $context->getInterceptors(); + self::assertCount(2, $interceptors); + self::assertInstanceOf(TranscriptActivityInterceptor::class, $interceptors[0]); + self::assertInstanceOf(TranscriptWorkflowInterceptor::class, $interceptors[1]); + } + + public function testConfigureWorkerAppendsInterceptorsWithoutClobberingExistingOnes(): void + { + $plugin = new TranscriptPlugin(); + $context = new WorkerPluginContext('test-queue', WorkerOptions::new()); + $existing = new TranscriptActivityInterceptor(); + $context->addInterceptor($existing); + + $plugin->configureWorker($context, static fn() => null); + + $interceptors = $context->getInterceptors(); + self::assertCount(3, $interceptors); + self::assertSame($existing, $interceptors[0]); + self::assertInstanceOf(TranscriptActivityInterceptor::class, $interceptors[1]); + self::assertInstanceOf(TranscriptWorkflowInterceptor::class, $interceptors[2]); + } + + public function testRegistryExposesPluginUnderWorkerPluginInterface(): void + { + $registry = new PluginRegistry(); + $plugin = new TranscriptPlugin(); + $registry->add($plugin); + + $workerPlugins = $registry->getPlugins(WorkerPluginInterface::class); + self::assertCount(1, $workerPlugins); + self::assertSame($plugin, $workerPlugins[0]); + } + + public function testRegistryRejectsDuplicateRegistration(): void + { + $registry = new PluginRegistry(); + $registry->add(new TranscriptPlugin()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Duplicate plugin "temporal-php.transcript": a plugin with this name is already registered.'); + + $registry->add(new TranscriptPlugin()); + } +} From 78b9c1c7c4cde6a76fd0f2e922e7ace4844f7ffc Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 14:21:57 +0400 Subject: [PATCH 05/24] feat: enhance transcript management with rotation support, error handling, and improved workflow history tracking --- .../App/Logger/TranscriptReader.php | 8 +++- tests/Acceptance/App/Logger/TranscriptRun.php | 15 ++++++- .../Acceptance/App/Logger/TranscriptStore.php | 3 ++ .../App/Logger/TranscriptWriter.php | 5 --- .../App/Logger/WorkflowHistoryDumper.php | 3 +- tests/Acceptance/App/Runtime/FatalHandler.php | 7 ++++ tests/Acceptance/App/Runtime/RRStarter.php | 7 ++++ tests/Acceptance/App/TestCase.php | 30 ++++++++------ .../App/Transport/RecordingHost.php | 20 ++++++--- .../Acceptance/ExecutionStartedSubscriber.php | 12 ++++++ tests/Acceptance/transcript-merge.php | 1 - tests/Acceptance/worker.php | 6 +-- .../Unit/Logger/TranscriptWriterTestCase.php | 19 +++++++++ .../Logger/WorkflowHistoryDumperTestCase.php | 41 ++++++++++++++++++- .../Unit/Plugin/TranscriptPluginTestCase.php | 4 +- 15 files changed, 145 insertions(+), 36 deletions(-) diff --git a/tests/Acceptance/App/Logger/TranscriptReader.php b/tests/Acceptance/App/Logger/TranscriptReader.php index 5542554ad..c41c10bfd 100644 --- a/tests/Acceptance/App/Logger/TranscriptReader.php +++ b/tests/Acceptance/App/Logger/TranscriptReader.php @@ -11,8 +11,12 @@ final class TranscriptReader public function __construct(string $directory) { - $matches = \glob($directory . '/*.log'); - $this->files = \is_array($matches) ? $matches : []; + $live = \glob($directory . '/*.log'); + $rotated = \glob($directory . '/*.log.*'); + $this->files = \array_values(\array_merge( + \is_array($live) ? $live : [], + \is_array($rotated) ? $rotated : [], + )); } /** diff --git a/tests/Acceptance/App/Logger/TranscriptRun.php b/tests/Acceptance/App/Logger/TranscriptRun.php index d2d8c145d..30cf6fd3c 100644 --- a/tests/Acceptance/App/Logger/TranscriptRun.php +++ b/tests/Acceptance/App/Logger/TranscriptRun.php @@ -6,6 +6,8 @@ final class TranscriptRun { + public const MERGED_DIRECTORY = '_merged'; + public function __construct( public readonly string $id, public readonly string $directory, @@ -37,7 +39,7 @@ public function reader(): TranscriptReader public function merge(): string { - $mergedDirectory = $this->directory . '/_merged'; + $mergedDirectory = $this->directory . '/' . self::MERGED_DIRECTORY; if (!\is_dir($mergedDirectory)) { @\mkdir($mergedDirectory, 0777, true); } @@ -46,11 +48,20 @@ public function merge(): string if ($handle === false) { throw new \RuntimeException("Failed to open merged file: {$path}"); } + if (!\flock($handle, \LOCK_EX)) { + \fclose($handle); + throw new \RuntimeException("Failed to acquire lock on merged file: {$path}"); + } try { foreach ($this->reader()->getLines() as $line) { - \fwrite($handle, $line->rawLine . "\n"); + $payload = $line->rawLine . "\n"; + $written = \fwrite($handle, $payload); + if ($written === false || $written < \strlen($payload)) { + throw new \RuntimeException("Short write while merging transcript at {$path}"); + } } } finally { + \flock($handle, \LOCK_UN); \fclose($handle); } return $path; diff --git a/tests/Acceptance/App/Logger/TranscriptStore.php b/tests/Acceptance/App/Logger/TranscriptStore.php index 39897e647..246584776 100644 --- a/tests/Acceptance/App/Logger/TranscriptStore.php +++ b/tests/Acceptance/App/Logger/TranscriptStore.php @@ -146,6 +146,9 @@ private static function sanitizeRunId(string $runId): string if ($slug === '') { return 'run'; } + if ($slug[0] === '_') { + $slug = 'r' . $slug; + } return \strlen($slug) > 64 ? \substr($slug, 0, 64) : $slug; } diff --git a/tests/Acceptance/App/Logger/TranscriptWriter.php b/tests/Acceptance/App/Logger/TranscriptWriter.php index d247d09d8..a15654e54 100644 --- a/tests/Acceptance/App/Logger/TranscriptWriter.php +++ b/tests/Acceptance/App/Logger/TranscriptWriter.php @@ -29,11 +29,6 @@ final class TranscriptWriter private readonly LoggerInterface $stderr; - /** - * Re-entry guard. Writes are called from shutdown handlers, destructors, and - * exception interceptors; a recursive failure inside doWrite must not retrigger - * the writer or it will mask the original throwable. - */ private bool $inWrite = false; /** diff --git a/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php b/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php index 5e35a8847..66f0275a6 100644 --- a/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php +++ b/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php @@ -40,7 +40,8 @@ private function collectExecutions(array $args): array foreach ($args as $arg) { if ($arg instanceof WorkflowStubInterface) { $execution = $arg->getExecution(); - $executions[$execution->getID()] = $execution; + $key = $execution->getID() . ':' . ($execution->getRunID() ?? ''); + $executions[$key] = $execution; } } return $executions; diff --git a/tests/Acceptance/App/Runtime/FatalHandler.php b/tests/Acceptance/App/Runtime/FatalHandler.php index 7b069a574..50c8b255f 100644 --- a/tests/Acceptance/App/Runtime/FatalHandler.php +++ b/tests/Acceptance/App/Runtime/FatalHandler.php @@ -24,11 +24,18 @@ final class FatalHandler private static bool $inHandler = false; + private static bool $registered = false; + public static function register(TranscriptWriter $writer, ?LoggerInterface $stderr = null): void { self::$writer = $writer; self::$stderr = $stderr ?? new NullLogger(); + if (self::$registered) { + return; + } + self::$registered = true; + \set_error_handler(static function (int $type, string $message, string $file, int $line): bool { if (self::$inHandler) { return false; diff --git a/tests/Acceptance/App/Runtime/RRStarter.php b/tests/Acceptance/App/Runtime/RRStarter.php index f3c653764..8bd10e775 100644 --- a/tests/Acceptance/App/Runtime/RRStarter.php +++ b/tests/Acceptance/App/Runtime/RRStarter.php @@ -62,8 +62,15 @@ public function start(array $allowedTestClasses = []): void $rrCommand[] = "tls.cert={$run->tlsCert}"; } + $envs = []; + $runId = \getenv('TEMPORAL_TRANSCRIPT_RUN_ID'); + if (\is_string($runId) && $runId !== '') { + $envs['TEMPORAL_TRANSCRIPT_RUN_ID'] = $runId; + } + $this->environment->startRoadRunner( rrCommand: $rrCommand, + envs: $envs, configFile: $this->runtime->rrConfigDir . DIRECTORY_SEPARATOR . '.rr.yaml', ); } diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index 99b884987..110f05382 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -27,6 +27,7 @@ use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; use Temporal\Tests\Acceptance\App\Logger\WorkflowHistoryDumper; +use Temporal\Worker\Logger\StderrLogger; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Tests\Acceptance\App\Runtime\RRStarter; @@ -134,23 +135,29 @@ function (Container $container): mixed { throw $e; } finally { - if ($transcript !== null) { - (new WorkflowHistoryDumper())->dump( - $transcript, - $container->get(WorkflowClientInterface::class), - $args, - ); - } - // Cleanup: terminate injected workflow if any foreach ($args as $arg) { if ($arg instanceof WorkflowStubInterface) { try { $arg->terminate('test-end'); } catch (\Throwable $e) { - // ignore + $transcript?->writeMeta('workflow_terminate_failed', [ + 'workflow_id' => $arg->getExecution()->getID(), + 'class' => $e::class, + 'message' => $e->getMessage(), + ]); } } } + if ($transcript !== null) { + (new WorkflowHistoryDumper())->dump( + $transcript, + $container->get(WorkflowClientInterface::class), + $args, + ); + } + $stderr = $container->has(StderrLogger::class) + ? $container->get(StderrLogger::class) + : null; if ($transcript !== null) { $status = match (true) { $caughtException === null => 'passed', @@ -169,9 +176,6 @@ function (Container $container): mixed { $transcript->writeTestBoundary(TranscriptSection::TEST_END, $endAttributes); $transcript->flush(); if ($caughtException !== null && !$caughtException instanceof SkippedTest) { - $stderr = $container->has(LoggerInterface::class) - ? $container->get(LoggerInterface::class) - : null; $stderr?->error('transcript', ['path' => $transcript->getPath()]); $stderr?->info('run `composer transcripts:last` to view the merged stream'); } @@ -194,7 +198,7 @@ protected function readCurrentTestTranscript(): array return $run->reader()->linesForTest(static::class, $this->name()); } -private function printWorkflowHistory(WorkflowClientInterface $workflowClient, array $args): void + private function printWorkflowHistory(WorkflowClientInterface $workflowClient, array $args): void { foreach ($args as $arg) { if (!$arg instanceof WorkflowStubInterface) { diff --git a/tests/Acceptance/App/Transport/RecordingHost.php b/tests/Acceptance/App/Transport/RecordingHost.php index d0ff7f983..1cc76c75e 100644 --- a/tests/Acceptance/App/Transport/RecordingHost.php +++ b/tests/Acceptance/App/Transport/RecordingHost.php @@ -16,9 +16,9 @@ public function __construct( private readonly HostConnectionInterface $inner, private readonly TranscriptWriter $transcript, ) { - $this->transcript->writeMeta('host_recording_started', [ + $this->record(fn() => $this->transcript->writeMeta('host_recording_started', [ 'inner' => $inner::class, - ]); + ])); } public function waitBatch(): ?CommandBatch @@ -28,19 +28,29 @@ public function waitBatch(): ?CommandBatch return null; } $this->frameCounter++; - $this->transcript->writeWireInbound($batch->messages, $batch->context, $this->frameCounter); + $frameId = $this->frameCounter; + $this->record(fn() => $this->transcript->writeWireInbound($batch->messages, $batch->context, $frameId)); return $batch; } public function send(string $frame): void { - $this->transcript->writeWireOutbound($frame, $this->frameCounter); + $frameId = $this->frameCounter; + $this->record(fn() => $this->transcript->writeWireOutbound($frame, $frameId)); $this->inner->send($frame); } public function error(\Throwable $error): void { - $this->transcript->writeWireError($error); + $this->record(fn() => $this->transcript->writeWireError($error)); $this->inner->error($error); } + + private function record(callable $write): void + { + try { + $write(); + } catch (\Throwable) { + } + } } diff --git a/tests/Acceptance/ExecutionStartedSubscriber.php b/tests/Acceptance/ExecutionStartedSubscriber.php index 5df5fcc23..f62f635bd 100644 --- a/tests/Acceptance/ExecutionStartedSubscriber.php +++ b/tests/Acceptance/ExecutionStartedSubscriber.php @@ -27,6 +27,8 @@ use Temporal\DataConverter\DataConverterInterface; use Temporal\Testing\Environment; use Temporal\Tests\Acceptance\App\Feature\WorkflowStubInjector; +use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; +use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\RRStarter; use Temporal\Tests\Acceptance\App\Runtime\State; @@ -80,6 +82,16 @@ public function notify(ExecutionStarted $event): void $container->bindSingleton(State::class, $state); $container->bindSingleton(Environment::class, $environment); $container->bindSingleton(LoggerInterface::class, $logger); + $container->bindSingleton(StderrLogger::class, $logger); + + $runId = TranscriptStore::currentRunIdFromEnvironment() ?? TranscriptStore::generateRunId(); + \putenv('TEMPORAL_TRANSCRIPT_RUN_ID=' . $runId); + $_ENV['TEMPORAL_TRANSCRIPT_RUN_ID'] = $runId; + $_SERVER['TEMPORAL_TRANSCRIPT_RUN_ID'] = $runId; + $logger->info('[transcript] run id', ['run_id' => $runId]); + + $testTranscript = TranscriptStore::create(stderr: $logger)->createWriter($runId, 'test'); + $container->bindSingleton(TranscriptWriter::class, $testTranscript); $temporalRunner = new TemporalStarter($environment); $rrRunner = new RRStarter($state, $environment); diff --git a/tests/Acceptance/transcript-merge.php b/tests/Acceptance/transcript-merge.php index 0dd49edc3..e5426dcbc 100644 --- a/tests/Acceptance/transcript-merge.php +++ b/tests/Acceptance/transcript-merge.php @@ -4,7 +4,6 @@ require __DIR__ . '/../../vendor/autoload.php'; -use Temporal\Tests\Acceptance\App\Logger\TranscriptRun; use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; use Temporal\Worker\Logger\StderrLogger; diff --git a/tests/Acceptance/worker.php b/tests/Acceptance/worker.php index aab401213..9119c3492 100644 --- a/tests/Acceptance/worker.php +++ b/tests/Acceptance/worker.php @@ -85,6 +85,7 @@ ); $run = $runtime->command; $container = new Spiral\Core\Container(); + ContainerFacade::$container = $container; $container->bindSingleton(TranscriptWriter::class, $workerTranscript); $converters = [ @@ -144,10 +145,7 @@ $getWorker($feature)->registerActivityImplementations($container->make($activity)); } - $host = RoadRunner::create(); - if (\getenv('TEMPORAL_WIRE_TRACE') !== false && \getenv('TEMPORAL_WIRE_TRACE') !== '0') { - $host = new RecordingHost($host, $workerTranscript); - } + $host = new RecordingHost(RoadRunner::create(), $workerTranscript); $container->get(WorkerFactoryInterface::class)->run($host); } catch (\Throwable $e) { $workerTranscript->writeFatal($e); diff --git a/tests/Unit/Logger/TranscriptWriterTestCase.php b/tests/Unit/Logger/TranscriptWriterTestCase.php index 9ce30aa68..c9a9fc9c8 100644 --- a/tests/Unit/Logger/TranscriptWriterTestCase.php +++ b/tests/Unit/Logger/TranscriptWriterTestCase.php @@ -212,4 +212,23 @@ public function testReaderRejectsMalformedLine(): void $reader->getLines(); } + public function testReaderIncludesRotatedLogFiles(): void + { + $writer = new TranscriptWriter($this->directory . '/rotated.log'); + $writer->writeLog('info', 'pre-rotation', []); + $writer->flush(); + \rename($this->directory . '/rotated.log', $this->directory . '/rotated.log.1'); + + $writer = new TranscriptWriter($this->directory . '/rotated.log'); + $writer->writeLog('info', 'post-rotation', []); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $messages = \array_map( + static fn($line): string => (string) $line->attributes['message'], + $reader->findBySection(TranscriptSection::LOG), + ); + self::assertContains('pre-rotation', $messages); + self::assertContains('post-rotation', $messages); + } } diff --git a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php index ccf17a874..976331bee 100644 --- a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php +++ b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php @@ -30,6 +30,14 @@ #[UsesClass(TranscriptLine::class)] #[UsesClass(TranscriptSection::class)] #[UsesClass(MalformedTranscriptException::class)] +#[UsesClass(WorkflowExecution::class)] +#[UsesClass(WorkflowExecutionHistory::class)] +#[UsesClass(Paginator::class)] +#[UsesClass(HistoryEvent::class)] +#[UsesClass(History::class)] +#[UsesClass(GetWorkflowExecutionHistoryResponse::class)] +#[UsesClass(EventType::class)] +#[UsesClass(Timestamp::class)] final class WorkflowHistoryDumperTestCase extends TestCase { private string $directory; @@ -114,7 +122,7 @@ public function testWritesHistoryEventsAndDumpedMetaForSingleExecution(): void self::assertSame(2, $dumpedMetas[0]->attributes['event_count']); } - public function testDeduplicatesExecutionsWithSameId(): void + public function testDeduplicatesExecutionsWithSameIdAndRunId(): void { $writer = $this->newWriter('dedup.log'); $execution = new WorkflowExecution('wf-dup', 'run-1'); @@ -138,7 +146,36 @@ public function testDeduplicatesExecutionsWithSameId(): void $reader->findBySection(TranscriptSection::META), static fn(TranscriptLine $line): bool => ($line->attributes['event'] ?? null) === 'history_dumped', )); - self::assertCount(1, $dumpedMetas, 'Same execution id should be dumped once'); + self::assertCount(1, $dumpedMetas, 'Same execution id+runId should be dumped once'); + } + + public function testDumpsBothExecutionsWhenSameIdButDifferentRunId(): void + { + $writer = $this->newWriter('two-runs.log'); + $stubA = $this->createMock(WorkflowStubInterface::class); + $stubA->method('getExecution')->willReturn(new WorkflowExecution('wf-retry', 'run-1')); + $stubB = $this->createMock(WorkflowStubInterface::class); + $stubB->method('getExecution')->willReturn(new WorkflowExecution('wf-retry', 'run-2')); + + $client = $this->createMock(WorkflowClientInterface::class); + $client->expects(self::exactly(2)) + ->method('getWorkflowHistory') + ->willReturnOnConsecutiveCalls( + $this->makeHistory([$this->newEvent(1, EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)]), + $this->makeHistory([$this->newEvent(1, EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)]), + ); + + (new WorkflowHistoryDumper())->dump($writer, $client, [$stubA, $stubB]); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $dumpedMetas = \array_values(\array_filter( + $reader->findBySection(TranscriptSection::META), + static fn(TranscriptLine $line): bool => ($line->attributes['event'] ?? null) === 'history_dumped', + )); + self::assertCount(2, $dumpedMetas); + $runIds = \array_map(static fn(TranscriptLine $line): mixed => $line->attributes['run_id'], $dumpedMetas); + self::assertEqualsCanonicalizing(['run-1', 'run-2'], $runIds); } public function testWritesHistoryErrorWhenClientThrows(): void diff --git a/tests/Unit/Plugin/TranscriptPluginTestCase.php b/tests/Unit/Plugin/TranscriptPluginTestCase.php index f21f5646c..8e318f2eb 100644 --- a/tests/Unit/Plugin/TranscriptPluginTestCase.php +++ b/tests/Unit/Plugin/TranscriptPluginTestCase.php @@ -20,6 +20,9 @@ #[UsesClass(AbstractPlugin::class)] #[UsesClass(PluginRegistry::class)] #[UsesClass(WorkerPluginContext::class)] +#[UsesClass(WorkerOptions::class)] +#[UsesClass(TranscriptActivityInterceptor::class)] +#[UsesClass(TranscriptWorkflowInterceptor::class)] final class TranscriptPluginTestCase extends TestCase { public function testGetNameReturnsCanonicalIdentifier(): void @@ -27,7 +30,6 @@ public function testGetNameReturnsCanonicalIdentifier(): void $plugin = new TranscriptPlugin(); self::assertSame('temporal-php.transcript', $plugin->getName()); - self::assertSame(TranscriptPlugin::NAME, $plugin->getName()); } public function testConfigureWorkerAddsActivityAndWorkflowInterceptors(): void From c8b25a14ace048420aa0207ce211053f17a75bbd Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 14:50:24 +0400 Subject: [PATCH 06/24] refactor: improve transcript handling with strict validations, refined sorting, and enhanced error reporting --- .../Acceptance/App/Feature/WorkerFactory.php | 34 ++++++++----- .../Acceptance/App/Logger/TranscriptLine.php | 6 +++ .../App/Logger/TranscriptReader.php | 51 ++++++++++++++----- tests/Acceptance/App/Logger/TranscriptRun.php | 3 ++ .../Acceptance/App/Logger/TranscriptStore.php | 19 ++++++- .../App/Logger/TranscriptWriter.php | 11 ++-- .../App/Transport/RecordingHost.php | 26 +++++++--- .../Transcript/TranscriptHappyPathTest.php | 33 ++++++++++-- .../Extra/Transcript/TranscriptRetryTest.php | 26 ++++++++-- .../TranscriptWorkflowFailureTest.php | 21 ++++---- tests/Acceptance/transcript-merge.php | 35 ++++++++++++- tests/Unit/Logger/FatalHandlerTestCase.php | 30 ++++++++--- .../Unit/Logger/TranscriptWriterTestCase.php | 42 ++++++++++----- 13 files changed, 254 insertions(+), 83 deletions(-) diff --git a/tests/Acceptance/App/Feature/WorkerFactory.php b/tests/Acceptance/App/Feature/WorkerFactory.php index f764e156c..854262be2 100644 --- a/tests/Acceptance/App/Feature/WorkerFactory.php +++ b/tests/Acceptance/App/Feature/WorkerFactory.php @@ -7,45 +7,51 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Spiral\Core\Attribute\Singleton; -use Spiral\Core\Container\InjectorInterface; use Spiral\Core\InvokerInterface; -use Temporal\Client\WorkflowStubInterface; +use Temporal\Plugin\PluginInterface; use Temporal\Tests\Acceptance\App\Attribute\Worker; use Temporal\Tests\Acceptance\App\Logger\LoggerFactory; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; -use Temporal\Tests\Acceptance\App\Plugin\TranscriptPlugin; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; use Temporal\Worker\WorkerOptions; -/** - * @implements InjectorInterface - */ #[Singleton] final class WorkerFactory { public function __construct( private readonly WorkerFactoryInterface $workerFactory, private readonly InvokerInterface $invoker, - ) { - } + ) {} public function createWorker( Feature $feature, ): WorkerInterface { - $attr = self::findAttribute( + $attribute = self::findAttribute( ...\array_map(static fn(array $check): string => $check[0], $feature->checks), ...$feature->workflows, ...$feature->activities, ); - $options = $attr?->options === null ? null : $this->invoker->invoke($attr->options); - $interceptorProvider = $attr?->pipelineProvider === null ? null : $this->invoker->invoke($attr->pipelineProvider); - $logger = $attr?->logger === null ? null : $this->invoker->invoke($attr->logger); + $options = $attribute?->options === null ? null : $this->invoker->invoke($attribute->options); + $interceptorProvider = $attribute?->pipelineProvider === null + ? null + : $this->invoker->invoke($attribute->pipelineProvider); + $logger = $attribute?->logger === null ? null : $this->invoker->invoke($attribute->logger); - if ($attr?->plugins !== null) { - $this->workerFactory->getPluginRegistry()->merge($attr->plugins); + if ($attribute?->plugins !== null) { + $registry = $this->workerFactory->getPluginRegistry(); + $registeredNames = \array_map( + static fn(PluginInterface $plugin): string => $plugin->getName(), + $registry->getPlugins(PluginInterface::class), + ); + foreach ($attribute->plugins as $plugin) { + if (!\in_array($plugin->getName(), $registeredNames, true)) { + $registry->add($plugin); + $registeredNames[] = $plugin->getName(); + } + } } return $this->workerFactory->newWorker( diff --git a/tests/Acceptance/App/Logger/TranscriptLine.php b/tests/Acceptance/App/Logger/TranscriptLine.php index 06bff30ae..4d3309088 100644 --- a/tests/Acceptance/App/Logger/TranscriptLine.php +++ b/tests/Acceptance/App/Logger/TranscriptLine.php @@ -8,6 +8,7 @@ final class TranscriptLine { /** * @param array $attributes + * @param array|null $payload */ public function __construct( public readonly \DateTimeImmutable $timestamp, @@ -23,4 +24,9 @@ public function getAttribute(string $key): string|int|float|bool|null { return $this->attributes[$key] ?? null; } + + public function hasAttribute(string $key): bool + { + return \array_key_exists($key, $this->attributes); + } } diff --git a/tests/Acceptance/App/Logger/TranscriptReader.php b/tests/Acceptance/App/Logger/TranscriptReader.php index c41c10bfd..bf45bfdf6 100644 --- a/tests/Acceptance/App/Logger/TranscriptReader.php +++ b/tests/Acceptance/App/Logger/TranscriptReader.php @@ -44,13 +44,17 @@ public function getLines(): array \fclose($handle); } } - \usort( - $lines, - static fn(TranscriptLine $a, TranscriptLine $b): int => - $a->timestamp <=> $b->timestamp - ?: $a->processId <=> $b->processId - ?: $a->sequence <=> $b->sequence, - ); + \usort($lines, static function (TranscriptLine $a, TranscriptLine $b): int { + $byTimestamp = $a->timestamp <=> $b->timestamp; + if ($byTimestamp !== 0) { + return $byTimestamp; + } + $byProcess = $a->processId <=> $b->processId; + if ($byProcess !== 0) { + return $byProcess; + } + return $a->sequence <=> $b->sequence; + }); return $lines; } @@ -140,20 +144,39 @@ private function parseLine(string $raw, string $file, int $lineNumber): Transcri throw new MalformedTranscriptException('Unknown section: ' . $sectionValue, $raw, $lineNumber, $file); } + $timestampRaw = $decoded['ts'] ?? null; + if (!\is_string($timestampRaw) || $timestampRaw === '') { + throw new MalformedTranscriptException('Missing or empty timestamp', $raw, $lineNumber, $file); + } try { - $timestamp = new \DateTimeImmutable((string) ($decoded['ts'] ?? '')); + $timestamp = new \DateTimeImmutable($timestampRaw); } catch (\Throwable) { - throw new MalformedTranscriptException('Invalid timestamp', $raw, $lineNumber, $file); + throw new MalformedTranscriptException('Invalid timestamp: ' . $timestampRaw, $raw, $lineNumber, $file); } - $attrs = $decoded['attrs'] ?? []; - if (!\is_array($attrs)) { - $attrs = []; + $attributes = $decoded['attributes'] ?? []; + if (!\is_array($attributes)) { + throw new MalformedTranscriptException('attributes must be an object', $raw, $lineNumber, $file); + } + foreach ($attributes as $key => $value) { + if ($value !== null && !\is_scalar($value)) { + throw new MalformedTranscriptException( + \sprintf('attribute "%s" must be scalar or null, %s given', (string) $key, \get_debug_type($value)), + $raw, + $lineNumber, + $file, + ); + } } $payload = $decoded['payload'] ?? null; if ($payload !== null && !\is_array($payload)) { - $payload = ['value' => $payload]; + throw new MalformedTranscriptException( + 'payload must be an object or null, ' . \get_debug_type($payload) . ' given', + $raw, + $lineNumber, + $file, + ); } return new TranscriptLine( @@ -161,7 +184,7 @@ private function parseLine(string $raw, string $file, int $lineNumber): Transcri processId: (int) ($decoded['pid'] ?? 0), sequence: (int) ($decoded['seq'] ?? 0), section: $sectionEnum, - attributes: $attrs, + attributes: $attributes, payload: $payload, rawLine: $raw, ); diff --git a/tests/Acceptance/App/Logger/TranscriptRun.php b/tests/Acceptance/App/Logger/TranscriptRun.php index 30cf6fd3c..1bb399d37 100644 --- a/tests/Acceptance/App/Logger/TranscriptRun.php +++ b/tests/Acceptance/App/Logger/TranscriptRun.php @@ -43,6 +43,9 @@ public function merge(): string if (!\is_dir($mergedDirectory)) { @\mkdir($mergedDirectory, 0777, true); } + if (!\is_dir($mergedDirectory)) { + throw new \RuntimeException("Failed to create merged directory: {$mergedDirectory}"); + } $path = $mergedDirectory . '/transcript.log'; $handle = \fopen($path, 'wb'); if ($handle === false) { diff --git a/tests/Acceptance/App/Logger/TranscriptStore.php b/tests/Acceptance/App/Logger/TranscriptStore.php index 246584776..23a7f4b55 100644 --- a/tests/Acceptance/App/Logger/TranscriptStore.php +++ b/tests/Acceptance/App/Logger/TranscriptStore.php @@ -90,7 +90,13 @@ public function listRuns(): array } \usort( $runs, - static fn(TranscriptRun $a, TranscriptRun $b): int => ($b->mtime ?? 0) <=> ($a->mtime ?? 0), + static function (TranscriptRun $a, TranscriptRun $b): int { + $byMtime = ($b->mtime ?? 0) <=> ($a->mtime ?? 0); + if ($byMtime !== 0) { + return $byMtime; + } + return $b->id <=> $a->id; + }, ); return $runs; } @@ -144,7 +150,9 @@ private static function sanitizeRunId(string $runId): string { $slug = \preg_replace('~[^A-Za-z0-9_-]~', '_', $runId) ?? ''; if ($slug === '') { - return 'run'; + throw new \InvalidArgumentException( + 'Run id sanitizes to an empty string: ' . \var_export($runId, true), + ); } if ($slug[0] === '_') { $slug = 'r' . $slug; @@ -165,6 +173,9 @@ private static function buildFilename(string $directory, string $processLabel): private function removeDirectoryRecursive(string $path): bool { + if (\is_link($path)) { + return @\unlink($path); + } if (!\is_dir($path)) { return false; } @@ -177,6 +188,10 @@ private function removeDirectoryRecursive(string $path): bool continue; } $child = $path . '/' . $entry; + if (\is_link($child)) { + @\unlink($child); + continue; + } if (\is_dir($child)) { $this->removeDirectoryRecursive($child); continue; diff --git a/tests/Acceptance/App/Logger/TranscriptWriter.php b/tests/Acceptance/App/Logger/TranscriptWriter.php index a15654e54..e9931b5d8 100644 --- a/tests/Acceptance/App/Logger/TranscriptWriter.php +++ b/tests/Acceptance/App/Logger/TranscriptWriter.php @@ -88,10 +88,10 @@ public function writeLog(string $level, string $message, array $context = []): v ], $context === [] ? null : $context); } - public function writeWireInbound(string $frame, array $headers, int $frameId): void + public function writeWireInbound(string $frame, array $headers, int $inboundBatchId): void { $this->write(TranscriptSection::WIRE_INBOUND, [ - 'frame_id' => $frameId, + 'inbound_batch_id' => $inboundBatchId, 'bytes' => \strlen($frame), ], [ 'headers' => $headers, @@ -99,10 +99,11 @@ public function writeWireInbound(string $frame, array $headers, int $frameId): v ]); } - public function writeWireOutbound(string $frame, int $frameId): void + public function writeWireOutbound(string $frame, int $inboundBatchId, int $outboundSeq): void { $this->write(TranscriptSection::WIRE_OUTBOUND, [ - 'frame_id' => $frameId, + 'inbound_batch_id' => $inboundBatchId, + 'outbound_seq' => $outboundSeq, 'bytes' => \strlen($frame), ], [ 'body' => $this->safeDecodeFrame($frame), @@ -223,7 +224,7 @@ private function doWrite(TranscriptSection $section, array $attributes, mixed $p 'pid' => $this->processId, 'seq' => $this->sequence, 'section' => $section->value, - 'attrs' => (object) $attributes, + 'attributes' => (object) $attributes, ]; if ($payload !== null) { $record['payload'] = $payload; diff --git a/tests/Acceptance/App/Transport/RecordingHost.php b/tests/Acceptance/App/Transport/RecordingHost.php index 1cc76c75e..64c14573a 100644 --- a/tests/Acceptance/App/Transport/RecordingHost.php +++ b/tests/Acceptance/App/Transport/RecordingHost.php @@ -10,7 +10,9 @@ final class RecordingHost implements HostConnectionInterface { - private int $frameCounter = 0; + private int $inboundBatchId = 0; + + private int $outboundSeq = 0; public function __construct( private readonly HostConnectionInterface $inner, @@ -18,25 +20,35 @@ public function __construct( ) { $this->record(fn() => $this->transcript->writeMeta('host_recording_started', [ 'inner' => $inner::class, + 'pid' => \getmypid() ?: 0, + 'transcript_path' => $this->transcript->getPath(), ])); } public function waitBatch(): ?CommandBatch { - $batch = $this->inner->waitBatch(); + try { + $batch = $this->inner->waitBatch(); + } catch (\Throwable $error) { + $this->record(fn() => $this->transcript->writeWireError($error)); + throw $error; + } if ($batch === null) { return null; } - $this->frameCounter++; - $frameId = $this->frameCounter; - $this->record(fn() => $this->transcript->writeWireInbound($batch->messages, $batch->context, $frameId)); + $this->inboundBatchId++; + $this->outboundSeq = 0; + $batchId = $this->inboundBatchId; + $this->record(fn() => $this->transcript->writeWireInbound($batch->messages, $batch->context, $batchId)); return $batch; } public function send(string $frame): void { - $frameId = $this->frameCounter; - $this->record(fn() => $this->transcript->writeWireOutbound($frame, $frameId)); + $this->outboundSeq++; + $batchId = $this->inboundBatchId; + $sequence = $this->outboundSeq; + $this->record(fn() => $this->transcript->writeWireOutbound($frame, $batchId, $sequence)); $this->inner->send($frame); } diff --git a/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php b/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php index aa1187622..a72fb4005 100644 --- a/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php +++ b/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php @@ -16,7 +16,7 @@ use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; -class TranscriptHappyPathTest extends TestCase +final class TranscriptHappyPathTest extends TestCase { public function testHappyPathRoundTripIsCaptured( #[Stub('Extra_Transcript_TranscriptHappyPath_run')] @@ -28,11 +28,34 @@ public function testHappyPathRoundTripIsCaptured( $lines = $this->readCurrentTestTranscript(); self::assertNotEmpty($lines, 'No transcript lines were captured for this test'); - $wireInbound = \array_filter($lines, static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::WIRE_INBOUND); - $wireOutbound = \array_filter($lines, static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::WIRE_OUTBOUND); + $workflowStart = $this->findMeta($lines, 'workflow_execute_start'); + self::assertCount(1, $workflowStart, 'Expected exactly one workflow_execute_start META'); + self::assertSame('Extra_Transcript_TranscriptHappyPath_run', $workflowStart[0]->attributes['workflow_type']); + self::assertSame($stub->getExecution()->getID(), $workflowStart[0]->attributes['workflow_id']); - self::assertGreaterThan(0, \count($wireInbound), 'Expected at least one WIRE_INBOUND frame from the worker'); - self::assertGreaterThan(0, \count($wireOutbound), 'Expected at least one WIRE_OUTBOUND frame from the worker'); + $workflowCompleted = $this->findMeta($lines, 'workflow_execute_completed'); + self::assertCount(1, $workflowCompleted, 'Expected exactly one workflow_execute_completed META'); + + $activityStart = $this->findMeta($lines, 'activity_start'); + self::assertCount(1, $activityStart, 'Expected exactly one activity_start META'); + self::assertSame('Extra_Transcript_TranscriptHappyPath.greet', $activityStart[0]->attributes['name']); + self::assertSame(1, $activityStart[0]->attributes['attempt']); + + $activityCompleted = $this->findMeta($lines, 'activity_completed'); + self::assertCount(1, $activityCompleted, 'Expected exactly one activity_completed META'); + } + + /** + * @param list $lines + * @return list + */ + private function findMeta(array $lines, string $event): array + { + return \array_values(\array_filter( + $lines, + static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::META + && ($line->attributes['event'] ?? null) === $event, + )); } } diff --git a/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php b/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php index 170b82098..e8e5bada3 100644 --- a/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php +++ b/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php @@ -18,7 +18,7 @@ use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; -class TranscriptRetryTest extends TestCase +final class TranscriptRetryTest extends TestCase { public function testRetriesAreRecordedPerAttempt( #[Stub('Extra_Transcript_TranscriptRetry_run')] @@ -30,6 +30,7 @@ public function testRetriesAreRecordedPerAttempt( $lines = $this->readCurrentTestTranscript(); $throwsByAttempt = []; + $messagesByAttempt = []; foreach ($lines as $line) { if ($line->section !== TranscriptSection::EXCEPTION) { continue; @@ -39,13 +40,28 @@ public function testRetriesAreRecordedPerAttempt( } $attempt = (int) ($line->attributes['attempt'] ?? 0); $throwsByAttempt[$attempt] = ($throwsByAttempt[$attempt] ?? 0) + 1; + $messagesByAttempt[$attempt] = (string) ($line->payload['message'] ?? ''); } - self::assertArrayHasKey(1, $throwsByAttempt, 'Expected exception line for attempt=1'); - self::assertArrayHasKey(2, $throwsByAttempt, 'Expected exception line for attempt=2'); + self::assertSame(1, $throwsByAttempt[1] ?? 0, 'Exactly one activity_throw expected for attempt=1'); + self::assertSame(1, $throwsByAttempt[2] ?? 0, 'Exactly one activity_throw expected for attempt=2'); self::assertArrayNotHasKey(3, $throwsByAttempt, 'Attempt 3 should succeed without throw'); + self::assertStringContainsString('boom-attempt-1', $messagesByAttempt[1] ?? ''); + self::assertStringContainsString('boom-attempt-2', $messagesByAttempt[2] ?? ''); - $wireOutbound = \array_filter($lines, static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::WIRE_OUTBOUND); - self::assertGreaterThanOrEqual(3, \count($wireOutbound), 'Expected at least 3 worker outbound frames covering retries'); + $activityStartsByAttempt = []; + foreach ($lines as $line) { + if ($line->section !== TranscriptSection::META) { + continue; + } + if (($line->attributes['event'] ?? null) !== 'activity_start') { + continue; + } + $attempt = (int) ($line->attributes['attempt'] ?? 0); + $activityStartsByAttempt[$attempt] = ($activityStartsByAttempt[$attempt] ?? 0) + 1; + } + self::assertSame(1, $activityStartsByAttempt[1] ?? 0); + self::assertSame(1, $activityStartsByAttempt[2] ?? 0); + self::assertSame(1, $activityStartsByAttempt[3] ?? 0, 'Attempt 3 must record an activity_start'); } } diff --git a/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php b/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php index ec9c5c33d..ec0ce0f11 100644 --- a/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php +++ b/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php @@ -14,7 +14,7 @@ use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; -class TranscriptWorkflowFailureTest extends TestCase +final class TranscriptWorkflowFailureTest extends TestCase { public function testWorkflowFailureCapturedWithHistory( #[Stub('Extra_Transcript_TranscriptWorkflowFailure_run')] @@ -30,17 +30,20 @@ public function testWorkflowFailureCapturedWithHistory( $lines = $this->readCurrentTestTranscript(); - // The workflow execute interceptor wraps the synchronous setup of the workflow scope; - // the generator body's throw is delivered asynchronously, so it surfaces via WIRE_OUTBOUND - // (failure response to RoadRunner) rather than via the interceptor's catch. - $executeMarkers = \array_filter( + $executeMarkers = \array_values(\array_filter( $lines, static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::META && ($line->attributes['event'] ?? null) === 'workflow_execute_start', - ); - $outbound = \array_filter($lines, static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::WIRE_OUTBOUND); - self::assertNotEmpty($executeMarkers, 'Expected workflow_execute_start META marker'); - self::assertNotEmpty($outbound, 'Expected at least one WIRE_OUTBOUND frame'); + )); + self::assertCount(1, $executeMarkers, 'Expected exactly one workflow_execute_start META'); + self::assertSame('Extra_Transcript_TranscriptWorkflowFailure_run', $executeMarkers[0]->attributes['workflow_type']); + self::assertSame($stub->getExecution()->getID(), $executeMarkers[0]->attributes['workflow_id']); + + $outbound = \array_values(\array_filter( + $lines, + static fn(TranscriptLine $line): bool => $line->section === TranscriptSection::WIRE_OUTBOUND, + )); + self::assertNotEmpty($outbound, 'Expected at least one WIRE_OUTBOUND frame from the worker'); } } diff --git a/tests/Acceptance/transcript-merge.php b/tests/Acceptance/transcript-merge.php index e5426dcbc..2a5b2d409 100644 --- a/tests/Acceptance/transcript-merge.php +++ b/tests/Acceptance/transcript-merge.php @@ -2,6 +2,13 @@ declare(strict_types=1); +/** + * Exit codes: + * 0 — success (run merged or list printed). + * 1 — no runs found / requested run absent / run has no transcript files. + * 2 — usage error (unknown flag, conflicting flags, repeated flag, --list/--last combined). + */ + require __DIR__ . '/../../vendor/autoload.php'; use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; @@ -11,23 +18,49 @@ $store = TranscriptStore::create(stderr: $stderr); $listMode = false; +$lastMode = false; $selector = null; foreach (\array_slice($argv, 1) as $arg) { if ($arg === '--list' || $arg === 'list') { + if ($listMode) { + $stderr->error('repeated flag', ['flag' => '--list']); + exit(2); + } $listMode = true; continue; } if ($arg === '--last' || $arg === 'last') { - $selector = null; + if ($lastMode) { + $stderr->error('repeated flag', ['flag' => '--last']); + exit(2); + } + $lastMode = true; continue; } if (\str_starts_with($arg, '-')) { $stderr->error('unknown flag', ['flag' => $arg]); exit(2); } + if ($selector !== null) { + $stderr->error('only one positional selector accepted', ['previous' => $selector, 'new' => $arg]); + exit(2); + } $selector = $arg; } +if ($listMode && $lastMode) { + $stderr->error('--list and --last are mutually exclusive'); + exit(2); +} +if ($listMode && $selector !== null) { + $stderr->error('--list does not accept a selector', ['selector' => $selector]); + exit(2); +} +if ($lastMode && $selector !== null) { + $stderr->error('--last does not accept a selector', ['selector' => $selector]); + exit(2); +} + if ($listMode) { exit(printRuns($store, $stderr)); } diff --git a/tests/Unit/Logger/FatalHandlerTestCase.php b/tests/Unit/Logger/FatalHandlerTestCase.php index 68376d031..7d28b4df6 100644 --- a/tests/Unit/Logger/FatalHandlerTestCase.php +++ b/tests/Unit/Logger/FatalHandlerTestCase.php @@ -48,7 +48,7 @@ public function testUserErrorIsRecordedAsFatalViaShutdownFunction(): void $reader = new TranscriptReader($this->directory); $fatal = $reader->findBySection(TranscriptSection::FATAL); - self::assertNotEmpty($fatal, 'Expected a [FATAL] line; transcript content: ' . \file_get_contents($logFile)); + self::assertNotEmpty($fatal, $this->diagnostic('Expected a [FATAL] line', $logFile)); self::assertStringContainsString('intentional fatal', (string) ($fatal[0]->payload['message'] ?? '')); } @@ -60,7 +60,7 @@ public function testUncaughtErrorIsRecordedAsFatalViaExceptionHandler(): void $reader = new TranscriptReader($this->directory); $fatal = $reader->findBySection(TranscriptSection::FATAL); - self::assertNotEmpty($fatal); + self::assertNotEmpty($fatal, $this->diagnostic('FATAL marker missing', $logFile)); self::assertSame(\Error::class, $fatal[0]->attributes['class']); self::assertSame('uncaught fatal', (string) $fatal[0]->payload['message']); } @@ -80,9 +80,16 @@ public function testWritesPriorToFatalArePreserved(): void $boundaries = $reader->findBySection(TranscriptSection::TEST_START); $logs = $reader->findBySection(TranscriptSection::LOG); $fatal = $reader->findBySection(TranscriptSection::FATAL); - self::assertNotEmpty($boundaries, 'TEST_START not preserved across fatal'); - self::assertNotEmpty($logs, 'LOG not preserved across fatal'); - self::assertNotEmpty($fatal, 'FATAL marker missing'); + self::assertNotEmpty($boundaries, $this->diagnostic('TEST_START not preserved across fatal', $logFile)); + self::assertNotEmpty($logs, $this->diagnostic('LOG not preserved across fatal', $logFile)); + self::assertNotEmpty($fatal, $this->diagnostic('FATAL marker missing', $logFile)); + } + + private function diagnostic(string $message, string $logFile): string + { + return $message + . "\nfixture stdout/stderr:\n" . $this->lastFixtureOutput() + . "\ntranscript content:\n" . (string) @\file_get_contents($logFile); } private function buildFixtureScript(string $logFile, string $body): string @@ -100,12 +107,21 @@ private function buildFixtureScript(string $logFile, string $body): string PHP; } + /** @var list */ + private array $lastFixtureOutput = []; + private function executeFixture(string $script): void { - $scriptPath = $this->directory . '/fixture-' . \uniqid() . '.php'; + $scriptPath = $this->directory . '/fixture-' . \uniqid('', true) . '.php'; \file_put_contents($scriptPath, $script); - $command = 'php ' . \escapeshellarg($scriptPath) . ' 2>&1'; + $command = \escapeshellarg(\PHP_BINARY) . ' ' . \escapeshellarg($scriptPath) . ' 2>&1'; \exec($command, $output, $exitCode); + $this->lastFixtureOutput = $output; self::assertNotSame(0, $exitCode, 'Fixture process should exit non-zero on fatal; output: ' . \implode("\n", $output)); } + + private function lastFixtureOutput(): string + { + return \implode("\n", $this->lastFixtureOutput); + } } diff --git a/tests/Unit/Logger/TranscriptWriterTestCase.php b/tests/Unit/Logger/TranscriptWriterTestCase.php index c9a9fc9c8..519f5207b 100644 --- a/tests/Unit/Logger/TranscriptWriterTestCase.php +++ b/tests/Unit/Logger/TranscriptWriterTestCase.php @@ -59,7 +59,7 @@ public function testMultiLineContextIsEscapedOnOneLine(): void $writer->flush(); $raw = \file_get_contents($writer->getPath()); - $bodyLines = \array_values(\array_filter(\explode("\n", $raw), static fn(string $l): bool => $l !== '')); + $bodyLines = \array_values(\array_filter(\explode("\n", $raw), static fn(string $line): bool => $line !== '')); $logLine = null; foreach ($bodyLines as $line) { if (\str_contains($line, '"section":"LOG"')) { @@ -77,16 +77,21 @@ public function testWriteWireRoundTripsFrameBytes(): void $writer = new TranscriptWriter($this->directory . '/wire.log'); $frame = '{"command":"InvokeActivity","payloads":["abc"]}'; $writer->writeWireInbound($frame, ['tickTime' => '2026-05-13'], 42); - $writer->writeWireOutbound($frame, 42); + $writer->writeWireOutbound($frame, 42, 1); + $writer->writeWireOutbound($frame, 42, 2); $writer->flush(); $reader = new TranscriptReader($this->directory); $inbound = $reader->findBySection(TranscriptSection::WIRE_INBOUND); $outbound = $reader->findBySection(TranscriptSection::WIRE_OUTBOUND); self::assertCount(1, $inbound); - self::assertCount(1, $outbound); - self::assertSame(42, $inbound[0]->attributes['frame_id']); + self::assertCount(2, $outbound); + self::assertSame(42, $inbound[0]->attributes['inbound_batch_id']); self::assertSame(\strlen($frame), $inbound[0]->attributes['bytes']); + self::assertSame(['tickTime' => '2026-05-13'], $inbound[0]->payload['headers']); + self::assertSame(42, $outbound[0]->attributes['inbound_batch_id']); + self::assertSame(1, $outbound[0]->attributes['outbound_seq']); + self::assertSame(2, $outbound[1]->attributes['outbound_seq']); $decoded = $inbound[0]->payload['body']['value'] ?? null; self::assertIsArray($decoded); self::assertSame('InvokeActivity', $decoded['command']); @@ -156,11 +161,12 @@ public function testEveryLineCarriesPidAndIsoTimestamp(): void public function testConcurrentWritersUnderLockExProduceWellFormedLines(): void { $path = $this->directory . '/concurrent.log'; - $childCount = 2; + $childCount = 4; $writesPerChild = 50; $baseDir = \dirname(__DIR__, 3); $autoloadPath = \var_export($baseDir . '/vendor/autoload.php', true); $childPaths = []; + /** @var list $processes */ $processes = []; for ($i = 0; $i < $childCount; $i++) { $script = $this->directory . "/child-{$i}.php"; @@ -174,21 +180,29 @@ public function testConcurrentWritersUnderLockExProduceWellFormedLines(): void \$writer->flush(); PHP); $childPaths[] = $script; - $processes[] = \proc_open(['php', $script], [ + $process = \proc_open([\PHP_BINARY, $script], [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ], $pipes); - // close pipes immediately to let child exit - foreach ($pipes as $pipe) { - if (\is_resource($pipe)) { - \fclose($pipe); - } + if (!\is_resource($process)) { + self::fail("proc_open failed for child {$i}"); } + \fclose($pipes[0]); + \fclose($pipes[1]); + $processes[] = ['process' => $process, 'stderr' => $pipes[2], 'index' => $i]; } - foreach ($processes as $process) { - if (\is_resource($process)) { - \proc_close($process); + foreach ($processes as $entry) { + $stderr = \stream_get_contents($entry['stderr']); + \fclose($entry['stderr']); + $exitCode = \proc_close($entry['process']); + if ($exitCode !== 0) { + self::fail(\sprintf( + "child %d exited with %d; stderr:\n%s", + $entry['index'], + $exitCode, + (string) $stderr, + )); } } foreach ($childPaths as $script) { From 4315d68a60a7672e20ab659a84ac2012294dea28 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 17:07:23 +0400 Subject: [PATCH 07/24] refactor: remove unused `rebindWriter` method from FatalHandler --- tests/Acceptance/App/Runtime/FatalHandler.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/Acceptance/App/Runtime/FatalHandler.php b/tests/Acceptance/App/Runtime/FatalHandler.php index 50c8b255f..ed357e7ab 100644 --- a/tests/Acceptance/App/Runtime/FatalHandler.php +++ b/tests/Acceptance/App/Runtime/FatalHandler.php @@ -93,12 +93,4 @@ public static function register(TranscriptWriter $writer, ?LoggerInterface $stde 'pid' => \getmypid() ?: 0, ]); } - - public static function rebindWriter(TranscriptWriter $writer): void - { - self::$writer = $writer; - $writer->writeMeta('fatal_handler_rebound', [ - 'pid' => \getmypid() ?: 0, - ]); - } } From 50291de7282aa32babb77169ea7ac28483c5fb53 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 18:49:55 +0400 Subject: [PATCH 08/24] refactor: centralize transcript path management and improve filesystem handling --- .../Acceptance/App/Feature/WorkerFactory.php | 39 +++---- .../TranscriptActivityInterceptor.php | 27 ++--- .../TranscriptWorkflowInterceptor.php | 43 ++------ tests/Acceptance/App/Logger/LoggerFactory.php | 19 +--- .../App/Logger/TranscriptAdapter.php | 5 +- .../Acceptance/App/Logger/TranscriptPaths.php | 102 ++++++++++++++++++ tests/Acceptance/App/Logger/TranscriptRun.php | 29 +++-- .../Acceptance/App/Logger/TranscriptStore.php | 97 +++++------------ .../App/Logger/TranscriptWriter.php | 52 +++++++-- .../App/Logger/WorkflowHistoryDumper.php | 41 +++---- .../App/Plugin/TranscriptPlugin.php | 10 +- .../App/Runtime/ContainerFacade.php | 4 +- tests/Acceptance/App/TestCase.php | 17 ++- .../App/Transport/RecordingHost.php | 7 +- .../Acceptance/ExecutionStartedSubscriber.php | 7 +- tests/Acceptance/transcript-merge.php | 7 +- tests/Acceptance/worker.php | 5 +- .../Activity/ExternalActivityFixturePaths.php | 20 ++++ .../Fixtures/src/Activity/SimpleActivity.php | 4 +- .../ActivityCompletionClientTestCase.php | 73 ++++++------- tests/Unit/Logger/FatalHandlerTestCase.php | 18 +--- tests/Unit/Logger/TranscriptTestSupport.php | 49 +++++++++ .../Unit/Logger/TranscriptWriterTestCase.php | 18 +--- .../Logger/WorkflowHistoryDumperTestCase.php | 17 +-- .../Unit/Plugin/TranscriptPluginTestCase.php | 26 +++-- 25 files changed, 405 insertions(+), 331 deletions(-) create mode 100644 tests/Acceptance/App/Logger/TranscriptPaths.php create mode 100644 tests/Fixtures/src/Activity/ExternalActivityFixturePaths.php create mode 100644 tests/Unit/Logger/TranscriptTestSupport.php diff --git a/tests/Acceptance/App/Feature/WorkerFactory.php b/tests/Acceptance/App/Feature/WorkerFactory.php index 854262be2..5a1198f54 100644 --- a/tests/Acceptance/App/Feature/WorkerFactory.php +++ b/tests/Acceptance/App/Feature/WorkerFactory.php @@ -5,14 +5,13 @@ namespace Temporal\Tests\Acceptance\App\Feature; use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use Spiral\Core\Attribute\Singleton; use Spiral\Core\InvokerInterface; -use Temporal\Plugin\PluginInterface; use Temporal\Tests\Acceptance\App\Attribute\Worker; +use Temporal\Tests\Acceptance\App\Logger\FanoutLogger; use Temporal\Tests\Acceptance\App\Logger\LoggerFactory; +use Temporal\Tests\Acceptance\App\Logger\TranscriptAdapter; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; -use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; @@ -24,6 +23,8 @@ final class WorkerFactory public function __construct( private readonly WorkerFactoryInterface $workerFactory, private readonly InvokerInterface $invoker, + private readonly ?TranscriptWriter $transcript = null, + private readonly ?LoggerInterface $stderr = null, ) {} public function createWorker( @@ -41,17 +42,7 @@ public function createWorker( $logger = $attribute?->logger === null ? null : $this->invoker->invoke($attribute->logger); if ($attribute?->plugins !== null) { - $registry = $this->workerFactory->getPluginRegistry(); - $registeredNames = \array_map( - static fn(PluginInterface $plugin): string => $plugin->getName(), - $registry->getPlugins(PluginInterface::class), - ); - foreach ($attribute->plugins as $plugin) { - if (!\in_array($plugin->getName(), $registeredNames, true)) { - $registry->add($plugin); - $registeredNames[] = $plugin->getName(); - } - } + $this->workerFactory->getPluginRegistry()->merge($attribute->plugins); } return $this->workerFactory->newWorker( @@ -64,19 +55,15 @@ public function createWorker( private function buildLoggerForFeature(Feature $feature): LoggerInterface { - $container = ContainerFacade::$container ?? null; - if ($container === null || !$container->has(TranscriptWriter::class)) { - return LoggerFactory::createServerLogger($feature->taskQueue); - } - try { - $transcript = $container->get(TranscriptWriter::class); - $stderr = $container->has(LoggerInterface::class) - ? $container->get(LoggerInterface::class) - : new NullLogger(); - return LoggerFactory::createServerLoggerWithTranscript($feature->taskQueue, $transcript, $stderr); - } catch (\Throwable) { - return LoggerFactory::createServerLogger($feature->taskQueue); + $serverLogger = LoggerFactory::createServerLogger($feature->taskQueue); + if ($this->transcript === null || $this->stderr === null) { + return $serverLogger; } + return new FanoutLogger( + $this->stderr, + $serverLogger, + new TranscriptAdapter($this->transcript, $this->stderr), + ); } /** diff --git a/tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php b/tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php index 4169eaca9..9fe66373f 100644 --- a/tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php +++ b/tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php @@ -9,25 +9,25 @@ use Temporal\Interceptor\ActivityInboundInterceptor; use Temporal\Interceptor\Trait\ActivityInboundInterceptorTrait; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; -use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; final class TranscriptActivityInterceptor implements ActivityInboundInterceptor { use ActivityInboundInterceptorTrait; - private ?TranscriptWriter $writer = null; + public function __construct( + private readonly TranscriptWriter $transcript, + ) {} public function handleActivityInbound(ActivityInput $input, callable $next): mixed { - $writer = $this->resolveWriter(); $attributes = $this->buildAttributes(); - $writer?->writeMeta('activity_start', $attributes); + $this->transcript->writeMeta('activity_start', $attributes); try { $result = $next($input); - $writer?->writeMeta('activity_completed', $attributes); + $this->transcript->writeMeta('activity_completed', $attributes); return $result; } catch (\Throwable $exception) { - $writer?->writeException('activity_throw', $attributes, $exception); + $this->transcript->writeException('activity_throw', $attributes, $exception); throw $exception; } } @@ -50,19 +50,4 @@ private function buildAttributes(): array return ['name' => 'unknown', 'attempt' => 0]; } } - - private function resolveWriter(): ?TranscriptWriter - { - if ($this->writer !== null) { - return $this->writer; - } - try { - $container = ContainerFacade::$container ?? null; - if ($container !== null && $container->has(TranscriptWriter::class)) { - $this->writer = $container->get(TranscriptWriter::class); - } - } catch (\Throwable) { - } - return $this->writer; - } } diff --git a/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php b/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php index bf8d7eacc..66f7700b9 100644 --- a/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php +++ b/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php @@ -11,13 +11,14 @@ use Temporal\Interceptor\WorkflowInbound\WorkflowInput; use Temporal\Interceptor\WorkflowInboundCallsInterceptor; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; -use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; final class TranscriptWorkflowInterceptor implements WorkflowInboundCallsInterceptor { use WorkflowInboundCallsInterceptorTrait; - private ?TranscriptWriter $writer = null; + public function __construct( + private readonly TranscriptWriter $transcript, + ) {} public function execute(WorkflowInput $input, callable $next): void { @@ -27,7 +28,7 @@ public function execute(WorkflowInput $input, callable $next): void 'run_id' => $input->info->execution->getRunID(), 'is_replaying' => $input->isReplaying, ]; - $this->runPhase('workflow_execute', $attributes, static fn() => $next($input)); + $this->runPhase('workflow_execute', $attributes, fn() => $next($input)); } public function handleSignal(SignalInput $input, callable $next): void @@ -37,7 +38,7 @@ public function handleSignal(SignalInput $input, callable $next): void 'workflow_id' => $input->info->execution->getID(), 'is_replaying' => $input->isReplaying, ]; - $this->runPhase('workflow_signal', $attributes, static fn() => $next($input)); + $this->runPhase('workflow_signal', $attributes, fn() => $next($input)); } public function handleQuery(QueryInput $input, callable $next): mixed @@ -46,7 +47,7 @@ public function handleQuery(QueryInput $input, callable $next): mixed 'query_name' => $input->queryName, 'workflow_id' => $input->info->execution->getID(), ]; - return $this->runPhase('workflow_query', $attributes, static fn() => $next($input)); + return $this->runPhase('workflow_query', $attributes, fn() => $next($input)); } public function handleUpdate(UpdateInput $input, callable $next): mixed @@ -57,18 +58,12 @@ public function handleUpdate(UpdateInput $input, callable $next): mixed 'workflow_id' => $input->info->execution->getID(), 'is_replaying' => $input->isReplaying, ]; - return $this->runPhase('workflow_update', $attributes, static fn() => $next($input)); + return $this->runPhase('workflow_update', $attributes, fn() => $next($input)); } public function validateUpdate(UpdateInput $input, callable $next): void { - $attributes = [ - 'update_name' => $input->updateName, - 'update_id' => $input->updateId, - 'workflow_id' => $input->info->execution->getID(), - 'is_replaying' => $input->isReplaying, - ]; - $this->runPhase('workflow_validate_update', $attributes, static fn() => $next($input)); + $next($input); } /** @@ -79,30 +74,14 @@ public function validateUpdate(UpdateInput $input, callable $next): void */ private function runPhase(string $phase, array $attributes, callable $execution): mixed { - $writer = $this->resolveWriter(); - $writer?->writeMeta($phase . '_start', $attributes); + $this->transcript->writeMeta($phase . '_start', $attributes); try { $result = $execution(); - $writer?->writeMeta($phase . '_completed', $attributes); + $this->transcript->writeMeta($phase . '_completed', $attributes); return $result; } catch (\Throwable $exception) { - $writer?->writeException($phase, $attributes, $exception); + $this->transcript->writeException($phase, $attributes, $exception); throw $exception; } } - - private function resolveWriter(): ?TranscriptWriter - { - if ($this->writer !== null) { - return $this->writer; - } - try { - $container = ContainerFacade::$container ?? null; - if ($container !== null && $container->has(TranscriptWriter::class)) { - $this->writer = $container->get(TranscriptWriter::class); - } - } catch (\Throwable) { - } - return $this->writer; - } } diff --git a/tests/Acceptance/App/Logger/LoggerFactory.php b/tests/Acceptance/App/Logger/LoggerFactory.php index 3a8df7280..94926ca0f 100644 --- a/tests/Acceptance/App/Logger/LoggerFactory.php +++ b/tests/Acceptance/App/Logger/LoggerFactory.php @@ -4,7 +4,7 @@ namespace Temporal\Tests\Acceptance\App\Logger; -use Psr\Log\LoggerInterface; +use Symfony\Component\Filesystem\Filesystem; final class LoggerFactory { @@ -34,26 +34,11 @@ public static function getLogFilename(string $dir, string $taskQueue): string ); } - public static function createServerLoggerWithTranscript( - string $taskQueue, - TranscriptWriter $transcript, - LoggerInterface $stderr, - ?string $baseDir = null, - ): FanoutLogger { - return new FanoutLogger( - $stderr, - self::createServerLogger($taskQueue, $baseDir), - new TranscriptAdapter($transcript, $stderr), - ); - } - private static function getLogDirectory(?string $baseDir = null): string { $baseDir ??= \dirname(__DIR__, 4); $logDir = $baseDir . '/' . self::DEFAULT_LOG_DIR; - if (!\is_dir($logDir)) { - \mkdir($logDir, 0777, true); - } + (new Filesystem())->mkdir($logDir); return $logDir; } } diff --git a/tests/Acceptance/App/Logger/TranscriptAdapter.php b/tests/Acceptance/App/Logger/TranscriptAdapter.php index dbcb31504..94fb079f1 100644 --- a/tests/Acceptance/App/Logger/TranscriptAdapter.php +++ b/tests/Acceptance/App/Logger/TranscriptAdapter.php @@ -12,13 +12,10 @@ final class TranscriptAdapter implements LoggerInterface { use LoggerTrait; - private readonly LoggerInterface $stderr; - public function __construct( private readonly TranscriptWriter $writer, - ?LoggerInterface $stderr = null, + private readonly LoggerInterface $stderr, ) { - $this->stderr = $stderr ?? new NullLogger(); } public function log($level, \Stringable|string $message, array $context = []): void diff --git a/tests/Acceptance/App/Logger/TranscriptPaths.php b/tests/Acceptance/App/Logger/TranscriptPaths.php new file mode 100644 index 000000000..41619ef05 --- /dev/null +++ b/tests/Acceptance/App/Logger/TranscriptPaths.php @@ -0,0 +1,102 @@ + $max ? \substr($value, 0, $max) : $value; + } +} diff --git a/tests/Acceptance/App/Logger/TranscriptRun.php b/tests/Acceptance/App/Logger/TranscriptRun.php index 1bb399d37..aa8c0f7c6 100644 --- a/tests/Acceptance/App/Logger/TranscriptRun.php +++ b/tests/Acceptance/App/Logger/TranscriptRun.php @@ -4,10 +4,11 @@ namespace Temporal\Tests\Acceptance\App\Logger; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; + final class TranscriptRun { - public const MERGED_DIRECTORY = '_merged'; - public function __construct( public readonly string $id, public readonly string $directory, @@ -19,8 +20,12 @@ public function __construct( */ public function files(): array { - $files = \glob($this->directory . '/*.log'); - return $files === false ? [] : \array_values($files); + $live = \glob($this->directory . '/*.log'); + $rotated = \glob($this->directory . '/*.log.*'); + return \array_values(\array_merge( + \is_array($live) ? $live : [], + \is_array($rotated) ? $rotated : [], + )); } public function totalBytes(): int @@ -39,14 +44,16 @@ public function reader(): TranscriptReader public function merge(): string { - $mergedDirectory = $this->directory . '/' . self::MERGED_DIRECTORY; - if (!\is_dir($mergedDirectory)) { - @\mkdir($mergedDirectory, 0777, true); - } - if (!\is_dir($mergedDirectory)) { - throw new \RuntimeException("Failed to create merged directory: {$mergedDirectory}"); + $mergedDirectory = TranscriptPaths::mergedDirectory($this->directory); + try { + (new Filesystem())->mkdir($mergedDirectory); + } catch (IOException $ioError) { + throw new \RuntimeException( + "Failed to create merged directory: {$mergedDirectory} ({$ioError->getMessage()})", + previous: $ioError, + ); } - $path = $mergedDirectory . '/transcript.log'; + $path = TranscriptPaths::mergedFile($this->directory); $handle = \fopen($path, 'wb'); if ($handle === false) { throw new \RuntimeException("Failed to open merged file: {$path}"); diff --git a/tests/Acceptance/App/Logger/TranscriptStore.php b/tests/Acceptance/App/Logger/TranscriptStore.php index 23a7f4b55..a9b336d98 100644 --- a/tests/Acceptance/App/Logger/TranscriptStore.php +++ b/tests/Acceptance/App/Logger/TranscriptStore.php @@ -6,6 +6,8 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; final class TranscriptStore { @@ -15,13 +17,21 @@ final class TranscriptStore private readonly LoggerInterface $stderr; + private readonly Filesystem $filesystem; + public function __construct( public readonly string $baseDirectory, ?LoggerInterface $stderr = null, ) { $this->stderr = $stderr ?? new NullLogger(); - if (!\is_dir($this->baseDirectory)) { - @\mkdir($this->baseDirectory, 0777, true); + $this->filesystem = new Filesystem(); + try { + $this->filesystem->mkdir($this->baseDirectory); + } catch (IOException $ioError) { + $this->stderr->warning('transcript-store: base directory create failed', [ + 'path' => $this->baseDirectory, + 'message' => $ioError->getMessage(), + ]); } } @@ -38,11 +48,6 @@ public static function create(?string $projectRoot = null, ?LoggerInterface $std return new self($projectRoot . '/' . self::DEFAULT_BASE_RELATIVE, $stderr); } - public static function generateRunId(): string - { - return \date('Ymd-His') . '-' . \bin2hex(\random_bytes(2)); - } - public static function currentRunIdFromEnvironment(): ?string { $runId = \getenv(self::RUN_ID_ENV); @@ -51,15 +56,13 @@ public static function currentRunIdFromEnvironment(): ?string public function runDirectory(string $runId): string { - return $this->baseDirectory . '/' . self::sanitizeRunId($runId); + return TranscriptPaths::runDirectory($this->baseDirectory, $runId); } public function ensureRunDirectory(string $runId): string { $directory = $this->runDirectory($runId); - if (!\is_dir($directory)) { - @\mkdir($directory, 0777, true); - } + $this->filesystem->mkdir($directory); return $directory; } @@ -74,7 +77,7 @@ public function listRuns(): array } $runs = []; foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..' || \str_starts_with($entry, '_')) { + if ($entry === '.' || $entry === '..' || TranscriptPaths::isReservedEntry($entry)) { continue; } $path = $this->baseDirectory . '/' . $entry; @@ -108,14 +111,13 @@ public function latestRun(): ?TranscriptRun public function findRun(string $runId): ?TranscriptRun { - $sanitized = self::sanitizeRunId($runId); - $directory = $this->baseDirectory . '/' . $sanitized; + $directory = TranscriptPaths::runDirectory($this->baseDirectory, $runId); if (!\is_dir($directory)) { return null; } $mtime = @\filemtime($directory); return new TranscriptRun( - id: $sanitized, + id: \basename($directory), directory: $directory, mtime: $mtime === false ? null : $mtime, ); @@ -133,8 +135,14 @@ public function pruneOldRuns(int $keep): int $stale = \array_slice($this->listRuns(), $keep); $deleted = 0; foreach ($stale as $run) { - if ($this->removeDirectoryRecursive($run->directory)) { + try { + $this->filesystem->remove($run->directory); $deleted++; + } catch (IOException $ioError) { + $this->stderr->warning('transcript-store: prune failed', [ + 'path' => $run->directory, + 'message' => $ioError->getMessage(), + ]); } } return $deleted; @@ -143,61 +151,6 @@ public function pruneOldRuns(int $keep): int public function createWriter(string $runId, string $processLabel): TranscriptWriter { $directory = $this->ensureRunDirectory($runId); - return new TranscriptWriter(self::buildFilename($directory, $processLabel), $this->stderr); - } - - private static function sanitizeRunId(string $runId): string - { - $slug = \preg_replace('~[^A-Za-z0-9_-]~', '_', $runId) ?? ''; - if ($slug === '') { - throw new \InvalidArgumentException( - 'Run id sanitizes to an empty string: ' . \var_export($runId, true), - ); - } - if ($slug[0] === '_') { - $slug = 'r' . $slug; - } - return \strlen($slug) > 64 ? \substr($slug, 0, 64) : $slug; - } - - private static function buildFilename(string $directory, string $processLabel): string - { - $slug = \preg_replace('~[^A-Za-z0-9_-]~', '_', $processLabel) ?? 'process'; - if (\strlen($slug) > 40) { - $slug = \substr($slug, 0, 40); - } - $processId = \getmypid() ?: 0; - $startMs = (int) (\microtime(true) * 1000); - return $directory . '/' . $slug . '__pid' . $processId . '__' . $startMs . '.log'; - } - - private function removeDirectoryRecursive(string $path): bool - { - if (\is_link($path)) { - return @\unlink($path); - } - if (!\is_dir($path)) { - return false; - } - $entries = @\scandir($path); - if ($entries === false) { - return false; - } - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - $child = $path . '/' . $entry; - if (\is_link($child)) { - @\unlink($child); - continue; - } - if (\is_dir($child)) { - $this->removeDirectoryRecursive($child); - continue; - } - @\unlink($child); - } - return @\rmdir($path); + return new TranscriptWriter(TranscriptPaths::writerFile($directory, $processLabel), $this->stderr); } } diff --git a/tests/Acceptance/App/Logger/TranscriptWriter.php b/tests/Acceptance/App/Logger/TranscriptWriter.php index e9931b5d8..fd45118a2 100644 --- a/tests/Acceptance/App/Logger/TranscriptWriter.php +++ b/tests/Acceptance/App/Logger/TranscriptWriter.php @@ -6,6 +6,8 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; final class TranscriptWriter { @@ -13,7 +15,6 @@ final class TranscriptWriter private const JSON_FLAGS = \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES - | \JSON_PARTIAL_OUTPUT_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE; /** @var resource|null */ @@ -29,6 +30,8 @@ final class TranscriptWriter private readonly LoggerInterface $stderr; + private readonly Filesystem $filesystem; + private bool $inWrite = false; /** @@ -39,10 +42,11 @@ public function __construct(string $path, ?LoggerInterface $stderr = null) $this->processId = \getmypid() ?: 0; $this->currentPath = $path; $this->stderr = $stderr ?? new NullLogger(); + $this->filesystem = new Filesystem(); $this->openFileDescriptor($path); $this->writeMeta('writer_initialized', [ 'path' => $path, - 'worker_start_epoch_ms' => (int) (\microtime(true) * 1000), + 'worker_start_epoch_ms' => TranscriptPaths::currentEpochMs(), ]); \register_shutdown_function(function (): void { if ($this->fileDescriptor !== null) { @@ -237,10 +241,16 @@ private function doWrite(TranscriptSection $section, array $attributes, mixed $p 'pid' => $this->processId, 'seq' => $this->sequence, 'section' => $section->value, - 'attrs' => new \stdClass(), - 'payload' => ['error' => 'json_encode_failed'], + 'attributes' => (object) $attributes, + 'payload' => ['error' => 'json_encode_failed', 'message' => \json_last_error_msg()], ], self::JSON_FLAGS); } + if ($encoded === false) { + $this->stderr->error('transcript-writer-internal-error: json fallback failed', [ + 'message' => \json_last_error_msg(), + ]); + return; + } $line = $encoded . "\n"; if (!\flock($this->fileDescriptor, \LOCK_EX)) { @@ -265,28 +275,48 @@ private function rotateIfNeeded(): void return; } $this->rotationCounter++; - $rotated = $this->currentPath . '.' . $this->rotationCounter; - @\rename($this->currentPath, $rotated); + $rotated = TranscriptPaths::rotatedFile($this->currentPath, $this->rotationCounter); + try { + $this->filesystem->rename($this->currentPath, $rotated, true); + } catch (IOException $ioError) { + $this->stderr->error('transcript-writer-internal-error: rotate rename failed', [ + 'from' => $this->currentPath, + 'to' => $rotated, + 'message' => $ioError->getMessage(), + ]); + return; + } $this->openFileDescriptor($this->currentPath); - $this->writeMeta('writer_rotated', [ + $this->doWrite(TranscriptSection::META, [ + 'event' => 'writer_rotated', 'from' => $rotated, 'to' => $this->currentPath, 'reason' => 'size_cap', - ]); + ], null); } private function openFileDescriptor(string $path): void { - $directory = \dirname($path); - if (!\is_dir($directory)) { - @\mkdir($directory, 0777, true); + try { + $this->filesystem->mkdir(\dirname($path)); + } catch (IOException $ioError) { + $this->stderr->error('transcript-writer-internal-error: mkdir failed', [ + 'path' => $path, + 'message' => $ioError->getMessage(), + ]); } $resource = @\fopen($path, 'ab'); if ($resource === false) { $this->stderr->error('transcript-writer-internal-error: fopen failed', ['path' => $path]); + if (\is_resource($this->fileDescriptor)) { + @\fclose($this->fileDescriptor); + } $this->fileDescriptor = null; return; } + if (\is_resource($this->fileDescriptor)) { + @\fclose($this->fileDescriptor); + } $this->fileDescriptor = $resource; } diff --git a/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php b/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php index 66f0275a6..3f877874a 100644 --- a/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php +++ b/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php @@ -19,8 +19,26 @@ public function dump( WorkflowClientInterface $workflowClient, array $args, ): void { - $executions = $this->collectExecutions($args); - if ($executions === []) { + $executions = []; + $stubCount = 0; + foreach ($args as $arg) { + if (!$arg instanceof WorkflowStubInterface) { + continue; + } + $stubCount++; + $execution = $arg->getExecution(); + if ($execution->getRunID() === null) { + $transcript->writeMeta('history_skipped', [ + 'reason' => 'no_run_id', + 'workflow_id' => $execution->getID(), + ]); + continue; + } + $key = $execution->getID() . ':' . $execution->getRunID(); + $executions[$key] = $execution; + } + + if ($executions === [] && $stubCount === 0) { $transcript->writeMeta('history_skipped', ['reason' => 'no_executions_inspected']); return; } @@ -30,23 +48,6 @@ public function dump( } } - /** - * @param array $args - * @return array - */ - private function collectExecutions(array $args): array - { - $executions = []; - foreach ($args as $arg) { - if ($arg instanceof WorkflowStubInterface) { - $execution = $arg->getExecution(); - $key = $execution->getID() . ':' . ($execution->getRunID() ?? ''); - $executions[$key] = $execution; - } - } - return $executions; - } - private function dumpExecution( TranscriptWriter $transcript, WorkflowClientInterface $workflowClient, @@ -72,7 +73,7 @@ private function dumpExecution( } $transcript->writeHistoryEvent( $execution->getID(), - $execution->getRunID(), + (string) $execution->getRunID(), $eventAttributes, $payloadJson, ); diff --git a/tests/Acceptance/App/Plugin/TranscriptPlugin.php b/tests/Acceptance/App/Plugin/TranscriptPlugin.php index f7ad9619f..e00e9b997 100644 --- a/tests/Acceptance/App/Plugin/TranscriptPlugin.php +++ b/tests/Acceptance/App/Plugin/TranscriptPlugin.php @@ -8,20 +8,22 @@ use Temporal\Plugin\WorkerPluginContext; use Temporal\Tests\Acceptance\App\Interceptor\TranscriptActivityInterceptor; use Temporal\Tests\Acceptance\App\Interceptor\TranscriptWorkflowInterceptor; +use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; final class TranscriptPlugin extends AbstractPlugin { public const NAME = 'temporal-php.transcript'; - public function __construct() - { + public function __construct( + private readonly TranscriptWriter $transcript, + ) { parent::__construct(self::NAME); } public function configureWorker(WorkerPluginContext $context, callable $next): void { - $context->addInterceptor(new TranscriptActivityInterceptor()); - $context->addInterceptor(new TranscriptWorkflowInterceptor()); + $context->addInterceptor(new TranscriptActivityInterceptor($this->transcript)); + $context->addInterceptor(new TranscriptWorkflowInterceptor($this->transcript)); $next($context); } } diff --git a/tests/Acceptance/App/Runtime/ContainerFacade.php b/tests/Acceptance/App/Runtime/ContainerFacade.php index 8229424c3..7aeedf232 100644 --- a/tests/Acceptance/App/Runtime/ContainerFacade.php +++ b/tests/Acceptance/App/Runtime/ContainerFacade.php @@ -4,7 +4,9 @@ namespace Temporal\Tests\Acceptance\App\Runtime; +use Spiral\Core\Container; + final class ContainerFacade { - public static \Spiral\Core\Container $container; + public static ?Container $container = null; } diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index 110f05382..039cce5b7 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -36,8 +36,6 @@ abstract class TestCase extends \Temporal\Tests\TestCase { - private const TRANSCRIPT_FLUSH_USLEEP = 500_000; - #[\Override] protected function runTest(): mixed { @@ -135,6 +133,13 @@ function (Container $container): mixed { throw $e; } finally { + if ($transcript !== null) { + (new WorkflowHistoryDumper())->dump( + $transcript, + $container->get(WorkflowClientInterface::class), + $args, + ); + } foreach ($args as $arg) { if ($arg instanceof WorkflowStubInterface) { try { @@ -148,13 +153,6 @@ function (Container $container): mixed { } } } - if ($transcript !== null) { - (new WorkflowHistoryDumper())->dump( - $transcript, - $container->get(WorkflowClientInterface::class), - $args, - ); - } $stderr = $container->has(StderrLogger::class) ? $container->get(StderrLogger::class) : null; @@ -190,7 +188,6 @@ function (Container $container): mixed { */ protected function readCurrentTestTranscript(): array { - \usleep(self::TRANSCRIPT_FLUSH_USLEEP); $run = TranscriptStore::create()->currentRun(); if ($run === null) { return []; diff --git a/tests/Acceptance/App/Transport/RecordingHost.php b/tests/Acceptance/App/Transport/RecordingHost.php index 64c14573a..1ff50313b 100644 --- a/tests/Acceptance/App/Transport/RecordingHost.php +++ b/tests/Acceptance/App/Transport/RecordingHost.php @@ -48,8 +48,13 @@ public function send(string $frame): void $this->outboundSeq++; $batchId = $this->inboundBatchId; $sequence = $this->outboundSeq; + try { + $this->inner->send($frame); + } catch (\Throwable $error) { + $this->record(fn() => $this->transcript->writeWireError($error)); + throw $error; + } $this->record(fn() => $this->transcript->writeWireOutbound($frame, $batchId, $sequence)); - $this->inner->send($frame); } public function error(\Throwable $error): void diff --git a/tests/Acceptance/ExecutionStartedSubscriber.php b/tests/Acceptance/ExecutionStartedSubscriber.php index f62f635bd..84cc43e3e 100644 --- a/tests/Acceptance/ExecutionStartedSubscriber.php +++ b/tests/Acceptance/ExecutionStartedSubscriber.php @@ -27,6 +27,7 @@ use Temporal\DataConverter\DataConverterInterface; use Temporal\Testing\Environment; use Temporal\Tests\Acceptance\App\Feature\WorkflowStubInjector; +use Temporal\Tests\Acceptance\App\Logger\TranscriptPaths; use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; @@ -84,13 +85,15 @@ public function notify(ExecutionStarted $event): void $container->bindSingleton(LoggerInterface::class, $logger); $container->bindSingleton(StderrLogger::class, $logger); - $runId = TranscriptStore::currentRunIdFromEnvironment() ?? TranscriptStore::generateRunId(); + $runId = TranscriptStore::currentRunIdFromEnvironment() ?? TranscriptPaths::generateRunId(); \putenv('TEMPORAL_TRANSCRIPT_RUN_ID=' . $runId); $_ENV['TEMPORAL_TRANSCRIPT_RUN_ID'] = $runId; $_SERVER['TEMPORAL_TRANSCRIPT_RUN_ID'] = $runId; $logger->info('[transcript] run id', ['run_id' => $runId]); - $testTranscript = TranscriptStore::create(stderr: $logger)->createWriter($runId, 'test'); + $transcriptStore = TranscriptStore::create(stderr: $logger); + $transcriptStore->pruneOldRuns(keep: 20); + $testTranscript = $transcriptStore->createWriter($runId, 'test'); $container->bindSingleton(TranscriptWriter::class, $testTranscript); $temporalRunner = new TemporalStarter($environment); diff --git a/tests/Acceptance/transcript-merge.php b/tests/Acceptance/transcript-merge.php index 2a5b2d409..0a132650a 100644 --- a/tests/Acceptance/transcript-merge.php +++ b/tests/Acceptance/transcript-merge.php @@ -65,7 +65,12 @@ exit(printRuns($store, $stderr)); } -$run = $selector === null ? $store->latestRun() : $store->findRun($selector); +try { + $run = $selector === null ? $store->latestRun() : $store->findRun($selector); +} catch (\InvalidArgumentException $invalidSelector) { + $stderr->error('invalid selector', ['selector' => $selector, 'message' => $invalidSelector->getMessage()]); + exit(2); +} if ($run === null) { $stderr->error( $selector === null ? 'no transcript runs found' : 'transcript run not found', diff --git a/tests/Acceptance/worker.php b/tests/Acceptance/worker.php index 9119c3492..ef9f1b321 100644 --- a/tests/Acceptance/worker.php +++ b/tests/Acceptance/worker.php @@ -28,7 +28,6 @@ use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; use Temporal\Tests\Acceptance\App\Plugin\TranscriptPlugin; -use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\FatalHandler; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Tests\Acceptance\App\Runtime\State; @@ -85,8 +84,8 @@ ); $run = $runtime->command; $container = new Spiral\Core\Container(); - ContainerFacade::$container = $container; $container->bindSingleton(TranscriptWriter::class, $workerTranscript); + $container->bindSingleton(LoggerInterface::class, $logger); $converters = [ new NullConverter(), @@ -101,7 +100,7 @@ $converter = new DataConverter(...$converters); $container->bindSingleton(DataConverter::class, $converter); - $plugins = [new TranscriptPlugin]; + $plugins = [new TranscriptPlugin($workerTranscript)]; $container->bindSingleton( WorkerFactoryInterface::class, WorkerFactory::create( diff --git a/tests/Fixtures/src/Activity/ExternalActivityFixturePaths.php b/tests/Fixtures/src/Activity/ExternalActivityFixturePaths.php new file mode 100644 index 000000000..a7d52f84d --- /dev/null +++ b/tests/Fixtures/src/Activity/ExternalActivityFixturePaths.php @@ -0,0 +1,20 @@ +taskToken); + file_put_contents(ExternalActivityFixturePaths::tokenPath(), Activity::getInfo()->taskToken); file_put_contents( - 'runtime/activityId', + ExternalActivityFixturePaths::idPath(), json_encode( [ 'id' => Activity::getInfo()->workflowExecution->getID(), diff --git a/tests/Functional/Client/ActivityCompletionClientTestCase.php b/tests/Functional/Client/ActivityCompletionClientTestCase.php index 122cbb8b4..02062e89c 100644 --- a/tests/Functional/Client/ActivityCompletionClientTestCase.php +++ b/tests/Functional/Client/ActivityCompletionClientTestCase.php @@ -18,6 +18,7 @@ use Temporal\Exception\Client\WorkflowFailedException; use Temporal\Exception\Failure\ActivityFailure; use Temporal\Exception\Failure\ApplicationFailure; +use Temporal\Tests\Activity\ExternalActivityFixturePaths; /** * @group client @@ -35,10 +36,10 @@ public function testCompleteAsyncActivityById() $this->assertNotEmpty($e->getExecution()->getRunID()); sleep(2); - $this->assertFileExists('runtime/activityId'); - $data = json_decode(file_get_contents('runtime/activityId')); - unlink('runtime/taskToken'); - unlink('runtime/activityId'); + $this->assertFileExists(ExternalActivityFixturePaths::idPath()); + $data = json_decode(file_get_contents(ExternalActivityFixturePaths::idPath())); + unlink(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::idPath()); $act = $client->newActivityCompletionClient(); @@ -57,10 +58,10 @@ public function testCompleteAsyncActivityByIdExplicit() $this->assertNotEmpty($e->getExecution()->getRunID()); sleep(1); - $this->assertFileExists('runtime/activityId'); - $data = json_decode(file_get_contents('runtime/activityId')); - unlink('runtime/taskToken'); - unlink('runtime/activityId'); + $this->assertFileExists(ExternalActivityFixturePaths::idPath()); + $data = json_decode(file_get_contents(ExternalActivityFixturePaths::idPath())); + unlink(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::idPath()); $act = $client->newActivityCompletionClient(); @@ -79,10 +80,10 @@ public function testCompleteAsyncActivityByIdInvalid() $this->assertNotEmpty($e->getExecution()->getRunID()); sleep(1); - $this->assertFileExists('runtime/activityId'); - $data = json_decode(file_get_contents('runtime/activityId')); - unlink('runtime/taskToken'); - unlink('runtime/activityId'); + $this->assertFileExists(ExternalActivityFixturePaths::idPath()); + $data = json_decode(file_get_contents(ExternalActivityFixturePaths::idPath())); + unlink(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::idPath()); $act = $client->newActivityCompletionClient(); @@ -105,10 +106,10 @@ public function testCompleteAsyncActivityByToken() $this->assertNotEmpty($e->getExecution()->getRunID()); sleep(1); - $this->assertFileExists('runtime/taskToken'); - $taskToken = file_get_contents('runtime/taskToken'); - unlink('runtime/taskToken'); - unlink('runtime/activityId'); + $this->assertFileExists(ExternalActivityFixturePaths::tokenPath()); + $taskToken = file_get_contents(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::idPath()); $act = $client->newActivityCompletionClient(); @@ -127,11 +128,11 @@ public function testCompleteAsyncActivityByTokenInvalid() $this->assertNotEmpty($e->getExecution()->getRunID()); sleep(1); - $this->assertFileExists('runtime/taskToken'); - $taskToken = file_get_contents('runtime/taskToken'); + $this->assertFileExists(ExternalActivityFixturePaths::tokenPath()); + $taskToken = file_get_contents(ExternalActivityFixturePaths::tokenPath()); - unlink('runtime/taskToken'); - unlink('runtime/activityId'); + unlink(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::idPath()); $act = $client->newActivityCompletionClient(); @@ -154,10 +155,10 @@ public function testCompleteAsyncActivityByTokenExceptionally() $this->assertNotEmpty($e->getExecution()->getRunID()); sleep(1); - $this->assertFileExists('runtime/taskToken'); - $taskToken = file_get_contents('runtime/taskToken'); - unlink('runtime/taskToken'); - unlink('runtime/activityId'); + $this->assertFileExists(ExternalActivityFixturePaths::tokenPath()); + $taskToken = file_get_contents(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::idPath()); $act = $client->newActivityCompletionClient(); @@ -185,10 +186,10 @@ public function testCompleteAsyncActivityByTokenExceptionallyById() $this->assertNotEmpty($e->getExecution()->getRunID()); sleep(2); - $this->assertFileExists('runtime/taskToken'); - $data = json_decode(file_get_contents('runtime/activityId')); - unlink('runtime/taskToken'); - unlink('runtime/activityId'); + $this->assertFileExists(ExternalActivityFixturePaths::tokenPath()); + $data = json_decode(file_get_contents(ExternalActivityFixturePaths::idPath())); + unlink(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::idPath()); $act = $client->newActivityCompletionClient(); @@ -222,10 +223,10 @@ public function testHeartBeatByID() $this->assertNotEmpty($e->getExecution()->getRunID()); sleep(1); - $this->assertFileExists('runtime/taskToken'); - $data = json_decode(file_get_contents('runtime/activityId')); - unlink('runtime/taskToken'); - unlink('runtime/activityId'); + $this->assertFileExists(ExternalActivityFixturePaths::tokenPath()); + $data = json_decode(file_get_contents(ExternalActivityFixturePaths::idPath())); + unlink(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::idPath()); $act = $client->newActivityCompletionClient(); @@ -269,10 +270,10 @@ public function testHeartBeatByToken() $this->assertNotEmpty($e->getExecution()->getRunID()); sleep(1); - $this->assertFileExists('runtime/taskToken'); - $taskToken = file_get_contents('runtime/taskToken'); - unlink('runtime/taskToken'); - unlink('runtime/activityId'); + $this->assertFileExists(ExternalActivityFixturePaths::tokenPath()); + $taskToken = file_get_contents(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::tokenPath()); + unlink(ExternalActivityFixturePaths::idPath()); $act = $client->newActivityCompletionClient(); diff --git a/tests/Unit/Logger/FatalHandlerTestCase.php b/tests/Unit/Logger/FatalHandlerTestCase.php index 7d28b4df6..d3f1f6111 100644 --- a/tests/Unit/Logger/FatalHandlerTestCase.php +++ b/tests/Unit/Logger/FatalHandlerTestCase.php @@ -22,23 +22,7 @@ #[UsesClass(MalformedTranscriptException::class)] final class FatalHandlerTestCase extends TestCase { - private string $directory; - - protected function setUp(): void - { - $this->directory = \sys_get_temp_dir() . '/fatal-handler-' . \getmypid() . '-' . \uniqid(); - \mkdir($this->directory, 0777, true); - } - - protected function tearDown(): void - { - foreach (\glob($this->directory . '/*') ?: [] as $path) { - if (\is_file($path)) { - \unlink($path); - } - } - @\rmdir($this->directory); - } + use TranscriptTestSupport; public function testUserErrorIsRecordedAsFatalViaShutdownFunction(): void { diff --git a/tests/Unit/Logger/TranscriptTestSupport.php b/tests/Unit/Logger/TranscriptTestSupport.php new file mode 100644 index 000000000..2f40c7ea8 --- /dev/null +++ b/tests/Unit/Logger/TranscriptTestSupport.php @@ -0,0 +1,49 @@ +filesystem = new Filesystem(); + $this->directory = $this->makeTempDirectory(); + $this->filesystem->mkdir($this->directory); + } + + #[\Override] + protected function tearDown(): void + { + try { + $this->filesystem->remove($this->directory); + } catch (IOException) { + } + } + + private function makeTempDirectory(): string + { + $shortName = \strtolower((new \ReflectionClass(static::class))->getShortName()); + return \sys_get_temp_dir() + . '/' . $shortName + . '-' . (\getmypid() ?: 0) + . '-' . \uniqid('', true); + } +} diff --git a/tests/Unit/Logger/TranscriptWriterTestCase.php b/tests/Unit/Logger/TranscriptWriterTestCase.php index 519f5207b..8501c1d21 100644 --- a/tests/Unit/Logger/TranscriptWriterTestCase.php +++ b/tests/Unit/Logger/TranscriptWriterTestCase.php @@ -20,23 +20,7 @@ #[UsesClass(MalformedTranscriptException::class)] final class TranscriptWriterTestCase extends TestCase { - private string $directory; - - protected function setUp(): void - { - $this->directory = \sys_get_temp_dir() . '/temporal-transcript-test-' . \getmypid() . '-' . \uniqid(); - \mkdir($this->directory, 0777, true); - } - - protected function tearDown(): void - { - foreach (\glob($this->directory . '/*') ?: [] as $path) { - if (\is_file($path)) { - \unlink($path); - } - } - @\rmdir($this->directory); - } + use TranscriptTestSupport; public function testWriteLogProducesParseableLine(): void { diff --git a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php index 976331bee..21488a49b 100644 --- a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php +++ b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php @@ -40,23 +40,8 @@ #[UsesClass(Timestamp::class)] final class WorkflowHistoryDumperTestCase extends TestCase { - private string $directory; + use TranscriptTestSupport; - protected function setUp(): void - { - $this->directory = \sys_get_temp_dir() . '/history-dumper-' . \getmypid() . '-' . \uniqid(); - \mkdir($this->directory, 0777, true); - } - - protected function tearDown(): void - { - foreach (\glob($this->directory . '/*') ?: [] as $path) { - if (\is_file($path)) { - \unlink($path); - } - } - @\rmdir($this->directory); - } public function testWritesHistorySkippedMetaWhenArgsAreEmpty(): void { diff --git a/tests/Unit/Plugin/TranscriptPluginTestCase.php b/tests/Unit/Plugin/TranscriptPluginTestCase.php index 8e318f2eb..6f8c33cb5 100644 --- a/tests/Unit/Plugin/TranscriptPluginTestCase.php +++ b/tests/Unit/Plugin/TranscriptPluginTestCase.php @@ -13,7 +13,9 @@ use Temporal\Plugin\WorkerPluginInterface; use Temporal\Tests\Acceptance\App\Interceptor\TranscriptActivityInterceptor; use Temporal\Tests\Acceptance\App\Interceptor\TranscriptWorkflowInterceptor; +use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; use Temporal\Tests\Acceptance\App\Plugin\TranscriptPlugin; +use Temporal\Tests\Unit\Logger\TranscriptTestSupport; use Temporal\Worker\WorkerOptions; #[CoversClass(TranscriptPlugin::class)] @@ -25,16 +27,19 @@ #[UsesClass(TranscriptWorkflowInterceptor::class)] final class TranscriptPluginTestCase extends TestCase { + use TranscriptTestSupport; + public function testGetNameReturnsCanonicalIdentifier(): void { - $plugin = new TranscriptPlugin(); + $plugin = new TranscriptPlugin($this->newWriter()); self::assertSame('temporal-php.transcript', $plugin->getName()); } public function testConfigureWorkerAddsActivityAndWorkflowInterceptors(): void { - $plugin = new TranscriptPlugin(); + $writer = $this->newWriter(); + $plugin = new TranscriptPlugin($writer); $context = new WorkerPluginContext('test-queue', WorkerOptions::new()); $nextCalled = false; @@ -52,9 +57,10 @@ public function testConfigureWorkerAddsActivityAndWorkflowInterceptors(): void public function testConfigureWorkerAppendsInterceptorsWithoutClobberingExistingOnes(): void { - $plugin = new TranscriptPlugin(); + $writer = $this->newWriter(); + $plugin = new TranscriptPlugin($writer); $context = new WorkerPluginContext('test-queue', WorkerOptions::new()); - $existing = new TranscriptActivityInterceptor(); + $existing = new TranscriptActivityInterceptor($writer); $context->addInterceptor($existing); $plugin->configureWorker($context, static fn() => null); @@ -69,7 +75,7 @@ public function testConfigureWorkerAppendsInterceptorsWithoutClobberingExistingO public function testRegistryExposesPluginUnderWorkerPluginInterface(): void { $registry = new PluginRegistry(); - $plugin = new TranscriptPlugin(); + $plugin = new TranscriptPlugin($this->newWriter()); $registry->add($plugin); $workerPlugins = $registry->getPlugins(WorkerPluginInterface::class); @@ -79,12 +85,18 @@ public function testRegistryExposesPluginUnderWorkerPluginInterface(): void public function testRegistryRejectsDuplicateRegistration(): void { + $writer = $this->newWriter(); $registry = new PluginRegistry(); - $registry->add(new TranscriptPlugin()); + $registry->add(new TranscriptPlugin($writer)); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Duplicate plugin "temporal-php.transcript": a plugin with this name is already registered.'); - $registry->add(new TranscriptPlugin()); + $registry->add(new TranscriptPlugin($writer)); + } + + private function newWriter(): TranscriptWriter + { + return new TranscriptWriter($this->directory . '/' . \uniqid('plugin-', true) . '.log'); } } From b810eb622df5bcf43b46d0eec2d3c7d40ddeddcd Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 21:10:19 +0400 Subject: [PATCH 09/24] refactor: simplify FatalHandler logic and enhance logger injection consistency throughout tests --- tests/Acceptance/.rr.yaml | 1 - .../Acceptance/App/Feature/WorkerFactory.php | 21 +++++++------ .../TranscriptWorkflowInterceptor.php | 2 +- .../App/Logger/TranscriptAdapter.php | 1 - .../App/Runtime/ContainerFacade.php | 2 +- tests/Acceptance/App/Runtime/FatalHandler.php | 31 +++++++------------ tests/Acceptance/App/Runtime/RRStarter.php | 1 + tests/Acceptance/App/TestCase.php | 23 +++++++------- .../Transcript/TranscriptHappyPathTest.php | 3 +- .../Extra/Transcript/TranscriptRetryTest.php | 3 +- tests/Unit/Logger/FatalHandlerTestCase.php | 5 +-- .../Unit/Plugin/TranscriptPluginTestCase.php | 12 ------- 12 files changed, 44 insertions(+), 61 deletions(-) diff --git a/tests/Acceptance/.rr.yaml b/tests/Acceptance/.rr.yaml index e26335039..0a9fe20bc 100644 --- a/tests/Acceptance/.rr.yaml +++ b/tests/Acceptance/.rr.yaml @@ -5,7 +5,6 @@ rpc: server: command: "php worker.php" env: - TEMPORAL_WIRE_TRACE: "1" TEMPORAL_TRANSCRIPT_DIR: "runtime/tests/transcripts" # Workflow and activity mesh service diff --git a/tests/Acceptance/App/Feature/WorkerFactory.php b/tests/Acceptance/App/Feature/WorkerFactory.php index 5a1198f54..48088f067 100644 --- a/tests/Acceptance/App/Feature/WorkerFactory.php +++ b/tests/Acceptance/App/Feature/WorkerFactory.php @@ -23,8 +23,8 @@ final class WorkerFactory public function __construct( private readonly WorkerFactoryInterface $workerFactory, private readonly InvokerInterface $invoker, + private readonly LoggerInterface $logger, private readonly ?TranscriptWriter $transcript = null, - private readonly ?LoggerInterface $stderr = null, ) {} public function createWorker( @@ -40,6 +40,9 @@ public function createWorker( ? null : $this->invoker->invoke($attribute->pipelineProvider); $logger = $attribute?->logger === null ? null : $this->invoker->invoke($attribute->logger); + if ($logger !== null && !$logger instanceof LoggerInterface) { + throw new \InvalidArgumentException(sprintf("Logger must implement PSR-3 LoggerInterface, got %s", \get_debug_type($logger))); + } if ($attribute?->plugins !== null) { $this->workerFactory->getPluginRegistry()->merge($attribute->plugins); @@ -49,21 +52,19 @@ public function createWorker( $feature->taskQueue, $options ?? WorkerOptions::new()->withMaxConcurrentActivityExecutionSize(10), interceptorProvider: $interceptorProvider, - logger: $logger ?? $this->buildLoggerForFeature($feature), + logger: $this->decorateLogger($logger, $feature), ); } - private function buildLoggerForFeature(Feature $feature): LoggerInterface + private function decorateLogger(?LoggerInterface $logger, Feature $feature): LoggerInterface { $serverLogger = LoggerFactory::createServerLogger($feature->taskQueue); - if ($this->transcript === null || $this->stderr === null) { - return $serverLogger; + $loggers = [$this->logger, $logger, $serverLogger]; + if ($this->transcript !== null) { + $loggers[] = new TranscriptAdapter($this->transcript, $this->logger); } - return new FanoutLogger( - $this->stderr, - $serverLogger, - new TranscriptAdapter($this->transcript, $this->stderr), - ); + + return new FanoutLogger(...array_filter($loggers)); } /** diff --git a/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php b/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php index 66f7700b9..28ebe96b8 100644 --- a/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php +++ b/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php @@ -80,7 +80,7 @@ private function runPhase(string $phase, array $attributes, callable $execution) $this->transcript->writeMeta($phase . '_completed', $attributes); return $result; } catch (\Throwable $exception) { - $this->transcript->writeException($phase, $attributes, $exception); + $this->transcript->writeException($phase . '_failed', $attributes, $exception); throw $exception; } } diff --git a/tests/Acceptance/App/Logger/TranscriptAdapter.php b/tests/Acceptance/App/Logger/TranscriptAdapter.php index 94fb079f1..8e1c73560 100644 --- a/tests/Acceptance/App/Logger/TranscriptAdapter.php +++ b/tests/Acceptance/App/Logger/TranscriptAdapter.php @@ -6,7 +6,6 @@ use Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; -use Psr\Log\NullLogger; final class TranscriptAdapter implements LoggerInterface { diff --git a/tests/Acceptance/App/Runtime/ContainerFacade.php b/tests/Acceptance/App/Runtime/ContainerFacade.php index 7aeedf232..c5498bdeb 100644 --- a/tests/Acceptance/App/Runtime/ContainerFacade.php +++ b/tests/Acceptance/App/Runtime/ContainerFacade.php @@ -8,5 +8,5 @@ final class ContainerFacade { - public static ?Container $container = null; + public static Container $container; } diff --git a/tests/Acceptance/App/Runtime/FatalHandler.php b/tests/Acceptance/App/Runtime/FatalHandler.php index ed357e7ab..26833b21b 100644 --- a/tests/Acceptance/App/Runtime/FatalHandler.php +++ b/tests/Acceptance/App/Runtime/FatalHandler.php @@ -18,63 +18,56 @@ final class FatalHandler \E_USER_ERROR, ]; - private static ?TranscriptWriter $writer = null; - - private static ?LoggerInterface $stderr = null; - private static bool $inHandler = false; private static bool $registered = false; - public static function register(TranscriptWriter $writer, ?LoggerInterface $stderr = null): void + public static function register(TranscriptWriter $writer, LoggerInterface $stderr): void { - self::$writer = $writer; - self::$stderr = $stderr ?? new NullLogger(); - if (self::$registered) { return; } self::$registered = true; - \set_error_handler(static function (int $type, string $message, string $file, int $line): bool { + \set_error_handler(static function (int $type, string $message, string $file, int $line) use ($writer): bool { if (self::$inHandler) { return false; } self::$inHandler = true; try { - self::$writer?->writeError($type, $message, $file, $line); + $writer->writeError($type, $message, $file, $line); } finally { self::$inHandler = false; } return false; }); - \set_exception_handler(static function (\Throwable $throwable): void { + \set_exception_handler(static function (\Throwable $throwable) use ($stderr, $writer): void { if (self::$inHandler) { return; } self::$inHandler = true; try { - self::$writer?->writeFatal($throwable); - self::$writer?->flush(); + $writer->writeFatal($throwable); + $writer->flush(); } finally { self::$inHandler = false; } - self::$stderr?->critical('fatal', [ + $stderr->critical('fatal', [ 'class' => $throwable::class, 'message' => $throwable->getMessage(), ]); exit(1); }); - \register_shutdown_function(static function (): void { + \register_shutdown_function(static function () use ($writer): void { $error = \error_get_last(); if ($error === null) { - self::$writer?->flush(); + $writer->flush(); return; } if (!\in_array((int) $error['type'], self::FATAL_ERROR_TYPES, true)) { - self::$writer?->flush(); + $writer->flush(); return; } if (self::$inHandler) { @@ -82,8 +75,8 @@ public static function register(TranscriptWriter $writer, ?LoggerInterface $stde } self::$inHandler = true; try { - self::$writer?->writeFatalFromError($error); - self::$writer?->flush(); + $writer->writeFatalFromError($error); + $writer->flush(); } finally { self::$inHandler = false; } diff --git a/tests/Acceptance/App/Runtime/RRStarter.php b/tests/Acceptance/App/Runtime/RRStarter.php index 8bd10e775..3bbaaec20 100644 --- a/tests/Acceptance/App/Runtime/RRStarter.php +++ b/tests/Acceptance/App/Runtime/RRStarter.php @@ -6,6 +6,7 @@ use Temporal\Testing\Environment; use Temporal\Testing\SystemInfo; +use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; final class RRStarter { diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index 039cce5b7..68f84c2c0 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -56,6 +56,7 @@ protected function runTest(): mixed LoggerInterface::class => ClientLogger::class, ClientLogger::class => $logger, ]; + $workflowClient = $container->get(WorkflowClientInterface::class); // Auto-inject plugin-configured client from #[Worker(plugins: [...])] attribute $workerAttr = WorkerFactory::findAttribute(static::class); @@ -63,9 +64,8 @@ protected function runTest(): mixed $pluginRegistry = new PluginRegistry($workerAttr->plugins); $clientPlugins = $pluginRegistry->getPlugins(ClientPluginInterface::class); if ($clientPlugins !== []) { - $existingClient = $container->get(WorkflowClientInterface::class); $pluginClient = WorkflowClient::create( - serviceClient: $existingClient->getServiceClient(), + serviceClient: $workflowClient->getServiceClient(), options: (new ClientOptions())->withNamespace($runtime->namespace), pluginRegistry: new PluginRegistry($workerAttr->plugins), ); @@ -75,7 +75,7 @@ protected function runTest(): mixed return $container->runScope( new Scope(name: 'feature', bindings: $bindings), - function (Container $container): mixed { + function (Container $container) use ($workflowClient): mixed { $args = []; $caughtException = null; $startedAt = \microtime(true); @@ -106,7 +106,7 @@ function (Container $container): mixed { echo "\n=== Stack trace ===\n"; echo $e->getTraceAsString(); echo "\n=== Workflow history ===\n"; - $this->printWorkflowHistory($container->get(WorkflowClientInterface::class), $args); + $this->printWorkflowHistory($workflowClient, $args); $logRecords = $container->get(ClientLogger::class)->getRecords(); if ($logRecords !== []) { @@ -134,11 +134,10 @@ function (Container $container): mixed { throw $e; } finally { if ($transcript !== null) { - (new WorkflowHistoryDumper())->dump( - $transcript, - $container->get(WorkflowClientInterface::class), - $args, - ); + $dumper = $container->has(WorkflowHistoryDumper::class) + ? $container->get(WorkflowHistoryDumper::class) + : null; + $dumper?->dump($transcript, $workflowClient, $args); } foreach ($args as $arg) { if ($arg instanceof WorkflowStubInterface) { @@ -153,9 +152,6 @@ function (Container $container): mixed { } } } - $stderr = $container->has(StderrLogger::class) - ? $container->get(StderrLogger::class) - : null; if ($transcript !== null) { $status = match (true) { $caughtException === null => 'passed', @@ -174,6 +170,9 @@ function (Container $container): mixed { $transcript->writeTestBoundary(TranscriptSection::TEST_END, $endAttributes); $transcript->flush(); if ($caughtException !== null && !$caughtException instanceof SkippedTest) { + $stderr = $container->has(StderrLogger::class) + ? $container->get(StderrLogger::class) + : null; $stderr?->error('transcript', ['path' => $transcript->getPath()]); $stderr?->info('run `composer transcripts:last` to view the merged stream'); } diff --git a/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php b/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php index a72fb4005..1cfeabc7e 100644 --- a/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php +++ b/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php @@ -7,6 +7,7 @@ use Temporal\Activity; use Temporal\Activity\ActivityInterface; use Temporal\Activity\ActivityMethod; +use Temporal\Activity\ActivityOptions; use Temporal\Client\WorkflowStubInterface; use Temporal\Tests\Acceptance\App\Attribute\Stub; use Temporal\Tests\Acceptance\App\Logger\TranscriptLine; @@ -67,7 +68,7 @@ public function run(): \Generator { $activity = Workflow::newActivityStub( HappyPathActivity::class, - Activity\ActivityOptions::new()->withScheduleToCloseTimeout(10), + ActivityOptions::new()->withScheduleToCloseTimeout(10), ); return yield $activity->greet(); } diff --git a/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php b/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php index e8e5bada3..6d173fe53 100644 --- a/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php +++ b/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php @@ -7,6 +7,7 @@ use Temporal\Activity; use Temporal\Activity\ActivityInterface; use Temporal\Activity\ActivityMethod; +use Temporal\Activity\ActivityOptions; use Temporal\Client\WorkflowStubInterface; use Temporal\Common\RetryOptions; use Temporal\Exception\Failure\ApplicationFailure; @@ -73,7 +74,7 @@ public function run(): \Generator { $activity = Workflow::newActivityStub( RetryActivity::class, - Activity\ActivityOptions::new() + ActivityOptions::new() ->withScheduleToCloseTimeout(30) ->withRetryOptions(RetryOptions::new()->withMaximumAttempts(3)->withInitialInterval(1)), ); diff --git a/tests/Unit/Logger/FatalHandlerTestCase.php b/tests/Unit/Logger/FatalHandlerTestCase.php index d3f1f6111..dd75ef4c5 100644 --- a/tests/Unit/Logger/FatalHandlerTestCase.php +++ b/tests/Unit/Logger/FatalHandlerTestCase.php @@ -4,6 +4,7 @@ namespace Temporal\Tests\Unit\Logger; +use JetBrains\PhpStorm\Language; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; @@ -73,10 +74,10 @@ private function diagnostic(string $message, string $logFile): string { return $message . "\nfixture stdout/stderr:\n" . $this->lastFixtureOutput() - . "\ntranscript content:\n" . (string) @\file_get_contents($logFile); + . "\ntranscript content:\n" . @\file_get_contents($logFile); } - private function buildFixtureScript(string $logFile, string $body): string + private function buildFixtureScript(string $logFile, #[Language("PHP")]string $body): string { $baseDir = \dirname(__DIR__, 3); $autoloadPath = \var_export($baseDir . '/vendor/autoload.php', true); diff --git a/tests/Unit/Plugin/TranscriptPluginTestCase.php b/tests/Unit/Plugin/TranscriptPluginTestCase.php index 6f8c33cb5..8f560077e 100644 --- a/tests/Unit/Plugin/TranscriptPluginTestCase.php +++ b/tests/Unit/Plugin/TranscriptPluginTestCase.php @@ -83,18 +83,6 @@ public function testRegistryExposesPluginUnderWorkerPluginInterface(): void self::assertSame($plugin, $workerPlugins[0]); } - public function testRegistryRejectsDuplicateRegistration(): void - { - $writer = $this->newWriter(); - $registry = new PluginRegistry(); - $registry->add(new TranscriptPlugin($writer)); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Duplicate plugin "temporal-php.transcript": a plugin with this name is already registered.'); - - $registry->add(new TranscriptPlugin($writer)); - } - private function newWriter(): TranscriptWriter { return new TranscriptWriter($this->directory . '/' . \uniqid('plugin-', true) . '.log'); From 74f3fa51bd4726e6a8c03cca9b0ebb68f9786643 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 21:12:25 +0400 Subject: [PATCH 10/24] refactor: centralize `runId` generation with `getOrCreateRunId` method in TranscriptStore --- tests/Acceptance/App/Logger/TranscriptStore.php | 11 +++++++++++ tests/Acceptance/ExecutionStartedSubscriber.php | 6 +----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/Acceptance/App/Logger/TranscriptStore.php b/tests/Acceptance/App/Logger/TranscriptStore.php index a9b336d98..352a08e9f 100644 --- a/tests/Acceptance/App/Logger/TranscriptStore.php +++ b/tests/Acceptance/App/Logger/TranscriptStore.php @@ -54,6 +54,17 @@ public static function currentRunIdFromEnvironment(): ?string return \is_string($runId) && $runId !== '' ? $runId : null; } + public static function getOrCreateRunId(): string + { + $runId = self::currentRunIdFromEnvironment(); + if ($runId !== null) { + return $runId; + } + $runId = TranscriptPaths::generateRunId(); + \putenv(self::RUN_ID_ENV . '=' . $runId); + return $runId; + } + public function runDirectory(string $runId): string { return TranscriptPaths::runDirectory($this->baseDirectory, $runId); diff --git a/tests/Acceptance/ExecutionStartedSubscriber.php b/tests/Acceptance/ExecutionStartedSubscriber.php index 84cc43e3e..aadcb8c3a 100644 --- a/tests/Acceptance/ExecutionStartedSubscriber.php +++ b/tests/Acceptance/ExecutionStartedSubscriber.php @@ -27,7 +27,6 @@ use Temporal\DataConverter\DataConverterInterface; use Temporal\Testing\Environment; use Temporal\Tests\Acceptance\App\Feature\WorkflowStubInjector; -use Temporal\Tests\Acceptance\App\Logger\TranscriptPaths; use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; @@ -85,10 +84,7 @@ public function notify(ExecutionStarted $event): void $container->bindSingleton(LoggerInterface::class, $logger); $container->bindSingleton(StderrLogger::class, $logger); - $runId = TranscriptStore::currentRunIdFromEnvironment() ?? TranscriptPaths::generateRunId(); - \putenv('TEMPORAL_TRANSCRIPT_RUN_ID=' . $runId); - $_ENV['TEMPORAL_TRANSCRIPT_RUN_ID'] = $runId; - $_SERVER['TEMPORAL_TRANSCRIPT_RUN_ID'] = $runId; + $runId = TranscriptStore::getOrCreateRunId(); $logger->info('[transcript] run id', ['run_id' => $runId]); $transcriptStore = TranscriptStore::create(stderr: $logger); From 71cd048633e78df84e868a298e9f5175c6706702 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 21:22:19 +0400 Subject: [PATCH 11/24] refactor: consolidate transcript-related classes under `Temporal\Testing\Transcript` namespace and streamline imports --- .../src/Transcript}/MalformedTranscriptException.php | 2 +- .../Transcript}/TranscriptActivityInterceptor.php | 4 ++-- .../src/Transcript}/TranscriptAdapter.php | 2 +- .../src/Transcript}/TranscriptLine.php | 2 +- .../src/Transcript}/TranscriptPaths.php | 2 +- .../src/Transcript}/TranscriptPlugin.php | 6 ++---- .../src/Transcript}/TranscriptReader.php | 2 +- .../src/Transcript}/TranscriptRun.php | 2 +- .../src/Transcript}/TranscriptSection.php | 2 +- .../src/Transcript}/TranscriptStore.php | 7 ++++++- .../Transcript}/TranscriptWorkflowInterceptor.php | 4 ++-- .../src/Transcript}/TranscriptWriter.php | 2 +- .../src/Transcript}/WorkflowHistoryDumper.php | 2 +- tests/Acceptance/App/Feature/WorkerFactory.php | 4 ++-- tests/Acceptance/App/Runtime/FatalHandler.php | 2 +- tests/Acceptance/App/Runtime/RRStarter.php | 2 +- tests/Acceptance/App/TestCase.php | 10 +++++----- tests/Acceptance/App/Transport/RecordingHost.php | 2 +- tests/Acceptance/ExecutionStartedSubscriber.php | 4 ++-- .../Extra/Transcript/TranscriptHappyPathTest.php | 4 ++-- .../Extra/Transcript/TranscriptRetryTest.php | 4 ++-- .../Transcript/TranscriptWorkflowFailureTest.php | 4 ++-- tests/Acceptance/transcript-merge.php | 2 +- tests/Acceptance/worker.php | 11 ++++------- tests/Unit/Logger/FatalHandlerTestCase.php | 10 +++++----- tests/Unit/Logger/TranscriptWriterTestCase.php | 10 +++++----- tests/Unit/Logger/WorkflowHistoryDumperTestCase.php | 12 ++++++------ tests/Unit/Plugin/TranscriptPluginTestCase.php | 8 ++++---- 28 files changed, 64 insertions(+), 64 deletions(-) rename {tests/Acceptance/App/Logger => testing/src/Transcript}/MalformedTranscriptException.php (91%) rename {tests/Acceptance/App/Interceptor => testing/src/Transcript}/TranscriptActivityInterceptor.php (93%) rename {tests/Acceptance/App/Logger => testing/src/Transcript}/TranscriptAdapter.php (93%) rename {tests/Acceptance/App/Logger => testing/src/Transcript}/TranscriptLine.php (94%) rename {tests/Acceptance/App/Logger => testing/src/Transcript}/TranscriptPaths.php (98%) rename {tests/Acceptance/App/Plugin => testing/src/Transcript}/TranscriptPlugin.php (71%) rename {tests/Acceptance/App/Logger => testing/src/Transcript}/TranscriptReader.php (99%) rename {tests/Acceptance/App/Logger => testing/src/Transcript}/TranscriptRun.php (97%) rename {tests/Acceptance/App/Logger => testing/src/Transcript}/TranscriptSection.php (91%) rename {tests/Acceptance/App/Logger => testing/src/Transcript}/TranscriptStore.php (96%) rename {tests/Acceptance/App/Interceptor => testing/src/Transcript}/TranscriptWorkflowInterceptor.php (96%) rename {tests/Acceptance/App/Logger => testing/src/Transcript}/TranscriptWriter.php (99%) rename {tests/Acceptance/App/Logger => testing/src/Transcript}/WorkflowHistoryDumper.php (98%) diff --git a/tests/Acceptance/App/Logger/MalformedTranscriptException.php b/testing/src/Transcript/MalformedTranscriptException.php similarity index 91% rename from tests/Acceptance/App/Logger/MalformedTranscriptException.php rename to testing/src/Transcript/MalformedTranscriptException.php index aa74c4490..d95a8d44a 100644 --- a/tests/Acceptance/App/Logger/MalformedTranscriptException.php +++ b/testing/src/Transcript/MalformedTranscriptException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Logger; +namespace Temporal\Testing\Transcript; final class MalformedTranscriptException extends \RuntimeException { diff --git a/tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php b/testing/src/Transcript/TranscriptActivityInterceptor.php similarity index 93% rename from tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php rename to testing/src/Transcript/TranscriptActivityInterceptor.php index 9fe66373f..2b3b05ce6 100644 --- a/tests/Acceptance/App/Interceptor/TranscriptActivityInterceptor.php +++ b/testing/src/Transcript/TranscriptActivityInterceptor.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Interceptor; +namespace Temporal\Testing\Transcript; use Temporal\Activity; use Temporal\Interceptor\ActivityInbound\ActivityInput; use Temporal\Interceptor\ActivityInboundInterceptor; use Temporal\Interceptor\Trait\ActivityInboundInterceptorTrait; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Testing\Transcript\TranscriptWriter; final class TranscriptActivityInterceptor implements ActivityInboundInterceptor { diff --git a/tests/Acceptance/App/Logger/TranscriptAdapter.php b/testing/src/Transcript/TranscriptAdapter.php similarity index 93% rename from tests/Acceptance/App/Logger/TranscriptAdapter.php rename to testing/src/Transcript/TranscriptAdapter.php index 8e1c73560..c6bcf7db8 100644 --- a/tests/Acceptance/App/Logger/TranscriptAdapter.php +++ b/testing/src/Transcript/TranscriptAdapter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Logger; +namespace Temporal\Testing\Transcript; use Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; diff --git a/tests/Acceptance/App/Logger/TranscriptLine.php b/testing/src/Transcript/TranscriptLine.php similarity index 94% rename from tests/Acceptance/App/Logger/TranscriptLine.php rename to testing/src/Transcript/TranscriptLine.php index 4d3309088..d8d85f1ff 100644 --- a/tests/Acceptance/App/Logger/TranscriptLine.php +++ b/testing/src/Transcript/TranscriptLine.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Logger; +namespace Temporal\Testing\Transcript; final class TranscriptLine { diff --git a/tests/Acceptance/App/Logger/TranscriptPaths.php b/testing/src/Transcript/TranscriptPaths.php similarity index 98% rename from tests/Acceptance/App/Logger/TranscriptPaths.php rename to testing/src/Transcript/TranscriptPaths.php index 41619ef05..6f4b80183 100644 --- a/tests/Acceptance/App/Logger/TranscriptPaths.php +++ b/testing/src/Transcript/TranscriptPaths.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Logger; +namespace Temporal\Testing\Transcript; /** * Single owner of transcript path/id grammar: diff --git a/tests/Acceptance/App/Plugin/TranscriptPlugin.php b/testing/src/Transcript/TranscriptPlugin.php similarity index 71% rename from tests/Acceptance/App/Plugin/TranscriptPlugin.php rename to testing/src/Transcript/TranscriptPlugin.php index e00e9b997..7cf822327 100644 --- a/tests/Acceptance/App/Plugin/TranscriptPlugin.php +++ b/testing/src/Transcript/TranscriptPlugin.php @@ -2,13 +2,11 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Plugin; +namespace Temporal\Testing\Transcript; use Temporal\Plugin\AbstractPlugin; use Temporal\Plugin\WorkerPluginContext; -use Temporal\Tests\Acceptance\App\Interceptor\TranscriptActivityInterceptor; -use Temporal\Tests\Acceptance\App\Interceptor\TranscriptWorkflowInterceptor; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Testing\Transcript\TranscriptWriter; final class TranscriptPlugin extends AbstractPlugin { diff --git a/tests/Acceptance/App/Logger/TranscriptReader.php b/testing/src/Transcript/TranscriptReader.php similarity index 99% rename from tests/Acceptance/App/Logger/TranscriptReader.php rename to testing/src/Transcript/TranscriptReader.php index bf45bfdf6..a5c10939f 100644 --- a/tests/Acceptance/App/Logger/TranscriptReader.php +++ b/testing/src/Transcript/TranscriptReader.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Logger; +namespace Temporal\Testing\Transcript; final class TranscriptReader { diff --git a/tests/Acceptance/App/Logger/TranscriptRun.php b/testing/src/Transcript/TranscriptRun.php similarity index 97% rename from tests/Acceptance/App/Logger/TranscriptRun.php rename to testing/src/Transcript/TranscriptRun.php index aa8c0f7c6..7d35ea809 100644 --- a/tests/Acceptance/App/Logger/TranscriptRun.php +++ b/testing/src/Transcript/TranscriptRun.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Logger; +namespace Temporal\Testing\Transcript; use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Filesystem; diff --git a/tests/Acceptance/App/Logger/TranscriptSection.php b/testing/src/Transcript/TranscriptSection.php similarity index 91% rename from tests/Acceptance/App/Logger/TranscriptSection.php rename to testing/src/Transcript/TranscriptSection.php index 78f17247f..b4ad17885 100644 --- a/tests/Acceptance/App/Logger/TranscriptSection.php +++ b/testing/src/Transcript/TranscriptSection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Logger; +namespace Temporal\Testing\Transcript; enum TranscriptSection: string { diff --git a/tests/Acceptance/App/Logger/TranscriptStore.php b/testing/src/Transcript/TranscriptStore.php similarity index 96% rename from tests/Acceptance/App/Logger/TranscriptStore.php rename to testing/src/Transcript/TranscriptStore.php index 352a08e9f..612b04342 100644 --- a/tests/Acceptance/App/Logger/TranscriptStore.php +++ b/testing/src/Transcript/TranscriptStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Logger; +namespace Temporal\Testing\Transcript; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -54,6 +54,11 @@ public static function currentRunIdFromEnvironment(): ?string return \is_string($runId) && $runId !== '' ? $runId : null; } + public static function currentRunIdOrOrphan(): string + { + return self::currentRunIdFromEnvironment() ?? ('orphan-' . (\getmypid() ?: 0)); + } + public static function getOrCreateRunId(): string { $runId = self::currentRunIdFromEnvironment(); diff --git a/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php b/testing/src/Transcript/TranscriptWorkflowInterceptor.php similarity index 96% rename from tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php rename to testing/src/Transcript/TranscriptWorkflowInterceptor.php index 28ebe96b8..1206e5f3c 100644 --- a/tests/Acceptance/App/Interceptor/TranscriptWorkflowInterceptor.php +++ b/testing/src/Transcript/TranscriptWorkflowInterceptor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Interceptor; +namespace Temporal\Testing\Transcript; use Temporal\Interceptor\Trait\WorkflowInboundCallsInterceptorTrait; use Temporal\Interceptor\WorkflowInbound\QueryInput; @@ -10,7 +10,7 @@ use Temporal\Interceptor\WorkflowInbound\UpdateInput; use Temporal\Interceptor\WorkflowInbound\WorkflowInput; use Temporal\Interceptor\WorkflowInboundCallsInterceptor; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Testing\Transcript\TranscriptWriter; final class TranscriptWorkflowInterceptor implements WorkflowInboundCallsInterceptor { diff --git a/tests/Acceptance/App/Logger/TranscriptWriter.php b/testing/src/Transcript/TranscriptWriter.php similarity index 99% rename from tests/Acceptance/App/Logger/TranscriptWriter.php rename to testing/src/Transcript/TranscriptWriter.php index fd45118a2..9c5a73e46 100644 --- a/tests/Acceptance/App/Logger/TranscriptWriter.php +++ b/testing/src/Transcript/TranscriptWriter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Logger; +namespace Temporal\Testing\Transcript; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; diff --git a/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php b/testing/src/Transcript/WorkflowHistoryDumper.php similarity index 98% rename from tests/Acceptance/App/Logger/WorkflowHistoryDumper.php rename to testing/src/Transcript/WorkflowHistoryDumper.php index 3f877874a..fe59f659a 100644 --- a/tests/Acceptance/App/Logger/WorkflowHistoryDumper.php +++ b/testing/src/Transcript/WorkflowHistoryDumper.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Temporal\Tests\Acceptance\App\Logger; +namespace Temporal\Testing\Transcript; use Temporal\Api\Enums\V1\EventType; use Temporal\Client\WorkflowClientInterface; diff --git a/tests/Acceptance/App/Feature/WorkerFactory.php b/tests/Acceptance/App/Feature/WorkerFactory.php index 48088f067..5c50eaae7 100644 --- a/tests/Acceptance/App/Feature/WorkerFactory.php +++ b/tests/Acceptance/App/Feature/WorkerFactory.php @@ -10,8 +10,8 @@ use Temporal\Tests\Acceptance\App\Attribute\Worker; use Temporal\Tests\Acceptance\App\Logger\FanoutLogger; use Temporal\Tests\Acceptance\App\Logger\LoggerFactory; -use Temporal\Tests\Acceptance\App\Logger\TranscriptAdapter; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Testing\Transcript\TranscriptAdapter; +use Temporal\Testing\Transcript\TranscriptWriter; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; diff --git a/tests/Acceptance/App/Runtime/FatalHandler.php b/tests/Acceptance/App/Runtime/FatalHandler.php index 26833b21b..9adee63c2 100644 --- a/tests/Acceptance/App/Runtime/FatalHandler.php +++ b/tests/Acceptance/App/Runtime/FatalHandler.php @@ -6,7 +6,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Testing\Transcript\TranscriptWriter; final class FatalHandler { diff --git a/tests/Acceptance/App/Runtime/RRStarter.php b/tests/Acceptance/App/Runtime/RRStarter.php index 3bbaaec20..db0f2374f 100644 --- a/tests/Acceptance/App/Runtime/RRStarter.php +++ b/tests/Acceptance/App/Runtime/RRStarter.php @@ -6,7 +6,7 @@ use Temporal\Testing\Environment; use Temporal\Testing\SystemInfo; -use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; +use Temporal\Testing\Transcript\TranscriptStore; final class RRStarter { diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index 68f84c2c0..cf142d35a 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -22,11 +22,11 @@ use Temporal\Tests\Acceptance\App\Feature\WorkerFactory; use Temporal\Tests\Acceptance\App\Logger\ClientLogger; use Temporal\Tests\Acceptance\App\Logger\LoggerFactory; -use Temporal\Tests\Acceptance\App\Logger\TranscriptLine; -use Temporal\Tests\Acceptance\App\Logger\TranscriptSection; -use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; -use Temporal\Tests\Acceptance\App\Logger\WorkflowHistoryDumper; +use Temporal\Testing\Transcript\TranscriptLine; +use Temporal\Testing\Transcript\TranscriptSection; +use Temporal\Testing\Transcript\TranscriptStore; +use Temporal\Testing\Transcript\TranscriptWriter; +use Temporal\Testing\Transcript\WorkflowHistoryDumper; use Temporal\Worker\Logger\StderrLogger; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\Feature; diff --git a/tests/Acceptance/App/Transport/RecordingHost.php b/tests/Acceptance/App/Transport/RecordingHost.php index 1ff50313b..ce2cd21fa 100644 --- a/tests/Acceptance/App/Transport/RecordingHost.php +++ b/tests/Acceptance/App/Transport/RecordingHost.php @@ -4,7 +4,7 @@ namespace Temporal\Tests\Acceptance\App\Transport; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Testing\Transcript\TranscriptWriter; use Temporal\Worker\Transport\CommandBatch; use Temporal\Worker\Transport\HostConnectionInterface; diff --git a/tests/Acceptance/ExecutionStartedSubscriber.php b/tests/Acceptance/ExecutionStartedSubscriber.php index aadcb8c3a..fd70fcc24 100644 --- a/tests/Acceptance/ExecutionStartedSubscriber.php +++ b/tests/Acceptance/ExecutionStartedSubscriber.php @@ -27,8 +27,8 @@ use Temporal\DataConverter\DataConverterInterface; use Temporal\Testing\Environment; use Temporal\Tests\Acceptance\App\Feature\WorkflowStubInjector; -use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Testing\Transcript\TranscriptStore; +use Temporal\Testing\Transcript\TranscriptWriter; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; use Temporal\Tests\Acceptance\App\Runtime\RRStarter; use Temporal\Tests\Acceptance\App\Runtime\State; diff --git a/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php b/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php index 1cfeabc7e..9312a9f37 100644 --- a/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php +++ b/tests/Acceptance/Extra/Transcript/TranscriptHappyPathTest.php @@ -10,8 +10,8 @@ use Temporal\Activity\ActivityOptions; use Temporal\Client\WorkflowStubInterface; use Temporal\Tests\Acceptance\App\Attribute\Stub; -use Temporal\Tests\Acceptance\App\Logger\TranscriptLine; -use Temporal\Tests\Acceptance\App\Logger\TranscriptSection; +use Temporal\Testing\Transcript\TranscriptLine; +use Temporal\Testing\Transcript\TranscriptSection; use Temporal\Tests\Acceptance\App\TestCase; use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; diff --git a/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php b/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php index 6d173fe53..4d510ce82 100644 --- a/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php +++ b/tests/Acceptance/Extra/Transcript/TranscriptRetryTest.php @@ -12,8 +12,8 @@ use Temporal\Common\RetryOptions; use Temporal\Exception\Failure\ApplicationFailure; use Temporal\Tests\Acceptance\App\Attribute\Stub; -use Temporal\Tests\Acceptance\App\Logger\TranscriptLine; -use Temporal\Tests\Acceptance\App\Logger\TranscriptSection; +use Temporal\Testing\Transcript\TranscriptLine; +use Temporal\Testing\Transcript\TranscriptSection; use Temporal\Tests\Acceptance\App\TestCase; use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; diff --git a/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php b/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php index ec0ce0f11..30cd6acc5 100644 --- a/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php +++ b/tests/Acceptance/Extra/Transcript/TranscriptWorkflowFailureTest.php @@ -8,8 +8,8 @@ use Temporal\Exception\Client\WorkflowFailedException; use Temporal\Exception\Failure\ApplicationFailure; use Temporal\Tests\Acceptance\App\Attribute\Stub; -use Temporal\Tests\Acceptance\App\Logger\TranscriptLine; -use Temporal\Tests\Acceptance\App\Logger\TranscriptSection; +use Temporal\Testing\Transcript\TranscriptLine; +use Temporal\Testing\Transcript\TranscriptSection; use Temporal\Tests\Acceptance\App\TestCase; use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; diff --git a/tests/Acceptance/transcript-merge.php b/tests/Acceptance/transcript-merge.php index 0a132650a..1b1d5f425 100644 --- a/tests/Acceptance/transcript-merge.php +++ b/tests/Acceptance/transcript-merge.php @@ -11,7 +11,7 @@ require __DIR__ . '/../../vendor/autoload.php'; -use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; +use Temporal\Testing\Transcript\TranscriptStore; use Temporal\Worker\Logger\StderrLogger; $stderr = new StderrLogger(); diff --git a/tests/Acceptance/worker.php b/tests/Acceptance/worker.php index ef9f1b321..15bcc8b93 100644 --- a/tests/Acceptance/worker.php +++ b/tests/Acceptance/worker.php @@ -25,9 +25,9 @@ use Temporal\Internal\Support\StackRenderer; use Temporal\Plugin\PluginRegistry; use Temporal\Testing\Command; -use Temporal\Tests\Acceptance\App\Logger\TranscriptStore; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; -use Temporal\Tests\Acceptance\App\Plugin\TranscriptPlugin; +use Temporal\Testing\Transcript\TranscriptStore; +use Temporal\Testing\Transcript\TranscriptWriter; +use Temporal\Testing\Transcript\TranscriptPlugin; use Temporal\Tests\Acceptance\App\Runtime\FatalHandler; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Tests\Acceptance\App\Runtime\State; @@ -44,10 +44,7 @@ $logger = new StderrLogger(); $workerTranscript = TranscriptStore::create(stderr: $logger) - ->createWriter( - TranscriptStore::currentRunIdFromEnvironment() ?? ('orphan-' . (\getmypid() ?: 0)), - 'worker', - ); + ->createWriter(TranscriptStore::currentRunIdOrOrphan(), 'worker'); FatalHandler::register($workerTranscript, $logger); RuntimeBuilder::init(); diff --git a/tests/Unit/Logger/FatalHandlerTestCase.php b/tests/Unit/Logger/FatalHandlerTestCase.php index dd75ef4c5..924c45d6a 100644 --- a/tests/Unit/Logger/FatalHandlerTestCase.php +++ b/tests/Unit/Logger/FatalHandlerTestCase.php @@ -8,11 +8,11 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -use Temporal\Tests\Acceptance\App\Logger\MalformedTranscriptException; -use Temporal\Tests\Acceptance\App\Logger\TranscriptLine; -use Temporal\Tests\Acceptance\App\Logger\TranscriptReader; -use Temporal\Tests\Acceptance\App\Logger\TranscriptSection; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Testing\Transcript\MalformedTranscriptException; +use Temporal\Testing\Transcript\TranscriptLine; +use Temporal\Testing\Transcript\TranscriptReader; +use Temporal\Testing\Transcript\TranscriptSection; +use Temporal\Testing\Transcript\TranscriptWriter; use Temporal\Tests\Acceptance\App\Runtime\FatalHandler; #[CoversClass(FatalHandler::class)] diff --git a/tests/Unit/Logger/TranscriptWriterTestCase.php b/tests/Unit/Logger/TranscriptWriterTestCase.php index 8501c1d21..1c0b45356 100644 --- a/tests/Unit/Logger/TranscriptWriterTestCase.php +++ b/tests/Unit/Logger/TranscriptWriterTestCase.php @@ -7,11 +7,11 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -use Temporal\Tests\Acceptance\App\Logger\MalformedTranscriptException; -use Temporal\Tests\Acceptance\App\Logger\TranscriptLine; -use Temporal\Tests\Acceptance\App\Logger\TranscriptReader; -use Temporal\Tests\Acceptance\App\Logger\TranscriptSection; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; +use Temporal\Testing\Transcript\MalformedTranscriptException; +use Temporal\Testing\Transcript\TranscriptLine; +use Temporal\Testing\Transcript\TranscriptReader; +use Temporal\Testing\Transcript\TranscriptSection; +use Temporal\Testing\Transcript\TranscriptWriter; #[CoversClass(TranscriptWriter::class)] #[UsesClass(TranscriptSection::class)] diff --git a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php index 21488a49b..6783ef18d 100644 --- a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php +++ b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php @@ -16,12 +16,12 @@ use Temporal\Client\Workflow\WorkflowExecutionHistory; use Temporal\Client\WorkflowClientInterface; use Temporal\Client\WorkflowStubInterface; -use Temporal\Tests\Acceptance\App\Logger\MalformedTranscriptException; -use Temporal\Tests\Acceptance\App\Logger\TranscriptLine; -use Temporal\Tests\Acceptance\App\Logger\TranscriptReader; -use Temporal\Tests\Acceptance\App\Logger\TranscriptSection; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; -use Temporal\Tests\Acceptance\App\Logger\WorkflowHistoryDumper; +use Temporal\Testing\Transcript\MalformedTranscriptException; +use Temporal\Testing\Transcript\TranscriptLine; +use Temporal\Testing\Transcript\TranscriptReader; +use Temporal\Testing\Transcript\TranscriptSection; +use Temporal\Testing\Transcript\TranscriptWriter; +use Temporal\Testing\Transcript\WorkflowHistoryDumper; use Temporal\Workflow\WorkflowExecution; #[CoversClass(WorkflowHistoryDumper::class)] diff --git a/tests/Unit/Plugin/TranscriptPluginTestCase.php b/tests/Unit/Plugin/TranscriptPluginTestCase.php index 8f560077e..b5304987c 100644 --- a/tests/Unit/Plugin/TranscriptPluginTestCase.php +++ b/tests/Unit/Plugin/TranscriptPluginTestCase.php @@ -11,10 +11,10 @@ use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\WorkerPluginContext; use Temporal\Plugin\WorkerPluginInterface; -use Temporal\Tests\Acceptance\App\Interceptor\TranscriptActivityInterceptor; -use Temporal\Tests\Acceptance\App\Interceptor\TranscriptWorkflowInterceptor; -use Temporal\Tests\Acceptance\App\Logger\TranscriptWriter; -use Temporal\Tests\Acceptance\App\Plugin\TranscriptPlugin; +use Temporal\Testing\Transcript\TranscriptActivityInterceptor; +use Temporal\Testing\Transcript\TranscriptWorkflowInterceptor; +use Temporal\Testing\Transcript\TranscriptWriter; +use Temporal\Testing\Transcript\TranscriptPlugin; use Temporal\Tests\Unit\Logger\TranscriptTestSupport; use Temporal\Worker\WorkerOptions; From a73d51d0ba3f860a97875f7c6fd08eb3b6c151f1 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 21:29:33 +0400 Subject: [PATCH 12/24] refactor: consolidate transcript-related classes under `Temporal\Testing\Transcript` namespace and streamline imports --- tests/Unit/Logger/FatalHandlerTestCase.php | 23 +++++++++++-------- .../Unit/Logger/TranscriptWriterTestCase.php | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/Unit/Logger/FatalHandlerTestCase.php b/tests/Unit/Logger/FatalHandlerTestCase.php index 924c45d6a..51f20c3de 100644 --- a/tests/Unit/Logger/FatalHandlerTestCase.php +++ b/tests/Unit/Logger/FatalHandlerTestCase.php @@ -28,7 +28,9 @@ final class FatalHandlerTestCase extends TestCase public function testUserErrorIsRecordedAsFatalViaShutdownFunction(): void { $logFile = $this->directory . '/fatal.log'; - $script = $this->buildFixtureScript($logFile, "trigger_error('intentional fatal', E_USER_ERROR);"); + $script = $this->buildFixtureScript($logFile, <<<'PHP' + trigger_error('intentional fatal', E_USER_ERROR); + PHP); $this->executeFixture($script); $reader = new TranscriptReader($this->directory); @@ -40,7 +42,9 @@ public function testUserErrorIsRecordedAsFatalViaShutdownFunction(): void public function testUncaughtErrorIsRecordedAsFatalViaExceptionHandler(): void { $logFile = $this->directory . '/uncaught.log'; - $script = $this->buildFixtureScript($logFile, "throw new \\Error('uncaught fatal');"); + $script = $this->buildFixtureScript($logFile, <<<'PHP' + throw new \Error('uncaught fatal'); + PHP); $this->executeFixture($script); $reader = new TranscriptReader($this->directory); @@ -53,12 +57,11 @@ public function testUncaughtErrorIsRecordedAsFatalViaExceptionHandler(): void public function testWritesPriorToFatalArePreserved(): void { $logFile = $this->directory . '/preserved.log'; - $script = $this->buildFixtureScript( - $logFile, - "\$writer->writeTestBoundary(\\Temporal\\Tests\\Acceptance\\App\\Logger\\TranscriptSection::TEST_START, ['name' => 'pre-fatal']);\n" - . "\$writer->writeLog('info', 'about to die', []);\n" - . "trigger_error('boom', E_USER_ERROR);", - ); + $script = $this->buildFixtureScript($logFile, <<<'PHP' + $writer->writeTestBoundary(\Temporal\Testing\Transcript\TranscriptSection::TEST_START, ['name' => 'pre-fatal']); + $writer->writeLog('info', 'about to die', []); + trigger_error('boom', E_USER_ERROR); + PHP); $this->executeFixture($script); $reader = new TranscriptReader($this->directory); @@ -86,8 +89,8 @@ private function buildFixtureScript(string $logFile, #[Language("PHP")]string $b writeLog('info', "child-{$i}-write-\$j", []); } From f94523fce746eb9e7003d77ba55efb3a35b6a784 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 25 May 2026 21:54:58 +0400 Subject: [PATCH 13/24] feat: enable conditional transcript dumping on test failures and improve merged transcript handling --- .github/workflows/run-test-suite.yml | 1 + testing/src/Transcript/TranscriptStore.php | 10 ++++++++++ tests/Acceptance/App/TestCase.php | 17 ++++++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-test-suite.yml b/.github/workflows/run-test-suite.yml index 770ddb3ce..88edcd164 100644 --- a/.github/workflows/run-test-suite.yml +++ b/.github/workflows/run-test-suite.yml @@ -107,6 +107,7 @@ jobs: run: ${{ inputs.test-command }} env: XDEBUG_MODE: off + TEMPORAL_TRANSCRIPT_DUMP_ON_FAIL: "1" - name: Check for failures if: steps.validate.outcome == 'failure' diff --git a/testing/src/Transcript/TranscriptStore.php b/testing/src/Transcript/TranscriptStore.php index 612b04342..99a2812c3 100644 --- a/testing/src/Transcript/TranscriptStore.php +++ b/testing/src/Transcript/TranscriptStore.php @@ -145,6 +145,16 @@ public function currentRun(): ?TranscriptRun return $runId === null ? $this->latestRun() : $this->findRun($runId); } + public function readMergedRun(?string $runId = null): ?string + { + $run = $runId === null ? $this->currentRun() : $this->findRun($runId); + if ($run === null || $run->files() === []) { + return null; + } + $content = @\file_get_contents($run->merge()); + return \is_string($content) && $content !== '' ? $content : null; + } + public function pruneOldRuns(int $keep): int { $keep = \max(0, $keep); diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index cf142d35a..1ed9785ce 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -174,7 +174,16 @@ function (Container $container) use ($workflowClient): mixed { ? $container->get(StderrLogger::class) : null; $stderr?->error('transcript', ['path' => $transcript->getPath()]); - $stderr?->info('run `composer transcripts:last` to view the merged stream'); + $runId = TranscriptStore::currentRunIdFromEnvironment(); + $stderr?->info($runId !== null + ? "run `composer transcripts:merge {$runId}` to view the merged stream" + : 'run `composer transcripts:last` to view the merged stream'); + if ($runId !== null && self::shouldDumpTranscriptOnFail()) { + $content = TranscriptStore::create(stderr: $stderr)->readMergedRun($runId); + if ($content !== null) { + $stderr?->info("transcript run {$runId} dump:\n" . $content); + } + } } } } @@ -182,6 +191,12 @@ function (Container $container) use ($workflowClient): mixed { ); } + private static function shouldDumpTranscriptOnFail(): bool + { + $flag = \getenv('TEMPORAL_TRANSCRIPT_DUMP_ON_FAIL'); + return \is_string($flag) && !\in_array(\strtolower($flag), ['', '0', 'false', 'off', 'no'], true); + } + /** * @return list */ From 04b4fa2e26b8bf2ec0fd57c88e62187750ee323f Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Tue, 26 May 2026 09:56:51 +0400 Subject: [PATCH 14/24] feat: add WireFrameDecoder for decoding temporal frames, extend TranscriptWriter and WorkflowHistoryDumper with advanced frame handling --- .run/Acceptance.run.xml | 7 +- testing/src/Transcript/TranscriptWriter.php | 26 +-- testing/src/Transcript/WireFrameDecoder.php | 157 ++++++++++++++++++ .../src/Transcript/WorkflowHistoryDumper.php | 105 ++++++++++++ tests/Acceptance/App/TestCase.php | 104 +++--------- .../Extra/Activity/ActivityPausedTest.php | 30 ++-- .../ContinueAsNew/ContinueAsSameTest.php | 1 + .../Unit/Logger/TranscriptWriterTestCase.php | 126 ++++++++++++++ .../Logger/WorkflowHistoryDumperTestCase.php | 83 +++++++++ 9 files changed, 535 insertions(+), 104 deletions(-) create mode 100644 testing/src/Transcript/WireFrameDecoder.php diff --git a/.run/Acceptance.run.xml b/.run/Acceptance.run.xml index ae1d8ef29..947e4841c 100644 --- a/.run/Acceptance.run.xml +++ b/.run/Acceptance.run.xml @@ -1,6 +1,11 @@ + + + + + - + \ No newline at end of file diff --git a/testing/src/Transcript/TranscriptWriter.php b/testing/src/Transcript/TranscriptWriter.php index 9c5a73e46..7be3d4434 100644 --- a/testing/src/Transcript/TranscriptWriter.php +++ b/testing/src/Transcript/TranscriptWriter.php @@ -323,19 +323,21 @@ private function openFileDescriptor(string $path): void private function safeDecodeFrame(string $frame): mixed { $trimmed = \ltrim($frame); - if ($trimmed === '' || ($trimmed[0] !== '{' && $trimmed[0] !== '[')) { - return [ - 'encoding' => 'raw', - 'preview_base64' => \base64_encode(\substr($frame, 0, 512)), - ]; + if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) { + $decoded = \json_decode($frame, true); + if ($decoded !== null || \json_last_error() === \JSON_ERROR_NONE) { + return ['encoding' => 'json', 'value' => $decoded]; + } } - $decoded = \json_decode($frame, true); - if ($decoded === null && \json_last_error() !== \JSON_ERROR_NONE) { - return [ - 'encoding' => 'raw', - 'preview_base64' => \base64_encode(\substr($frame, 0, 512)), - ]; + + $temporalFrame = WireFrameDecoder::decode($frame); + if ($temporalFrame !== null) { + return $temporalFrame; } - return ['encoding' => 'json', 'value' => $decoded]; + + return [ + 'encoding' => 'raw', + 'preview_base64' => \base64_encode(\substr($frame, 0, 512)), + ]; } } diff --git a/testing/src/Transcript/WireFrameDecoder.php b/testing/src/Transcript/WireFrameDecoder.php new file mode 100644 index 000000000..5b690cd06 --- /dev/null +++ b/testing/src/Transcript/WireFrameDecoder.php @@ -0,0 +1,157 @@ +>}|null + */ + public static function decode(string $frame, ?DataConverterInterface $converter = null): ?array + { + if ($frame === '') { + return null; + } + + $proto = new Frame(); + try { + $proto->mergeFromString($frame); + } catch (\Throwable) { + return null; + } + + $messages = $proto->getMessages(); + if (\count($messages) === 0) { + return null; + } + + $converter ??= self::$defaultConverter ??= DataConverter::createDefault(); + + $decoded = []; + foreach ($messages as $message) { + $decoded[] = self::decodeMessage($message, $converter); + } + + return [ + 'encoding' => 'temporal-frame', + 'messages' => $decoded, + ]; + } + + /** + * Uses proto's native JSON serialization to extract non-default fields, then + * replaces bytes-shaped fields (`options`, `payloads`, `header`) with their + * SDK-decoded, human-readable representation. + * + * @return array + */ + private static function decodeMessage(Message $message, DataConverterInterface $converter): array + { + try { + $json = $message->serializeToJsonString(true); + } catch (\Throwable) { + return ['error' => 'proto_json_serialize_failed']; + } + $decoded = \json_decode($json, true) ?? []; + + if ($message->getOptions() !== '') { + $decoded['options'] = self::decodeJsonBytes($message->getOptions()); + } + if ($message->hasPayloads()) { + $decoded['payloads'] = self::decodePayloads($message->getPayloads(), $converter); + } + if ($message->hasHeader()) { + $decoded['header'] = self::decodeHeader($message->getHeader(), $converter); + } + + return $decoded; + } + + /** + * @return list + */ + private static function decodePayloads(Payloads $payloads, DataConverterInterface $converter): array + { + try { + return \array_values(EncodedValues::fromPayloads($payloads, $converter)->getValues()); + } catch (\Throwable) { + return \array_map(self::payloadFallback(...), \iterator_to_array($payloads->getPayloads(), false)); + } + } + + /** + * @return array + */ + private static function decodeHeader(Header $header, DataConverterInterface $converter): array + { + /** @var MapField $fields */ + $fields = $header->getFields(); + try { + return EncodedCollection::fromPayloadCollection($fields, $converter)->getValues(); + } catch (\Throwable) { + $out = []; + foreach ($fields as $name => $payload) { + $out[$name] = self::payloadFallback($payload); + } + return $out; + } + } + + /** + * @return array + */ + private static function payloadFallback(Payload $payload): array + { + $metadata = []; + /** @var MapField $meta */ + $meta = $payload->getMetadata(); + foreach ($meta as $key => $value) { + $metadata[$key] = self::bytesToReadable((string) $value); + } + return [ + 'metadata' => $metadata, + 'data' => self::bytesToReadable($payload->getData()), + ]; + } + + private static function decodeJsonBytes(string $bytes): mixed + { + $decoded = \json_decode($bytes, true); + if ($decoded !== null || \json_last_error() === \JSON_ERROR_NONE) { + return $decoded; + } + return self::bytesToReadable($bytes); + } + + /** + * Returns the bytes as a UTF-8 string when valid, otherwise wraps the + * bytes in a base64 representation so the JSON line stays well-formed. + */ + private static function bytesToReadable(string $bytes): mixed + { + if ($bytes === '') { + return ''; + } + if (\preg_match('//u', $bytes) === 1) { + return $bytes; + } + return ['encoding' => 'base64', 'value' => \base64_encode($bytes)]; + } +} diff --git a/testing/src/Transcript/WorkflowHistoryDumper.php b/testing/src/Transcript/WorkflowHistoryDumper.php index fe59f659a..8cec867e9 100644 --- a/testing/src/Transcript/WorkflowHistoryDumper.php +++ b/testing/src/Transcript/WorkflowHistoryDumper.php @@ -4,13 +4,53 @@ namespace Temporal\Testing\Transcript; +use Google\Protobuf\Timestamp; use Temporal\Api\Enums\V1\EventType; +use Temporal\Api\Failure\V1\Failure; +use Temporal\Api\History\V1\HistoryEvent; use Temporal\Client\WorkflowClientInterface; use Temporal\Client\WorkflowStubInterface; use Temporal\Workflow\WorkflowExecution; final class WorkflowHistoryDumper { + /** + * Writes a human-readable, single-blob render of each stub's history into + * the transcript as `workflow_history_render` META events. Use on test + * failure to capture an at-a-glance view alongside the structured `HISTORY` + * events produced by {@see self::dump()}. + * + * @param array $args Call arguments; only WorkflowStubInterface entries are inspected. + */ + public function renderForFailure( + TranscriptWriter $transcript, + WorkflowClientInterface $workflowClient, + array $args, + ): void { + foreach ($args as $arg) { + if (!$arg instanceof WorkflowStubInterface) { + continue; + } + $execution = $arg->getExecution(); + try { + $text = $this->renderExecution($workflowClient, $arg); + } catch (\Throwable $renderError) { + $transcript->writeMeta('workflow_history_render_failed', [ + 'workflow_id' => $execution->getID(), + 'run_id' => (string) $execution->getRunID(), + 'class' => $renderError::class, + 'message' => $renderError->getMessage(), + ]); + continue; + } + $transcript->writeMeta('workflow_history_render', [ + 'workflow_id' => $execution->getID(), + 'run_id' => (string) $execution->getRunID(), + 'text' => $text, + ]); + } + } + /** * @param array $args Call arguments; WorkflowStubInterface entries contribute their execution. */ @@ -87,4 +127,69 @@ private function dumpExecution( $transcript->writeHistoryError($execution->getID(), $historyError); } } + + private function renderExecution(WorkflowClientInterface $workflowClient, WorkflowStubInterface $stub): string + { + $fnTime = static fn(?Timestamp $ts): float => $ts === null + ? 0 + : $ts->getSeconds() + \round($ts->getNanos() / 1_000_000_000, 6); + + $out = ''; + $start = null; + foreach ($workflowClient->getWorkflowHistory($stub->getExecution()) as $event) { + \assert($event instanceof HistoryEvent); + $start ??= $fnTime($event->getEventTime()); + $deltaMs = \round(1_000 * ($fnTime($event->getEventTime()) - $start)); + + $out .= "\n" + . \str_pad((string) $event->getEventId(), 3, ' ', STR_PAD_LEFT) . ' ' + . \str_pad(\number_format($deltaMs, 0, '.', "'"), 6, ' ', STR_PAD_LEFT) . 'ms ' + . \str_pad(EventType::name($event->getEventType()), 40, ' ', STR_PAD_RIGHT) . ' '; + + $cause = $event->getStartChildWorkflowExecutionFailedEventAttributes()?->getCause() + ?? $event->getSignalExternalWorkflowExecutionFailedEventAttributes()?->getCause() + ?? $event->getRequestCancelExternalWorkflowExecutionFailedEventAttributes()?->getCause(); + if ($cause !== null) { + $out .= "Cause: $cause"; + continue; + } + + $failure = $event->getActivityTaskFailedEventAttributes()?->getFailure() + ?? $event->getWorkflowTaskFailedEventAttributes()?->getFailure() + ?? $event->getNexusOperationFailedEventAttributes()?->getFailure() + ?? $event->getWorkflowExecutionFailedEventAttributes()?->getFailure() + ?? $event->getChildWorkflowExecutionFailedEventAttributes()?->getFailure() + ?? $event->getNexusOperationCancelRequestFailedEventAttributes()?->getFailure(); + + if ($failure === null) { + continue; + } + + $out .= "Failure:\n" + . " ========== BEGIN ===========\n" + . $this->renderFailure($failure, 1) + . " =========== END ============"; + } + return $out; + } + + private function renderFailure(Failure $failure, int $level): string + { + $pad = \str_repeat(' ', $level); + $fnPad = static fn(string $str): string => $pad . \str_replace("\n", "\n$pad", $str); + + $out = $fnPad('Source: ' . $failure->getSource()) . "\n" + . $fnPad('Info: ' . $failure->getFailureInfo()) . "\n" + . $fnPad('Message: ' . $failure->getMessage()) . "\n" + . $fnPad('Stack trace:') . "\n" + . $fnPad($failure->getStackTrace()) . "\n"; + + $previous = $failure->getCause(); + if ($previous !== null) { + $out .= $fnPad('————————————————————————————') . "\n" + . $fnPad('Caused by:') . "\n" + . $this->renderFailure($previous, $level + 1); + } + return $out; + } } diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index 1ed9785ce..a0615bd5a 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -4,13 +4,10 @@ namespace Temporal\Tests\Acceptance\App; -use Google\Protobuf\Timestamp; use PHPUnit\Framework\SkippedTest; use Psr\Log\LoggerInterface; use Spiral\Core\Container; use Spiral\Core\Scope; -use Temporal\Api\Enums\V1\EventType; -use Temporal\Api\Failure\V1\Failure; use Temporal\Client\ClientOptions; use Temporal\Client\WorkflowClient; use Temporal\Client\WorkflowClientInterface; @@ -83,6 +80,9 @@ function (Container $container) use ($workflowClient): mixed { $transcript = $container->has(TranscriptWriter::class) ? $container->get(TranscriptWriter::class) : null; + $dumper = $container->has(WorkflowHistoryDumper::class) + ? $container->get(WorkflowHistoryDumper::class) + : null; $transcript?->writeTestBoundary(TranscriptSection::TEST_START, [ 'class' => static::class, 'method' => $this->name(), @@ -105,8 +105,10 @@ function (Container $container) use ($workflowClient): mixed { ); echo "\n=== Stack trace ===\n"; echo $e->getTraceAsString(); - echo "\n=== Workflow history ===\n"; - $this->printWorkflowHistory($workflowClient, $args); + + if ($transcript !== null) { + $dumper?->renderForFailure($transcript, $workflowClient, $args); + } $logRecords = $container->get(ClientLogger::class)->getRecords(); if ($logRecords !== []) { @@ -134,9 +136,6 @@ function (Container $container) use ($workflowClient): mixed { throw $e; } finally { if ($transcript !== null) { - $dumper = $container->has(WorkflowHistoryDumper::class) - ? $container->get(WorkflowHistoryDumper::class) - : null; $dumper?->dump($transcript, $workflowClient, $args); } foreach ($args as $arg) { @@ -175,13 +174,23 @@ function (Container $container) use ($workflowClient): mixed { : null; $stderr?->error('transcript', ['path' => $transcript->getPath()]); $runId = TranscriptStore::currentRunIdFromEnvironment(); - $stderr?->info($runId !== null - ? "run `composer transcripts:merge {$runId}` to view the merged stream" - : 'run `composer transcripts:last` to view the merged stream'); - if ($runId !== null && self::shouldDumpTranscriptOnFail()) { - $content = TranscriptStore::create(stderr: $stderr)->readMergedRun($runId); - if ($content !== null) { - $stderr?->info("transcript run {$runId} dump:\n" . $content); + $store = TranscriptStore::create(stderr: $stderr); + $run = $runId === null ? $store->latestRun() : $store->findRun($runId); + if ($run !== null && $run->files() !== []) { + try { + $mergedPath = $run->merge(); + $stderr?->info("view merged transcript: less {$mergedPath}"); + if (self::shouldDumpTranscriptOnFail()) { + $content = @\file_get_contents($mergedPath); + if (\is_string($content) && $content !== '') { + $label = $runId !== null ? "transcript run {$runId}" : 'transcript'; + $stderr?->info("{$label} dump:\n" . $content); + } + } + } catch (\Throwable $mergeError) { + $stderr?->warning('transcript merge failed', [ + 'message' => $mergeError->getMessage(), + ]); } } } @@ -209,69 +218,4 @@ protected function readCurrentTestTranscript(): array return $run->reader()->linesForTest(static::class, $this->name()); } - private function printWorkflowHistory(WorkflowClientInterface $workflowClient, array $args): void - { - foreach ($args as $arg) { - if (!$arg instanceof WorkflowStubInterface) { - continue; - } - - $fnTime = static fn(?Timestamp $ts): float => $ts === null - ? 0 - : $ts->getSeconds() + \round($ts->getNanos() / 1_000_000_000, 6); - - foreach ($workflowClient->getWorkflowHistory($arg->getExecution()) as $event) { - $start ??= $fnTime($event->getEventTime()); - echo "\n" . \str_pad((string) $event->getEventId(), 3, ' ', STR_PAD_LEFT) . ' '; - # Calculate delta time - $deltaMs = \round(1_000 * ($fnTime($event->getEventTime()) - $start)); - echo \str_pad(\number_format($deltaMs, 0, '.', "'"), 6, ' ', STR_PAD_LEFT) . 'ms '; - echo \str_pad(EventType::name($event->getEventType()), 40, ' ', STR_PAD_RIGHT) . ' '; - - $cause = $event->getStartChildWorkflowExecutionFailedEventAttributes()?->getCause() - ?? $event->getSignalExternalWorkflowExecutionFailedEventAttributes()?->getCause() - ?? $event->getRequestCancelExternalWorkflowExecutionFailedEventAttributes()?->getCause(); - if ($cause !== null) { - echo "Cause: $cause"; - continue; - } - - $failure = $event->getActivityTaskFailedEventAttributes()?->getFailure() - ?? $event->getWorkflowTaskFailedEventAttributes()?->getFailure() - ?? $event->getNexusOperationFailedEventAttributes()?->getFailure() - ?? $event->getWorkflowExecutionFailedEventAttributes()?->getFailure() - ?? $event->getChildWorkflowExecutionFailedEventAttributes()?->getFailure() - ?? $event->getNexusOperationCancelRequestFailedEventAttributes()?->getFailure(); - - if ($failure === null) { - continue; - } - - # Render failure - echo "Failure:\n"; - echo " ========== BEGIN ===========\n"; - $this->renderFailure($failure, 1); - echo " =========== END ============"; - } - } - } - - private function renderFailure(Failure $failure, int $level): void - { - $fnPad = static function (string $str) use ($level): string { - $pad = \str_repeat(' ', $level); - return $pad . \str_replace("\n", "\n$pad", $str); - }; - echo $fnPad('Source: ' . $failure->getSource()) . "\n"; - echo $fnPad('Info: ' . $failure->getFailureInfo()) . "\n"; - echo $fnPad('Message: ' . $failure->getMessage()) . "\n"; - echo $fnPad("Stack trace:") . "\n"; - echo $fnPad($failure->getStackTrace()) . "\n"; - $previous = $failure->getCause(); - if ($previous !== null) { - echo $fnPad('————————————————————————————') . "\n"; - echo $fnPad('Caused by:') . "\n"; - $this->renderFailure($previous, $level + 1); - } - } } diff --git a/tests/Acceptance/Extra/Activity/ActivityPausedTest.php b/tests/Acceptance/Extra/Activity/ActivityPausedTest.php index acf2edcf5..5a6306989 100644 --- a/tests/Acceptance/Extra/Activity/ActivityPausedTest.php +++ b/tests/Acceptance/Extra/Activity/ActivityPausedTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\Test; use Temporal\Activity; use Temporal\Api\Common\V1\WorkflowExecution; +use Temporal\Api\Workflowservice\V1\DescribeWorkflowExecutionRequest; use Temporal\Api\Workflowservice\V1\PauseActivityRequest; use Temporal\Client\GRPC\ServiceClientInterface; use Temporal\Client\WorkflowClientInterface; @@ -27,20 +28,27 @@ public function simplePause( WorkflowClientInterface $workflowClient, ): void { $deadline = \microtime(true) + 10; - find: - $found = false; - foreach ($workflowClient->getWorkflowHistory($stub->getExecution()) as $event) { - if ($event->hasActivityTaskScheduledEventAttributes()) { - $found = true; - break; + $started = false; + while (\microtime(true) < $deadline) { + $response = $serviceClient->DescribeWorkflowExecution( + (new DescribeWorkflowExecutionRequest()) + ->setNamespace('default') + ->setExecution( + (new WorkflowExecution()) + ->setWorkflowId($stub->getExecution()->getID()) + ->setRunId($stub->getExecution()->getRunID()), + ), + ); + foreach ($response->getPendingActivities() as $pending) { + if ($pending->hasLastStartedTime()) { + $started = true; + break 2; + } } + \usleep(50_000); } - if (!$found && \microtime(true) < $deadline) { - goto find; - } - - self::assertTrue($found, '`Activity task started` event not found in workflow history'); + self::assertTrue($started, 'Activity did not reach STARTED state in pending_activities'); $serviceClient->PauseActivity( (new PauseActivityRequest()) diff --git a/tests/Acceptance/Harness/ContinueAsNew/ContinueAsSameTest.php b/tests/Acceptance/Harness/ContinueAsNew/ContinueAsSameTest.php index f9b8e60da..f7229c82b 100644 --- a/tests/Acceptance/Harness/ContinueAsNew/ContinueAsSameTest.php +++ b/tests/Acceptance/Harness/ContinueAsNew/ContinueAsSameTest.php @@ -47,6 +47,7 @@ public function run(string $input) if (!empty(Workflow::getInfo()->continuedExecutionRunId)) { return $input; } + throw new \Exception('Should not be called'); return yield Workflow::continueAsNew( 'Harness_ContinueAsNew_ContinueAsSame', diff --git a/tests/Unit/Logger/TranscriptWriterTestCase.php b/tests/Unit/Logger/TranscriptWriterTestCase.php index ca79f34c1..90c66e35b 100644 --- a/tests/Unit/Logger/TranscriptWriterTestCase.php +++ b/tests/Unit/Logger/TranscriptWriterTestCase.php @@ -7,13 +7,21 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use RoadRunner\Temporal\DTO\V1\Frame; +use RoadRunner\Temporal\DTO\V1\Message; +use Temporal\Api\Common\V1\Header; +use Temporal\Api\Common\V1\Payload; +use Temporal\Api\Common\V1\Payloads; +use Temporal\Api\Failure\V1\Failure; use Temporal\Testing\Transcript\MalformedTranscriptException; use Temporal\Testing\Transcript\TranscriptLine; use Temporal\Testing\Transcript\TranscriptReader; use Temporal\Testing\Transcript\TranscriptSection; use Temporal\Testing\Transcript\TranscriptWriter; +use Temporal\Testing\Transcript\WireFrameDecoder; #[CoversClass(TranscriptWriter::class)] +#[CoversClass(WireFrameDecoder::class)] #[UsesClass(TranscriptSection::class)] #[UsesClass(TranscriptReader::class)] #[UsesClass(TranscriptLine::class)] @@ -81,6 +89,124 @@ public function testWriteWireRoundTripsFrameBytes(): void self::assertSame('InvokeActivity', $decoded['command']); } + public function testWriteWireDecodesTemporalProtobufFrame(): void + { + $jsonPayload = (new Payload()) + ->setMetadata(['encoding' => 'json/plain']) + ->setData('{"hello":"world","n":7}'); + $nullPayload = (new Payload()) + ->setMetadata(['encoding' => 'binary/null']) + ->setData(''); + $headerPayload = (new Payload()) + ->setMetadata(['encoding' => 'json/plain']) + ->setData('"trace-id"'); + + $message = new Message(); + $message->setId(42); + $message->setCommand('StartWorkflow'); + $message->setOptions('{"name":"Demo","attempt":1}'); + $message->setPayloads((new Payloads())->setPayloads([$jsonPayload, $nullPayload])); + $message->setHeader((new Header())->setFields(['traceId' => $headerPayload])); + $message->setHistoryLength(3); + $message->setHistorySize(375); + $message->setRunId('run-1'); + $message->setTaskQueue('default'); + $message->setReplay(true); + + $frame = (new Frame())->setMessages([$message])->serializeToString(); + + $writer = new TranscriptWriter($this->directory . '/proto-wire.log'); + $writer->writeWireInbound($frame, ['tickTime' => '2026-05-26'], 7); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $inbound = $reader->findBySection(TranscriptSection::WIRE_INBOUND); + self::assertCount(1, $inbound); + self::assertSame(\strlen($frame), $inbound[0]->attributes['bytes']); + + $body = $inbound[0]->payload['body']; + self::assertSame('temporal-frame', $body['encoding']); + self::assertCount(1, $body['messages']); + $decoded = $body['messages'][0]; + + self::assertSame('42', $decoded['id']); + self::assertSame('StartWorkflow', $decoded['command']); + self::assertSame(['name' => 'Demo', 'attempt' => 1], $decoded['options']); + self::assertSame('3', $decoded['history_length']); + self::assertSame('375', $decoded['history_size']); + self::assertSame('run-1', $decoded['run_id']); + self::assertSame('default', $decoded['task_queue']); + self::assertTrue($decoded['replay']); + + self::assertCount(2, $decoded['payloads']); + self::assertSame(['hello' => 'world', 'n' => 7], $decoded['payloads'][0]); + self::assertNull($decoded['payloads'][1]); + + self::assertSame(['traceId' => 'trace-id'], $decoded['header']); + } + + public function testWriteWireDecodesFailureAndPreservesNonUtf8Payload(): void + { + $cause = (new Failure()) + ->setMessage('inner') + ->setSource('PHP_SDK') + ->setStackTrace("at foo\nat bar"); + $failure = (new Failure()) + ->setMessage('Should not be called') + ->setSource('PHP_SDK') + ->setStackTrace('#0 outer') + ->setCause($cause); + + $binaryPayload = (new Payload()) + ->setMetadata(['encoding' => 'binary/plain']) + ->setData("\x00\xff\x01\x02non-utf8"); + + $message = (new Message()) + ->setCommand('CompleteWorkflow') + ->setFailure($failure) + ->setPayloads((new Payloads())->setPayloads([$binaryPayload])); + + $frame = (new Frame())->setMessages([$message])->serializeToString(); + + $writer = new TranscriptWriter($this->directory . '/proto-failure.log'); + $writer->writeWireOutbound($frame, 1, 1); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $outbound = $reader->findBySection(TranscriptSection::WIRE_OUTBOUND); + self::assertCount(1, $outbound); + + $decoded = $outbound[0]->payload['body']['messages'][0]; + self::assertSame('CompleteWorkflow', $decoded['command']); + self::assertSame('Should not be called', $decoded['failure']['message']); + self::assertSame('PHP_SDK', $decoded['failure']['source']); + self::assertSame('#0 outer', $decoded['failure']['stack_trace']); + self::assertSame('inner', $decoded['failure']['cause']['message']); + self::assertArrayNotHasKey('cause', $decoded['failure']['cause']); + self::assertArrayNotHasKey('id', $decoded); + self::assertArrayNotHasKey('replay', $decoded); + + $fallback = $decoded['payloads'][0]; + self::assertSame(['encoding' => 'binary/plain'], $fallback['metadata']); + self::assertSame('base64', $fallback['data']['encoding']); + self::assertSame("\x00\xff\x01\x02non-utf8", \base64_decode($fallback['data']['value'])); + } + + public function testWriteWireFallsBackToBase64ForUnparseableBinaryFrame(): void + { + $frame = "\x00\xff\x01\xfa\xfb\xfc"; + $writer = new TranscriptWriter($this->directory . '/garbage.log'); + $writer->writeWireInbound($frame, [], 1); + $writer->flush(); + + $reader = new TranscriptReader($this->directory); + $inbound = $reader->findBySection(TranscriptSection::WIRE_INBOUND); + self::assertCount(1, $inbound); + $body = $inbound[0]->payload['body']; + self::assertSame('raw', $body['encoding']); + self::assertSame($frame, \base64_decode($body['preview_base64'])); + } + public function testWriteExceptionCarriesClassAndTrace(): void { $writer = new TranscriptWriter($this->directory . '/exc.log'); diff --git a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php index 6783ef18d..8babb6fbf 100644 --- a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php +++ b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php @@ -9,8 +9,10 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Temporal\Api\Enums\V1\EventType; +use Temporal\Api\Failure\V1\Failure; use Temporal\Api\History\V1\History; use Temporal\Api\History\V1\HistoryEvent; +use Temporal\Api\History\V1\WorkflowExecutionFailedEventAttributes; use Temporal\Api\Workflowservice\V1\GetWorkflowExecutionHistoryResponse; use Temporal\Client\Common\Paginator; use Temporal\Client\Workflow\WorkflowExecutionHistory; @@ -189,6 +191,87 @@ public function testWritesHistoryErrorWhenClientThrows(): void self::assertEmpty($dumped); } + public function testRenderForFailureIgnoresArgsWithoutStubs(): void + { + $writer = $this->newWriter('render-no-stubs.log'); + $client = $this->createMock(WorkflowClientInterface::class); + $client->expects(self::never())->method('getWorkflowHistory'); + + (new WorkflowHistoryDumper())->renderForFailure($writer, $client, ['x', 1, new \stdClass()]); + $writer->flush(); + + self::assertSame([], $this->renderMetas('workflow_history_render')); + } + + public function testRenderForFailureEmitsRenderedTextWithFailureTree(): void + { + $writer = $this->newWriter('render-failure.log'); + $stub = $this->createMock(WorkflowStubInterface::class); + $stub->method('getExecution')->willReturn(new WorkflowExecution('wf-1', 'run-1')); + + $start = $this->newEvent(1, EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, 1700000000, 0); + $failed = $this->newEvent(2, EventType::EVENT_TYPE_WORKFLOW_EXECUTION_FAILED, 1700000000, 250_000_000); + $failed->setWorkflowExecutionFailedEventAttributes( + (new WorkflowExecutionFailedEventAttributes())->setFailure( + (new Failure()) + ->setMessage('Should not be called') + ->setSource('PHP_SDK') + ->setStackTrace('#0 outer'), + ), + ); + + $client = $this->createMock(WorkflowClientInterface::class); + $client->method('getWorkflowHistory')->willReturn($this->makeHistory([$start, $failed])); + + (new WorkflowHistoryDumper())->renderForFailure($writer, $client, [$stub]); + $writer->flush(); + + $renders = $this->renderMetas('workflow_history_render'); + self::assertCount(1, $renders); + self::assertSame('wf-1', $renders[0]->attributes['workflow_id']); + self::assertSame('run-1', $renders[0]->attributes['run_id']); + + $text = (string) $renders[0]->attributes['text']; + self::assertStringContainsString('EVENT_TYPE_WORKFLOW_EXECUTION_STARTED', $text); + self::assertStringContainsString('EVENT_TYPE_WORKFLOW_EXECUTION_FAILED', $text); + self::assertStringContainsString('250ms', $text); + self::assertStringContainsString('Should not be called', $text); + self::assertStringContainsString('PHP_SDK', $text); + self::assertStringContainsString('BEGIN', $text); + self::assertStringContainsString('END', $text); + } + + public function testRenderForFailureEmitsRenderFailedMetaWhenClientThrows(): void + { + $writer = $this->newWriter('render-throw.log'); + $stub = $this->createMock(WorkflowStubInterface::class); + $stub->method('getExecution')->willReturn(new WorkflowExecution('wf-x', 'run-x')); + + $client = $this->createMock(WorkflowClientInterface::class); + $client->method('getWorkflowHistory')->willThrowException(new \RuntimeException('temporal-down')); + + (new WorkflowHistoryDumper())->renderForFailure($writer, $client, [$stub]); + $writer->flush(); + + $metas = $this->renderMetas('workflow_history_render_failed'); + self::assertCount(1, $metas); + self::assertSame('wf-x', $metas[0]->attributes['workflow_id']); + self::assertSame(\RuntimeException::class, $metas[0]->attributes['class']); + self::assertSame('temporal-down', $metas[0]->attributes['message']); + } + + /** + * @return list + */ + private function renderMetas(string $event): array + { + $reader = new TranscriptReader($this->directory); + return \array_values(\array_filter( + $reader->findBySection(TranscriptSection::META), + static fn(TranscriptLine $line): bool => ($line->attributes['event'] ?? null) === $event, + )); + } + public function testRecordsSerializeErrorAttributeWhenEventSerializationFails(): void { $writer = $this->newWriter('serr.log'); From e74190946eb7fb563713b6ac57afe97bf426561b Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Tue, 26 May 2026 10:34:09 +0400 Subject: [PATCH 15/24] refactor: streamline WorkflowHistoryDumper API, enrich history tracking with failure summaries and delta timestamps --- .../src/Transcript/WorkflowHistoryDumper.php | 127 ++++-------------- tests/Acceptance/App/TestCase.php | 92 ++++++++++--- .../Logger/WorkflowHistoryDumperTestCase.php | 87 +++++------- 3 files changed, 139 insertions(+), 167 deletions(-) diff --git a/testing/src/Transcript/WorkflowHistoryDumper.php b/testing/src/Transcript/WorkflowHistoryDumper.php index 8cec867e9..53215b79f 100644 --- a/testing/src/Transcript/WorkflowHistoryDumper.php +++ b/testing/src/Transcript/WorkflowHistoryDumper.php @@ -4,9 +4,7 @@ namespace Temporal\Testing\Transcript; -use Google\Protobuf\Timestamp; use Temporal\Api\Enums\V1\EventType; -use Temporal\Api\Failure\V1\Failure; use Temporal\Api\History\V1\HistoryEvent; use Temporal\Client\WorkflowClientInterface; use Temporal\Client\WorkflowStubInterface; @@ -14,43 +12,6 @@ final class WorkflowHistoryDumper { - /** - * Writes a human-readable, single-blob render of each stub's history into - * the transcript as `workflow_history_render` META events. Use on test - * failure to capture an at-a-glance view alongside the structured `HISTORY` - * events produced by {@see self::dump()}. - * - * @param array $args Call arguments; only WorkflowStubInterface entries are inspected. - */ - public function renderForFailure( - TranscriptWriter $transcript, - WorkflowClientInterface $workflowClient, - array $args, - ): void { - foreach ($args as $arg) { - if (!$arg instanceof WorkflowStubInterface) { - continue; - } - $execution = $arg->getExecution(); - try { - $text = $this->renderExecution($workflowClient, $arg); - } catch (\Throwable $renderError) { - $transcript->writeMeta('workflow_history_render_failed', [ - 'workflow_id' => $execution->getID(), - 'run_id' => (string) $execution->getRunID(), - 'class' => $renderError::class, - 'message' => $renderError->getMessage(), - ]); - continue; - } - $transcript->writeMeta('workflow_history_render', [ - 'workflow_id' => $execution->getID(), - 'run_id' => (string) $execution->getRunID(), - 'text' => $text, - ]); - } - } - /** * @param array $args Call arguments; WorkflowStubInterface entries contribute their execution. */ @@ -95,6 +56,7 @@ private function dumpExecution( ): void { try { $eventCount = 0; + $startSec = null; foreach ($workflowClient->getWorkflowHistory($execution) as $event) { $eventCount++; $eventAttributes = [ @@ -103,8 +65,12 @@ private function dumpExecution( ]; $eventTime = $event->getEventTime(); if ($eventTime !== null) { + $sec = $eventTime->getSeconds() + \round($eventTime->getNanos() / 1_000_000_000, 6); $eventAttributes['event_time'] = $eventTime->getSeconds() . '.' . $eventTime->getNanos(); + $startSec ??= $sec; + $eventAttributes['delta_ms'] = (int) \round(($sec - $startSec) * 1000); } + $eventAttributes += $this->extractFailureSummary($event); $payloadJson = '{}'; try { $payloadJson = $event->serializeToJsonString(); @@ -128,68 +94,33 @@ private function dumpExecution( } } - private function renderExecution(WorkflowClientInterface $workflowClient, WorkflowStubInterface $stub): string + /** + * Pulls a one-line summary out of *Failed / *FailedEventAttributes so + * the row can be grepped/scanned without parsing the full proto-JSON. + * + * @return array + */ + private function extractFailureSummary(HistoryEvent $event): array { - $fnTime = static fn(?Timestamp $ts): float => $ts === null - ? 0 - : $ts->getSeconds() + \round($ts->getNanos() / 1_000_000_000, 6); - - $out = ''; - $start = null; - foreach ($workflowClient->getWorkflowHistory($stub->getExecution()) as $event) { - \assert($event instanceof HistoryEvent); - $start ??= $fnTime($event->getEventTime()); - $deltaMs = \round(1_000 * ($fnTime($event->getEventTime()) - $start)); - - $out .= "\n" - . \str_pad((string) $event->getEventId(), 3, ' ', STR_PAD_LEFT) . ' ' - . \str_pad(\number_format($deltaMs, 0, '.', "'"), 6, ' ', STR_PAD_LEFT) . 'ms ' - . \str_pad(EventType::name($event->getEventType()), 40, ' ', STR_PAD_RIGHT) . ' '; - - $cause = $event->getStartChildWorkflowExecutionFailedEventAttributes()?->getCause() - ?? $event->getSignalExternalWorkflowExecutionFailedEventAttributes()?->getCause() - ?? $event->getRequestCancelExternalWorkflowExecutionFailedEventAttributes()?->getCause(); - if ($cause !== null) { - $out .= "Cause: $cause"; - continue; - } - - $failure = $event->getActivityTaskFailedEventAttributes()?->getFailure() - ?? $event->getWorkflowTaskFailedEventAttributes()?->getFailure() - ?? $event->getNexusOperationFailedEventAttributes()?->getFailure() - ?? $event->getWorkflowExecutionFailedEventAttributes()?->getFailure() - ?? $event->getChildWorkflowExecutionFailedEventAttributes()?->getFailure() - ?? $event->getNexusOperationCancelRequestFailedEventAttributes()?->getFailure(); - - if ($failure === null) { - continue; - } - - $out .= "Failure:\n" - . " ========== BEGIN ===========\n" - . $this->renderFailure($failure, 1) - . " =========== END ============"; + $cause = $event->getStartChildWorkflowExecutionFailedEventAttributes()?->getCause() + ?? $event->getSignalExternalWorkflowExecutionFailedEventAttributes()?->getCause() + ?? $event->getRequestCancelExternalWorkflowExecutionFailedEventAttributes()?->getCause(); + if ($cause !== null) { + return ['cause' => (string) $cause]; } - return $out; - } - - private function renderFailure(Failure $failure, int $level): string - { - $pad = \str_repeat(' ', $level); - $fnPad = static fn(string $str): string => $pad . \str_replace("\n", "\n$pad", $str); - - $out = $fnPad('Source: ' . $failure->getSource()) . "\n" - . $fnPad('Info: ' . $failure->getFailureInfo()) . "\n" - . $fnPad('Message: ' . $failure->getMessage()) . "\n" - . $fnPad('Stack trace:') . "\n" - . $fnPad($failure->getStackTrace()) . "\n"; - $previous = $failure->getCause(); - if ($previous !== null) { - $out .= $fnPad('————————————————————————————') . "\n" - . $fnPad('Caused by:') . "\n" - . $this->renderFailure($previous, $level + 1); + $failure = $event->getActivityTaskFailedEventAttributes()?->getFailure() + ?? $event->getWorkflowTaskFailedEventAttributes()?->getFailure() + ?? $event->getNexusOperationFailedEventAttributes()?->getFailure() + ?? $event->getWorkflowExecutionFailedEventAttributes()?->getFailure() + ?? $event->getChildWorkflowExecutionFailedEventAttributes()?->getFailure() + ?? $event->getNexusOperationCancelRequestFailedEventAttributes()?->getFailure(); + if ($failure === null) { + return []; } - return $out; + return [ + 'failure_kind' => $failure->getFailureInfo(), + 'failure_message' => $failure->getMessage(), + ]; } } diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index a0615bd5a..f164575ca 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -4,10 +4,13 @@ namespace Temporal\Tests\Acceptance\App; +use Google\Protobuf\Timestamp; use PHPUnit\Framework\SkippedTest; use Psr\Log\LoggerInterface; use Spiral\Core\Container; use Spiral\Core\Scope; +use Temporal\Api\Enums\V1\EventType; +use Temporal\Api\Failure\V1\Failure; use Temporal\Client\ClientOptions; use Temporal\Client\WorkflowClient; use Temporal\Client\WorkflowClientInterface; @@ -80,9 +83,7 @@ function (Container $container) use ($workflowClient): mixed { $transcript = $container->has(TranscriptWriter::class) ? $container->get(TranscriptWriter::class) : null; - $dumper = $container->has(WorkflowHistoryDumper::class) - ? $container->get(WorkflowHistoryDumper::class) - : null; + $dumper = new WorkflowHistoryDumper(); $transcript?->writeTestBoundary(TranscriptSection::TEST_START, [ 'class' => static::class, 'method' => $this->name(), @@ -105,22 +106,12 @@ function (Container $container) use ($workflowClient): mixed { ); echo "\n=== Stack trace ===\n"; echo $e->getTraceAsString(); + echo "\n=== Workflow history ===\n"; + $this->printWorkflowHistory($workflowClient, $args); - if ($transcript !== null) { - $dumper?->renderForFailure($transcript, $workflowClient, $args); - } - - $logRecords = $container->get(ClientLogger::class)->getRecords(); - if ($logRecords !== []) { - echo "\n=== Client log records ===\n"; - foreach ($logRecords as $record) { - echo \sprintf( - "[%s] %s%s\n", - $record->level, - $record->message, - \json_encode($record->context, JSON_UNESCAPED_UNICODE), - ); - } + $clientLogger = $container->get(ClientLogger::class); + foreach ($clientLogger->getRecords() as $record) { + $transcript?->writeLog($record->level, $record->message, $record->context); } echo "\n\n"; @@ -136,7 +127,7 @@ function (Container $container) use ($workflowClient): mixed { throw $e; } finally { if ($transcript !== null) { - $dumper?->dump($transcript, $workflowClient, $args); + $dumper->dump($transcript, $workflowClient, $args); } foreach ($args as $arg) { if ($arg instanceof WorkflowStubInterface) { @@ -218,4 +209,67 @@ protected function readCurrentTestTranscript(): array return $run->reader()->linesForTest(static::class, $this->name()); } + private function printWorkflowHistory(WorkflowClientInterface $workflowClient, array $args): void + { + foreach ($args as $arg) { + if (!$arg instanceof WorkflowStubInterface) { + continue; + } + + $fnTime = static fn(?Timestamp $ts): float => $ts === null + ? 0 + : $ts->getSeconds() + \round($ts->getNanos() / 1_000_000_000, 6); + + $start = null; + foreach ($workflowClient->getWorkflowHistory($arg->getExecution()) as $event) { + $start ??= $fnTime($event->getEventTime()); + echo "\n" . \str_pad((string) $event->getEventId(), 3, ' ', STR_PAD_LEFT) . ' '; + $deltaMs = \round(1_000 * ($fnTime($event->getEventTime()) - $start)); + echo \str_pad(\number_format($deltaMs, 0, '.', "'"), 6, ' ', STR_PAD_LEFT) . 'ms '; + echo \str_pad(EventType::name($event->getEventType()), 40, ' ', STR_PAD_RIGHT) . ' '; + + $cause = $event->getStartChildWorkflowExecutionFailedEventAttributes()?->getCause() + ?? $event->getSignalExternalWorkflowExecutionFailedEventAttributes()?->getCause() + ?? $event->getRequestCancelExternalWorkflowExecutionFailedEventAttributes()?->getCause(); + if ($cause !== null) { + echo "Cause: $cause"; + continue; + } + + $failure = $event->getActivityTaskFailedEventAttributes()?->getFailure() + ?? $event->getWorkflowTaskFailedEventAttributes()?->getFailure() + ?? $event->getNexusOperationFailedEventAttributes()?->getFailure() + ?? $event->getWorkflowExecutionFailedEventAttributes()?->getFailure() + ?? $event->getChildWorkflowExecutionFailedEventAttributes()?->getFailure() + ?? $event->getNexusOperationCancelRequestFailedEventAttributes()?->getFailure(); + if ($failure === null) { + continue; + } + + echo "Failure:\n"; + echo " ========== BEGIN ===========\n"; + $this->renderFailure($failure, 1); + echo " =========== END ============"; + } + } + } + + private function renderFailure(Failure $failure, int $level): void + { + $fnPad = static function (string $str) use ($level): string { + $pad = \str_repeat(' ', $level); + return $pad . \str_replace("\n", "\n$pad", $str); + }; + echo $fnPad('Source: ' . $failure->getSource()) . "\n"; + echo $fnPad('Info: ' . $failure->getFailureInfo()) . "\n"; + echo $fnPad('Message: ' . $failure->getMessage()) . "\n"; + echo $fnPad('Stack trace:') . "\n"; + echo $fnPad($failure->getStackTrace()) . "\n"; + $previous = $failure->getCause(); + if ($previous !== null) { + echo $fnPad('————————————————————————————') . "\n"; + echo $fnPad('Caused by:') . "\n"; + $this->renderFailure($previous, $level + 1); + } + } } diff --git a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php index 8babb6fbf..7a5f0c598 100644 --- a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php +++ b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php @@ -9,9 +9,12 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Temporal\Api\Enums\V1\EventType; +use Temporal\Api\Enums\V1\WorkflowTaskFailedCause; +use Temporal\Api\Failure\V1\ApplicationFailureInfo; use Temporal\Api\Failure\V1\Failure; use Temporal\Api\History\V1\History; use Temporal\Api\History\V1\HistoryEvent; +use Temporal\Api\History\V1\StartChildWorkflowExecutionFailedEventAttributes; use Temporal\Api\History\V1\WorkflowExecutionFailedEventAttributes; use Temporal\Api\Workflowservice\V1\GetWorkflowExecutionHistoryResponse; use Temporal\Client\Common\Paginator; @@ -191,21 +194,9 @@ public function testWritesHistoryErrorWhenClientThrows(): void self::assertEmpty($dumped); } - public function testRenderForFailureIgnoresArgsWithoutStubs(): void + public function testEnrichesHistoryEntriesWithDeltaMsAndFailureSummary(): void { - $writer = $this->newWriter('render-no-stubs.log'); - $client = $this->createMock(WorkflowClientInterface::class); - $client->expects(self::never())->method('getWorkflowHistory'); - - (new WorkflowHistoryDumper())->renderForFailure($writer, $client, ['x', 1, new \stdClass()]); - $writer->flush(); - - self::assertSame([], $this->renderMetas('workflow_history_render')); - } - - public function testRenderForFailureEmitsRenderedTextWithFailureTree(): void - { - $writer = $this->newWriter('render-failure.log'); + $writer = $this->newWriter('enrich.log'); $stub = $this->createMock(WorkflowStubInterface::class); $stub->method('getExecution')->willReturn(new WorkflowExecution('wf-1', 'run-1')); @@ -216,60 +207,56 @@ public function testRenderForFailureEmitsRenderedTextWithFailureTree(): void (new Failure()) ->setMessage('Should not be called') ->setSource('PHP_SDK') - ->setStackTrace('#0 outer'), + ->setApplicationFailureInfo( + (new ApplicationFailureInfo())->setType('Exception'), + ), ), ); $client = $this->createMock(WorkflowClientInterface::class); $client->method('getWorkflowHistory')->willReturn($this->makeHistory([$start, $failed])); - (new WorkflowHistoryDumper())->renderForFailure($writer, $client, [$stub]); + (new WorkflowHistoryDumper())->dump($writer, $client, [$stub]); $writer->flush(); - $renders = $this->renderMetas('workflow_history_render'); - self::assertCount(1, $renders); - self::assertSame('wf-1', $renders[0]->attributes['workflow_id']); - self::assertSame('run-1', $renders[0]->attributes['run_id']); - - $text = (string) $renders[0]->attributes['text']; - self::assertStringContainsString('EVENT_TYPE_WORKFLOW_EXECUTION_STARTED', $text); - self::assertStringContainsString('EVENT_TYPE_WORKFLOW_EXECUTION_FAILED', $text); - self::assertStringContainsString('250ms', $text); - self::assertStringContainsString('Should not be called', $text); - self::assertStringContainsString('PHP_SDK', $text); - self::assertStringContainsString('BEGIN', $text); - self::assertStringContainsString('END', $text); + $reader = new TranscriptReader($this->directory); + $history = $reader->findBySection(TranscriptSection::HISTORY); + self::assertCount(2, $history); + self::assertSame(0, $history[0]->attributes['delta_ms']); + self::assertSame(250, $history[1]->attributes['delta_ms']); + self::assertSame('application_failure_info', $history[1]->attributes['failure_kind']); + self::assertSame('Should not be called', $history[1]->attributes['failure_message']); + self::assertArrayNotHasKey('failure_kind', $history[0]->attributes); + self::assertArrayNotHasKey('cause', $history[0]->attributes); } - public function testRenderForFailureEmitsRenderFailedMetaWhenClientThrows(): void + public function testRecordsCauseAttributeForChildWorkflowStartFailure(): void { - $writer = $this->newWriter('render-throw.log'); + $writer = $this->newWriter('cause.log'); $stub = $this->createMock(WorkflowStubInterface::class); - $stub->method('getExecution')->willReturn(new WorkflowExecution('wf-x', 'run-x')); + $stub->method('getExecution')->willReturn(new WorkflowExecution('wf-c', 'run-c')); + + $event = $this->newEvent(7, EventType::EVENT_TYPE_START_CHILD_WORKFLOW_EXECUTION_FAILED, 1700000000, 0); + $event->setStartChildWorkflowExecutionFailedEventAttributes( + (new StartChildWorkflowExecutionFailedEventAttributes()) + ->setCause(WorkflowTaskFailedCause::WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_WORKER_UNHANDLED_FAILURE), + ); $client = $this->createMock(WorkflowClientInterface::class); - $client->method('getWorkflowHistory')->willThrowException(new \RuntimeException('temporal-down')); + $client->method('getWorkflowHistory')->willReturn($this->makeHistory([$event])); - (new WorkflowHistoryDumper())->renderForFailure($writer, $client, [$stub]); + (new WorkflowHistoryDumper())->dump($writer, $client, [$stub]); $writer->flush(); - $metas = $this->renderMetas('workflow_history_render_failed'); - self::assertCount(1, $metas); - self::assertSame('wf-x', $metas[0]->attributes['workflow_id']); - self::assertSame(\RuntimeException::class, $metas[0]->attributes['class']); - self::assertSame('temporal-down', $metas[0]->attributes['message']); - } - - /** - * @return list - */ - private function renderMetas(string $event): array - { $reader = new TranscriptReader($this->directory); - return \array_values(\array_filter( - $reader->findBySection(TranscriptSection::META), - static fn(TranscriptLine $line): bool => ($line->attributes['event'] ?? null) === $event, - )); + $history = $reader->findBySection(TranscriptSection::HISTORY); + self::assertCount(1, $history); + self::assertArrayHasKey('cause', $history[0]->attributes); + self::assertSame( + (string) WorkflowTaskFailedCause::WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_WORKER_UNHANDLED_FAILURE, + (string) $history[0]->attributes['cause'], + ); + self::assertArrayNotHasKey('failure_kind', $history[0]->attributes); } public function testRecordsSerializeErrorAttributeWhenEventSerializationFails(): void From cf3a1328bfed2b7d2aedfeb8964c0c2f4aec9ab3 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Tue, 26 May 2026 18:51:40 +0400 Subject: [PATCH 16/24] refactor: streamline RRStarter initialization by embedding allowed test classes directly in State, remove unused method parameter --- tests/Acceptance/App/Runtime/RRStarter.php | 8 ++++---- tests/Acceptance/App/Runtime/State.php | 7 +++++++ tests/Acceptance/App/RuntimeBuilder.php | 1 + tests/Acceptance/ExecutionStartedSubscriber.php | 7 +++++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/Acceptance/App/Runtime/RRStarter.php b/tests/Acceptance/App/Runtime/RRStarter.php index db0f2374f..694f13306 100644 --- a/tests/Acceptance/App/Runtime/RRStarter.php +++ b/tests/Acceptance/App/Runtime/RRStarter.php @@ -11,6 +11,7 @@ final class RRStarter { private Environment $environment; + public function __construct( private State $runtime, ?Environment $environment = null, @@ -19,15 +20,14 @@ public function __construct( \register_shutdown_function(fn() => $this->stop()); } - /** - * @param list $allowedTestClasses - */ - public function start(array $allowedTestClasses = []): void + public function start(): void { if ($this->environment->isRoadRunnerRunning()) { return; } + $allowedTestClasses = $this->runtime->allowedTestClasses; + $systemInfo = SystemInfo::detect(); $run = $this->runtime->command; diff --git a/tests/Acceptance/App/Runtime/State.php b/tests/Acceptance/App/Runtime/State.php index 1becbc3ee..9eea9d8aa 100644 --- a/tests/Acceptance/App/Runtime/State.php +++ b/tests/Acceptance/App/Runtime/State.php @@ -18,6 +18,13 @@ final class State /** @var non-empty-string */ public string $address; + /** + * @var list Test classes the worker pool was bootstrapped with. + * Honored by RRStarter on initial start AND on every restart, so failure-triggered + * restarts preserve the same selection instead of falling back to "register all". + */ + public array $allowedTestClasses = []; + /** * @param non-empty-string $rrConfigDir Dir with rr.yaml * @param non-empty-string $workDir Dir where tests are run diff --git a/tests/Acceptance/App/RuntimeBuilder.php b/tests/Acceptance/App/RuntimeBuilder.php index 7342616c7..64086f205 100644 --- a/tests/Acceptance/App/RuntimeBuilder.php +++ b/tests/Acceptance/App/RuntimeBuilder.php @@ -73,6 +73,7 @@ public static function createState( array $allowedTestClasses = [], ): State { $runtime = new State($command, \dirname(__DIR__), $workDir, $testCasesDir, $workers); + $runtime->allowedTestClasses = $allowedTestClasses; self::hydrateClasses($runtime, $allowedTestClasses); diff --git a/tests/Acceptance/ExecutionStartedSubscriber.php b/tests/Acceptance/ExecutionStartedSubscriber.php index fd70fcc24..29213d219 100644 --- a/tests/Acceptance/ExecutionStartedSubscriber.php +++ b/tests/Acceptance/ExecutionStartedSubscriber.php @@ -57,7 +57,10 @@ public function notify(ExecutionStarted $event): void } $logger = new StderrLogger(); - $logger->info('[selection] picked test classes after filtering', ['count' => \count($selectedTestClasses)]); + $logger->info('[selection] picked test classes after filtering', [ + 'count' => \count($selectedTestClasses), + 'classes' => $selectedTestClasses, + ]); RuntimeBuilder::init(); @@ -95,7 +98,7 @@ public function notify(ExecutionStarted $event): void $temporalRunner = new TemporalStarter($environment); $rrRunner = new RRStarter($state, $environment); $temporalRunner->start(); - $rrRunner->start($selectedTestClasses); + $rrRunner->start(); $serviceClient = $state->command->tlsKey === null && $state->command->tlsCert === null ? ServiceClient::create($state->address) From 2fdd3c2758f04ee5e93167ee4d1586494052b4f3 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Tue, 26 May 2026 18:59:59 +0400 Subject: [PATCH 17/24] update: regenerate psalm baseline to reflect changes in testing transcript classes --- psalm-baseline.xml | 78 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 620ba5124..108b1fa6a 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + @@ -1422,6 +1422,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $next($input)]]> + $next($input)]]> + + + + + fileDescriptor]]> + fileDescriptor]]> + + + + + + + fileDescriptor]]> + + + + + + + + + + + + getHeader()]]> + getPayloads()]]> + + + + + + + + + getSeconds() + \round($eventTime->getNanos() / 1_000_000_000, 6)]]> + + + + getSeconds()]]> + + From d7c23feeb4ef0b8f67d435c7bbe1b6093407b0d1 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Tue, 26 May 2026 19:56:05 +0400 Subject: [PATCH 18/24] refactor: simplify WireFrameDecoder payload decoding logic, introduce `decodeSinglePayload` for improved readability and fallback handling --- testing/src/Transcript/WireFrameDecoder.php | 38 ++++++++++++++------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/testing/src/Transcript/WireFrameDecoder.php b/testing/src/Transcript/WireFrameDecoder.php index 5b690cd06..6b492f073 100644 --- a/testing/src/Transcript/WireFrameDecoder.php +++ b/testing/src/Transcript/WireFrameDecoder.php @@ -12,8 +12,7 @@ use Temporal\Api\Common\V1\Payloads; use Temporal\DataConverter\DataConverter; use Temporal\DataConverter\DataConverterInterface; -use Temporal\DataConverter\EncodedCollection; -use Temporal\DataConverter\EncodedValues; +use Temporal\DataConverter\EncodingKeys; final class WireFrameDecoder { @@ -89,11 +88,11 @@ private static function decodeMessage(Message $message, DataConverterInterface $ */ private static function decodePayloads(Payloads $payloads, DataConverterInterface $converter): array { - try { - return \array_values(EncodedValues::fromPayloads($payloads, $converter)->getValues()); - } catch (\Throwable) { - return \array_map(self::payloadFallback(...), \iterator_to_array($payloads->getPayloads(), false)); + $out = []; + foreach ($payloads->getPayloads() as $payload) { + $out[] = self::decodeSinglePayload($payload, $converter); } + return $out; } /** @@ -101,16 +100,31 @@ private static function decodePayloads(Payloads $payloads, DataConverterInterfac */ private static function decodeHeader(Header $header, DataConverterInterface $converter): array { + $out = []; /** @var MapField $fields */ $fields = $header->getFields(); + foreach ($fields as $name => $payload) { + $out[$name] = self::decodeSinglePayload($payload, $converter); + } + return $out; + } + + /** + * Decodes a single payload using its own `metadata.encoding`. Falls back to a raw + * representation when encoding is absent (e.g., {@see \Temporal\DataConverter\RawValue}) + * or when the converter cannot interpret the bytes. + */ + private static function decodeSinglePayload(Payload $payload, DataConverterInterface $converter): mixed + { + /** @var MapField $meta */ + $meta = $payload->getMetadata(); + if (!isset($meta[EncodingKeys::METADATA_ENCODING_KEY])) { + return self::payloadFallback($payload); + } try { - return EncodedCollection::fromPayloadCollection($fields, $converter)->getValues(); + return $converter->fromPayload($payload, null); } catch (\Throwable) { - $out = []; - foreach ($fields as $name => $payload) { - $out[$name] = self::payloadFallback($payload); - } - return $out; + return self::payloadFallback($payload); } } From 4ce0a67d9c6c7d19a2e3a5d0db6cc88686e2cf8d Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 27 May 2026 11:58:53 +0400 Subject: [PATCH 19/24] refactor: simplify WireFrameDecoder payload decoding logic, introduce `decodeSinglePayload` for improved readability and fallback handling --- tests/Acceptance/App/TaskQueueResolver.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Acceptance/App/TaskQueueResolver.php b/tests/Acceptance/App/TaskQueueResolver.php index fc12ecf91..399ab18fc 100644 --- a/tests/Acceptance/App/TaskQueueResolver.php +++ b/tests/Acceptance/App/TaskQueueResolver.php @@ -22,6 +22,7 @@ final class TaskQueueResolver \Temporal\Tests\Acceptance\Harness\Signal\Activities\ActivitiesTest::class, \Temporal\Tests\Acceptance\Extra\Versioning\Classic\ClassicTest::class, \Temporal\Tests\Acceptance\Extra\Versioning\Deployment\DeploymentTest::class, + \Temporal\Tests\Acceptance\Extra\Activity\ActivityPaused\ActivityPausedTest::class, ]; /** From 5cfc4f2fec72d601c065fa04f4ba3813a368674a Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 27 May 2026 13:28:40 +0400 Subject: [PATCH 20/24] refactor: replace workflow execution polling with direct history inspection in ActivityPausedTest for improved clarity --- .../Extra/Activity/ActivityPausedTest.php | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/tests/Acceptance/Extra/Activity/ActivityPausedTest.php b/tests/Acceptance/Extra/Activity/ActivityPausedTest.php index 5a6306989..acf2edcf5 100644 --- a/tests/Acceptance/Extra/Activity/ActivityPausedTest.php +++ b/tests/Acceptance/Extra/Activity/ActivityPausedTest.php @@ -7,7 +7,6 @@ use PHPUnit\Framework\Attributes\Test; use Temporal\Activity; use Temporal\Api\Common\V1\WorkflowExecution; -use Temporal\Api\Workflowservice\V1\DescribeWorkflowExecutionRequest; use Temporal\Api\Workflowservice\V1\PauseActivityRequest; use Temporal\Client\GRPC\ServiceClientInterface; use Temporal\Client\WorkflowClientInterface; @@ -28,27 +27,20 @@ public function simplePause( WorkflowClientInterface $workflowClient, ): void { $deadline = \microtime(true) + 10; - $started = false; - while (\microtime(true) < $deadline) { - $response = $serviceClient->DescribeWorkflowExecution( - (new DescribeWorkflowExecutionRequest()) - ->setNamespace('default') - ->setExecution( - (new WorkflowExecution()) - ->setWorkflowId($stub->getExecution()->getID()) - ->setRunId($stub->getExecution()->getRunID()), - ), - ); - foreach ($response->getPendingActivities() as $pending) { - if ($pending->hasLastStartedTime()) { - $started = true; - break 2; - } + find: + $found = false; + foreach ($workflowClient->getWorkflowHistory($stub->getExecution()) as $event) { + if ($event->hasActivityTaskScheduledEventAttributes()) { + $found = true; + break; } - \usleep(50_000); } - self::assertTrue($started, 'Activity did not reach STARTED state in pending_activities'); + if (!$found && \microtime(true) < $deadline) { + goto find; + } + + self::assertTrue($found, '`Activity task started` event not found in workflow history'); $serviceClient->PauseActivity( (new PauseActivityRequest()) From 6ff96c3d0b035088630f7461751b264a01e95b61 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 27 May 2026 13:33:20 +0400 Subject: [PATCH 21/24] refactor: remove unnecessary annotation in WireFrameDecoder metadata fallback logic --- testing/src/Transcript/WireFrameDecoder.php | 1 - 1 file changed, 1 deletion(-) diff --git a/testing/src/Transcript/WireFrameDecoder.php b/testing/src/Transcript/WireFrameDecoder.php index 6b492f073..229bf198d 100644 --- a/testing/src/Transcript/WireFrameDecoder.php +++ b/testing/src/Transcript/WireFrameDecoder.php @@ -134,7 +134,6 @@ private static function decodeSinglePayload(Payload $payload, DataConverterInter private static function payloadFallback(Payload $payload): array { $metadata = []; - /** @var MapField $meta */ $meta = $payload->getMetadata(); foreach ($meta as $key => $value) { $metadata[$key] = self::bytesToReadable((string) $value); From 4228ecefee4027bf5d0254946429a5dae15ffb0e Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 27 May 2026 13:42:35 +0400 Subject: [PATCH 22/24] refactor: remove test for serialize error attribute in WorkflowHistoryDumper, no longer relevant --- .../Logger/WorkflowHistoryDumperTestCase.php | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php index 7a5f0c598..5069bc9b5 100644 --- a/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php +++ b/tests/Unit/Logger/WorkflowHistoryDumperTestCase.php @@ -259,32 +259,6 @@ public function testRecordsCauseAttributeForChildWorkflowStartFailure(): void self::assertArrayNotHasKey('failure_kind', $history[0]->attributes); } - public function testRecordsSerializeErrorAttributeWhenEventSerializationFails(): void - { - $writer = $this->newWriter('serr.log'); - $stub = $this->createMock(WorkflowStubInterface::class); - $stub->method('getExecution')->willReturn(new WorkflowExecution('wf-serr', 'run-1')); - - $event = $this->createMock(HistoryEvent::class); - $event->method('getEventId')->willReturn(7); - $event->method('getEventType')->willReturn(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED); - $event->method('getEventTime')->willReturn(null); - $event->method('serializeToJsonString')->willThrowException(new \RuntimeException('bad-utf8')); - - $client = $this->createMock(WorkflowClientInterface::class); - $client->method('getWorkflowHistory')->willReturn($this->makeHistory([$event])); - - (new WorkflowHistoryDumper())->dump($writer, $client, [$stub]); - $writer->flush(); - - $reader = new TranscriptReader($this->directory); - $history = $reader->findBySection(TranscriptSection::HISTORY); - self::assertCount(1, $history); - self::assertSame(7, $history[0]->attributes['event_id']); - self::assertSame('bad-utf8', $history[0]->attributes['serialize_error']); - self::assertSame('{}', $history[0]->payload['attrs']); - } - private function newWriter(string $name): TranscriptWriter { return new TranscriptWriter($this->directory . '/' . $name); From eaf501d744a69e45bbbe3140dd687776cb43cd7c Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 27 May 2026 13:45:18 +0400 Subject: [PATCH 23/24] update: bump `symfony/http-client` dependency to `^5.4.53` --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 8dd098366..d3ea5fb04 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "spiral/roadrunner-kv": "^4.3.1", "spiral/roadrunner-worker": "^3.6.2", "symfony/filesystem": "^5.4.45 || ^6.4.13 || ^7.0 || ^8.0", - "symfony/http-client": "^5.4.49 || ^6.4.17 || ^7.0 || ^8.0", + "symfony/http-client": "^5.4.53 || ^6.4.17 || ^7.0 || ^8.0", "symfony/polyfill-php83": "^1.31.0", "symfony/process": "^5.4.51 || ^6.4.15 || ^7.0 || ^8.0" }, From e555b15f4d540453a90e3637c2cd380407088477 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 27 May 2026 13:53:03 +0400 Subject: [PATCH 24/24] fix: remove error stub --- tests/Acceptance/Harness/ContinueAsNew/ContinueAsSameTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Acceptance/Harness/ContinueAsNew/ContinueAsSameTest.php b/tests/Acceptance/Harness/ContinueAsNew/ContinueAsSameTest.php index f7229c82b..f9b8e60da 100644 --- a/tests/Acceptance/Harness/ContinueAsNew/ContinueAsSameTest.php +++ b/tests/Acceptance/Harness/ContinueAsNew/ContinueAsSameTest.php @@ -47,7 +47,6 @@ public function run(string $input) if (!empty(Workflow::getInfo()->continuedExecutionRunId)) { return $input; } - throw new \Exception('Should not be called'); return yield Workflow::continueAsNew( 'Harness_ContinueAsNew_ContinueAsSame',