Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
db2e881
feat: add transcript capture for acceptance tests
xepozz May 14, 2026
4329578
Merge branch 'master' into feature/acceptance-transcript
xepozz May 25, 2026
ab0f7a0
refactor: remove redundant setUp method in TestCase
xepozz May 25, 2026
04ef0d9
refactor: migrate transcript writer and reader to JSON format, extrac…
xepozz May 25, 2026
4f12794
feat: introduce TranscriptPlugin for worker interceptors and plugin r…
xepozz May 25, 2026
78b9c1c
feat: enhance transcript management with rotation support, error hand…
xepozz May 25, 2026
c8b25a1
refactor: improve transcript handling with strict validations, refine…
xepozz May 25, 2026
4315d68
refactor: remove unused `rebindWriter` method from FatalHandler
xepozz May 25, 2026
50291de
refactor: centralize transcript path management and improve filesyste…
xepozz May 25, 2026
b810eb6
refactor: simplify FatalHandler logic and enhance logger injection co…
xepozz May 25, 2026
74f3fa5
refactor: centralize `runId` generation with `getOrCreateRunId` metho…
xepozz May 25, 2026
71cd048
refactor: consolidate transcript-related classes under `Temporal\Test…
xepozz May 25, 2026
a73d51d
refactor: consolidate transcript-related classes under `Temporal\Test…
xepozz May 25, 2026
f94523f
feat: enable conditional transcript dumping on test failures and impr…
xepozz May 25, 2026
04b4fa2
feat: add WireFrameDecoder for decoding temporal frames, extend Trans…
xepozz May 26, 2026
e741909
refactor: streamline WorkflowHistoryDumper API, enrich history tracki…
xepozz May 26, 2026
cf3a132
refactor: streamline RRStarter initialization by embedding allowed te…
xepozz May 26, 2026
2fdd3c2
update: regenerate psalm baseline to reflect changes in testing trans…
xepozz May 26, 2026
d7c23fe
refactor: simplify WireFrameDecoder payload decoding logic, introduce…
xepozz May 26, 2026
4ce0a67
refactor: simplify WireFrameDecoder payload decoding logic, introduce…
xepozz May 27, 2026
5cfc4f2
refactor: replace workflow execution polling with direct history insp…
xepozz May 27, 2026
6ff96c3
refactor: remove unnecessary annotation in WireFrameDecoder metadata …
xepozz May 27, 2026
4228ece
refactor: remove test for serialize error attribute in WorkflowHistor…
xepozz May 27, 2026
eaf501d
update: bump `symfony/http-client` dependency to `^5.4.53`
xepozz May 27, 2026
e555b15
fix: remove error stub
xepozz May 27, 2026
2428e46
Merge branch 'master' into feature/acceptance-transcript
xepozz May 28, 2026
d6e5144
Merge branch 'master' into feature/acceptance-transcript
xepozz May 28, 2026
7a6f87b
Merge branch 'master' into feature/acceptance-transcript
xepozz Jun 8, 2026
334ce6f
Merge branch 'master' into feature/acceptance-transcript
xepozz Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/run-test-suite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,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'
Expand Down
7 changes: 6 additions & 1 deletion .run/Acceptance.run.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Acceptance" type="PHPUnitRunConfigurationType" factoryName="PHPUnit">
<CommandLine>
<envs>
<env name="TEMPORAL_TRANSCRIPT_DUMP_ON_FAIL" value="1" />
</envs>
</CommandLine>
<TestRunner bootstrap_file="$PROJECT_DIR$/tests/bootstrap.php" configuration_file="$PROJECT_DIR$/phpunit.xml.dist" directory="$PROJECT_DIR$/tests/Acceptance" scope="XML" options="--testsuite=Acceptance --log-junit=runtime/phpunit-acceptance-junit.xml" />
<method v="2" />
</configuration>
</component>
</component>
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,82 @@
<code><![CDATA[ChannelCredentials]]></code>
</UndefinedClass>
</file>
<file src="testing/src/Transcript/TranscriptActivityInterceptor.php">
<PossiblyNullReference>
<code><![CDATA[getID]]></code>
</PossiblyNullReference>
</file>
<file src="testing/src/Transcript/TranscriptPaths.php">
<InvalidOperand>
<code><![CDATA[\microtime(true) * 1000]]></code>
</InvalidOperand>
<RiskyTruthyFalsyComparison>
<code><![CDATA[\getmypid()]]></code>
</RiskyTruthyFalsyComparison>
</file>
<file src="testing/src/Transcript/TranscriptReader.php">
<RedundantFunctionCall>
<code><![CDATA[\array_values]]></code>
</RedundantFunctionCall>
</file>
<file src="testing/src/Transcript/TranscriptRun.php">
<RedundantFunctionCall>
<code><![CDATA[\array_values]]></code>
</RedundantFunctionCall>
</file>
<file src="testing/src/Transcript/TranscriptStore.php">
<ArgumentTypeCoercion>
<code><![CDATA[TranscriptPaths::writerFile($directory, $processLabel)]]></code>
</ArgumentTypeCoercion>
<RiskyTruthyFalsyComparison>
<code><![CDATA[\getmypid()]]></code>
</RiskyTruthyFalsyComparison>
</file>
<file src="testing/src/Transcript/TranscriptWorkflowInterceptor.php">
<MissingClosureReturnType>
<code><![CDATA[fn() => $next($input)]]></code>
<code><![CDATA[fn() => $next($input)]]></code>
</MissingClosureReturnType>
</file>
<file src="testing/src/Transcript/TranscriptWriter.php">
<InvalidPropertyAssignmentValue>
<code><![CDATA[$this->fileDescriptor]]></code>
<code><![CDATA[$this->fileDescriptor]]></code>
</InvalidPropertyAssignmentValue>
<PossiblyInvalidCast>
<code><![CDATA[$errorRecord['file'] ?? '']]></code>
<code><![CDATA[$errorRecord['message'] ?? '']]></code>
</PossiblyInvalidCast>
<PossiblyNullArgument>
<code><![CDATA[$this->fileDescriptor]]></code>
</PossiblyNullArgument>
<RiskyCast>
<code><![CDATA[$errorRecord['line'] ?? 0]]></code>
<code><![CDATA[$errorRecord['type'] ?? 0]]></code>
</RiskyCast>
<RiskyTruthyFalsyComparison>
<code><![CDATA[\getmypid()]]></code>
</RiskyTruthyFalsyComparison>
</file>
<file src="testing/src/Transcript/WireFrameDecoder.php">
<PossiblyNullArgument>
<code><![CDATA[$message->getHeader()]]></code>
<code><![CDATA[$message->getPayloads()]]></code>
</PossiblyNullArgument>
<TooManyTemplateParams>
<code><![CDATA[$fields]]></code>
<code><![CDATA[$meta]]></code>
</TooManyTemplateParams>
</file>
<file src="testing/src/Transcript/WorkflowHistoryDumper.php">
<InvalidOperand>
<code><![CDATA[$eventTime->getSeconds() + \round($eventTime->getNanos() / 1_000_000_000, 6)]]></code>
<code><![CDATA[($sec - $startSec) * 1000]]></code>
</InvalidOperand>
<PossiblyInvalidOperand>
<code><![CDATA[$eventTime->getSeconds()]]></code>
</PossiblyInvalidOperand>
</file>
<file src="testing/src/WorkerMock.php">
<DeprecatedMethod>
<code><![CDATA[registerActivityImplementations]]></code>
Expand Down
1 change: 1 addition & 0 deletions runtime/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
*
!.gitignore
23 changes: 23 additions & 0 deletions testing/src/Transcript/MalformedTranscriptException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Temporal\Testing\Transcript;

final class MalformedTranscriptException extends \RuntimeException
{
public function __construct(
string $message,
public readonly string $offendingLine,
public readonly int $offendingLineNumber,
public readonly string $offendingFile,
) {
parent::__construct(\sprintf(
'%s (file=%s line=%d offending=%s)',
$message,
$offendingFile,
$offendingLineNumber,
\substr($offendingLine, 0, 200),
));
}
}
53 changes: 53 additions & 0 deletions testing/src/Transcript/TranscriptActivityInterceptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Temporal\Testing\Transcript;

use Temporal\Activity;
use Temporal\Interceptor\ActivityInbound\ActivityInput;
use Temporal\Interceptor\ActivityInboundInterceptor;
use Temporal\Interceptor\Trait\ActivityInboundInterceptorTrait;
use Temporal\Testing\Transcript\TranscriptWriter;

final class TranscriptActivityInterceptor implements ActivityInboundInterceptor
{
use ActivityInboundInterceptorTrait;

public function __construct(
private readonly TranscriptWriter $transcript,
) {}

public function handleActivityInbound(ActivityInput $input, callable $next): mixed
{
$attributes = $this->buildAttributes();
$this->transcript->writeMeta('activity_start', $attributes);
try {
$result = $next($input);
$this->transcript->writeMeta('activity_completed', $attributes);
return $result;
} catch (\Throwable $exception) {
$this->transcript->writeException('activity_throw', $attributes, $exception);
throw $exception;
}
}

/**
* @return array<string, scalar|null>
*/
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];
}
}
}
30 changes: 30 additions & 0 deletions testing/src/Transcript/TranscriptAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Temporal\Testing\Transcript;

use Psr\Log\LoggerInterface;
use Psr\Log\LoggerTrait;

final class TranscriptAdapter implements LoggerInterface
{
use LoggerTrait;

public function __construct(
private readonly TranscriptWriter $writer,
private readonly LoggerInterface $stderr,
) {
}

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(),
]);
}
}
}
32 changes: 32 additions & 0 deletions testing/src/Transcript/TranscriptLine.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Temporal\Testing\Transcript;

final class TranscriptLine
{
/**
* @param array<string, scalar|null> $attributes
* @param array<string, mixed>|null $payload
*/
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;
}

public function hasAttribute(string $key): bool
{
return \array_key_exists($key, $this->attributes);
}
}
102 changes: 102 additions & 0 deletions testing/src/Transcript/TranscriptPaths.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Temporal\Testing\Transcript;

/**
* Single owner of transcript path/id grammar:
* - run id format and sanitization
* - per-process writer filename layout
* - rotation suffix
* - merged-directory + merged-file conventions
* - the reserved underscore-prefix used to mark control directories
*/
final class TranscriptPaths
{
private const SLUG_PATTERN = '~[^A-Za-z0-9_-]~';
private const SLUG_REPLACEMENT = '_';
private const RUN_ID_MAX_LENGTH = 64;
private const PROCESS_LABEL_MAX_LENGTH = 40;
private const LOG_EXTENSION = '.log';
private const MERGED_DIRECTORY = '_merged';
private const MERGED_FILENAME = 'transcript.log';
private const RESERVED_PREFIX = '_';

public static function generateRunId(): string
{
return \date('Ymd-His') . '-' . \bin2hex(\random_bytes(2));
}

public static function sanitizeRunId(string $runId): string
{
$slug = self::sanitizeSegment($runId);
if ($slug === '') {
throw new \InvalidArgumentException(
'Run id sanitizes to an empty string: ' . \var_export($runId, true),
);
}
if (\str_starts_with($slug, self::RESERVED_PREFIX)) {
$slug = 'r' . $slug;
}
return self::truncate($slug, self::RUN_ID_MAX_LENGTH);
}

public static function sanitizeProcessLabel(string $label): string
{
$slug = self::sanitizeSegment($label);
if ($slug === '') {
$slug = 'process';
}
return self::truncate($slug, self::PROCESS_LABEL_MAX_LENGTH);
}

public static function currentEpochMs(): int
{
return (int) \floor(\microtime(true) * 1000);
}

public static function runDirectory(string $baseDirectory, string $runId): string
{
return $baseDirectory . '/' . self::sanitizeRunId($runId);
}

public static function writerFile(string $runDirectory, string $processLabel): string
{
return $runDirectory
. '/' . self::sanitizeProcessLabel($processLabel)
. '__pid' . (\getmypid() ?: 0)
. '__' . self::currentEpochMs()
. self::LOG_EXTENSION;
}

public static function rotatedFile(string $currentPath, int $rotationCounter): string
{
return $currentPath . '.' . $rotationCounter;
}

public static function mergedDirectory(string $runDirectory): string
{
return $runDirectory . '/' . self::MERGED_DIRECTORY;
}

public static function mergedFile(string $runDirectory): string
{
return self::mergedDirectory($runDirectory) . '/' . self::MERGED_FILENAME;
}

public static function isReservedEntry(string $entry): bool
{
return \str_starts_with($entry, self::RESERVED_PREFIX);
}

private static function sanitizeSegment(string $value): string
{
return \preg_replace(self::SLUG_PATTERN, self::SLUG_REPLACEMENT, $value) ?? '';
}

private static function truncate(string $value, int $max): string
{
return \strlen($value) > $max ? \substr($value, 0, $max) : $value;
}
}
27 changes: 27 additions & 0 deletions testing/src/Transcript/TranscriptPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Temporal\Testing\Transcript;

use Temporal\Plugin\AbstractPlugin;
use Temporal\Plugin\WorkerPluginContext;
use Temporal\Testing\Transcript\TranscriptWriter;

final class TranscriptPlugin extends AbstractPlugin
{
public const NAME = 'temporal-php.transcript';

public function __construct(
private readonly TranscriptWriter $transcript,
) {
parent::__construct(self::NAME);
}

public function configureWorker(WorkerPluginContext $context, callable $next): void
{
$context->addInterceptor(new TranscriptActivityInterceptor($this->transcript));
$context->addInterceptor(new TranscriptWorkflowInterceptor($this->transcript));
$next($context);
}
}
Loading
Loading