From bc92c1a11d35fdecd6f91e65cf7d4ca7de8b32e0 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Mon, 17 Aug 2026 16:48:42 -0600 Subject: [PATCH 1/3] fix(scripts): surface structured executor errors --- ProcessMaker/Jobs/ErrorHandling.php | 61 ++++++++++++++++++++++++++--- tests/unit/ErrorHandlingTest.php | 52 ++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/ProcessMaker/Jobs/ErrorHandling.php b/ProcessMaker/Jobs/ErrorHandling.php index 63c3161040..1fb6da6332 100644 --- a/ProcessMaker/Jobs/ErrorHandling.php +++ b/ProcessMaker/Jobs/ErrorHandling.php @@ -232,7 +232,11 @@ public static function convertResponseToException($result) private static function extractScriptErrorMessage(array $result): string { $candidates = [ + $result['error_message'] ?? null, + $result['output']['error_message'] ?? null, + $result['error'] ?? null, $result['output']['error'] ?? null, + $result['exception'] ?? null, $result['output']['exception'] ?? null, $result['output']['stderr'] ?? null, $result['output']['stdout'] ?? null, @@ -240,11 +244,36 @@ private static function extractScriptErrorMessage(array $result): string ]; foreach ($candidates as $candidate) { - if (is_string($candidate) || is_numeric($candidate)) { - $short = self::shortenMessage((string) $candidate); - if (!empty($short)) { - return $short; - } + $message = self::extractMessageCandidate($candidate); + if (!empty($message)) { + return $message; + } + } + + return ''; + } + + /** + * Extract a message only from known human-readable fields. + */ + private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string + { + if (is_string($candidate) || is_numeric($candidate)) { + return self::shortenMessage((string) $candidate); + } + + if (!is_array($candidate) || $depth >= 3) { + return ''; + } + + foreach (['error_message', 'message', 'detail', 'error', 'exception'] as $key) { + if (!array_key_exists($key, $candidate)) { + continue; + } + + $message = self::extractMessageCandidate($candidate[$key], $depth + 1); + if (!empty($message)) { + return $message; } } @@ -260,6 +289,28 @@ private static function shortenMessage(string $message): string $firstLine = $firstLine === false ? $message : $firstLine; $trimmed = trim($firstLine); + $trimmed = preg_replace( + '/^(?:PHP\s+)?(?:Fatal error:\s*)?(?:Uncaught\s+)?(?:[\\w\\\\]*(?:Exception|Error)):\s*/i', + '', + $trimmed + ) ?? $trimmed; + $trimmed = preg_replace( + '/\s+in\s+(?:\/|[A-Za-z]:\\\\).*(?:\s+on\s+line\s+\d+|:\d+)\s*$/i', + '', + $trimmed + ) ?? $trimmed; + $trimmed = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $trimmed) ?? $trimmed; + $trimmed = preg_replace( + '/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password|authorization)\b\s*[:=]\s*[^\s,;]+/i', + '$1=[REDACTED]', + $trimmed + ) ?? $trimmed; + $trimmed = preg_replace( + '/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/', + '[REDACTED]', + $trimmed + ) ?? $trimmed; + if (strlen($trimmed) > 400) { return substr($trimmed, 0, 400) . '…'; } diff --git a/tests/unit/ErrorHandlingTest.php b/tests/unit/ErrorHandlingTest.php index f6be0bfe7d..2e1297e1f4 100644 --- a/tests/unit/ErrorHandlingTest.php +++ b/tests/unit/ErrorHandlingTest.php @@ -66,4 +66,56 @@ public function testFallsBackToRawMessageWhenNoOutputPresent(): void ErrorHandling::convertResponseToException($result); } + + public function testUsesTopLevelErrorMessageBeforeStructuredMicroserviceError(): void + { + $result = [ + 'status' => 'error', + 'error_message' => 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif', + 'error' => [ + 'code' => 'RuntimeException', + 'file' => '/opt/executor/script.php', + 'line' => 42, + 'trace' => 'sensitive stack trace', + ], + ]; + + $this->expectException(ScriptException::class); + $this->expectExceptionMessage( + 'Failed to apply Smart Extract model: Unsupported image type for PDF conversion: image/gif' + ); + + ErrorHandling::convertResponseToException($result); + } + + public function testExtractsMessageFromStructuredMicroserviceError(): void + { + $result = [ + 'status' => 'error', + 'error' => [ + 'detail' => 'Unsupported image type for PDF conversion: image/gif', + 'trace' => 'stack trace must not be used', + ], + ]; + + $this->expectException(ScriptException::class); + $this->expectExceptionMessage('Unsupported image type for PDF conversion: image/gif'); + + ErrorHandling::convertResponseToException($result); + } + + public function testSanitizesMicroserviceErrorMessage(): void + { + $result = [ + 'status' => 'error', + 'error_message' => "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: Bearer secret-token in /opt/executor/script.php:42\nStack trace:\n#0 {main}", + ]; + + $this->expectException(ScriptException::class); + $this->expectExceptionMessage( + 'Failed to apply Smart Extract model: Bearer [REDACTED]' + ); + + ErrorHandling::convertResponseToException($result); + } } From a427fbbd85d78c6f23767d8e6194296c6a820f3f Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 18 Aug 2026 09:05:36 -0600 Subject: [PATCH 2/3] fix(smart-extract): hide custom executor stack traces --- ProcessMaker/Jobs/ErrorHandling.php | 42 ++++++---- ProcessMaker/Jobs/RunServiceTask.php | 35 ++++++-- .../Services/SmartExtractConfiguration.php | 2 + tests/unit/ErrorHandlingTest.php | 80 ++++++++++++++++++- 4 files changed, 138 insertions(+), 21 deletions(-) diff --git a/ProcessMaker/Jobs/ErrorHandling.php b/ProcessMaker/Jobs/ErrorHandling.php index 1fb6da6332..31fd41f6e9 100644 --- a/ProcessMaker/Jobs/ErrorHandling.php +++ b/ProcessMaker/Jobs/ErrorHandling.php @@ -259,7 +259,7 @@ private static function extractScriptErrorMessage(array $result): string private static function extractMessageCandidate(mixed $candidate, int $depth = 0): string { if (is_string($candidate) || is_numeric($candidate)) { - return self::shortenMessage((string) $candidate); + return self::sanitizeScriptErrorMessage((string) $candidate); } if (!is_array($candidate) || $depth >= 3) { @@ -283,7 +283,7 @@ private static function extractMessageCandidate(mixed $candidate, int $depth = 0 /** * Keep only the first line of the error and limit its length to avoid noisy traces. */ - private static function shortenMessage(string $message): string + public static function sanitizeScriptErrorMessage(string $message): string { $firstLine = strtok($message, "\n"); $firstLine = $firstLine === false ? $message : $firstLine; @@ -299,22 +299,36 @@ private static function shortenMessage(string $message): string '', $trimmed ) ?? $trimmed; - $trimmed = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $trimmed) ?? $trimmed; - $trimmed = preg_replace( - '/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password|authorization)\b\s*[:=]\s*[^\s,;]+/i', - '$1=[REDACTED]', - $trimmed - ) ?? $trimmed; - $trimmed = preg_replace( - '/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/', - '[REDACTED]', - $trimmed - ) ?? $trimmed; + $trimmed = self::redactScriptErrorDetails($trimmed); if (strlen($trimmed) > 400) { - return substr($trimmed, 0, 400) . '…'; + return mb_strcut($trimmed, 0, 400, 'UTF-8') . '…'; } return $trimmed; } + + /** + * Redact credentials while preserving multiline diagnostics for application logs. + */ + public static function redactScriptErrorDetails(string $details): string + { + $redacted = preg_replace( + '/\bauthorization\b\s*[:=]\s*[^\r\n]*/i', + 'Authorization=[REDACTED]', + $details + ) ?? $details; + $redacted = preg_replace('/\bBearer\s+\S+/i', 'Bearer [REDACTED]', $redacted) ?? $redacted; + $redacted = preg_replace( + '/\b(api[_-]?token|access[_-]?token|client[_-]?secret|password)\b\s*[:=]\s*[^\s,;]+/i', + '$1=[REDACTED]', + $redacted + ) ?? $redacted; + + return preg_replace( + '/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/', + '[REDACTED]', + $redacted + ) ?? $redacted; + } } diff --git a/ProcessMaker/Jobs/RunServiceTask.php b/ProcessMaker/Jobs/RunServiceTask.php index cfd4cf9729..6dbffd61d8 100644 --- a/ProcessMaker/Jobs/RunServiceTask.php +++ b/ProcessMaker/Jobs/RunServiceTask.php @@ -15,6 +15,7 @@ use ProcessMaker\Models\Script; use ProcessMaker\Nayra\Contracts\Bpmn\ServiceTaskInterface; use ProcessMaker\Repositories\DefinitionsRepository; +use ProcessMaker\Services\SmartExtractConfiguration; use Throwable; class RunServiceTask extends BpmnAction implements ShouldQueue @@ -112,11 +113,12 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $ $this->unlock(); $this->updateData(['output' => $exception->getMessageForData($token)]); } catch (Throwable $exception) { + $handledException = $this->prepareExceptionForHandling($implementation, $exception); $finalAttempt = true; if ($errorHandling) { - [$message, $finalAttempt] = $errorHandling->handleRetries($this, $exception); + [$message, $finalAttempt] = $errorHandling->handleRetries($this, $handledException); } else { - $message = $exception->getMessage(); + $message = $handledException->getMessage(); } if ($finalAttempt) { @@ -128,18 +130,39 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $ $error->setName($message); $token->setProperty('error', $error); - if ($message !== $exception->getMessage()) { - $modifiedException = new Exception($message, $exception->getCode(), $exception); + if ($message !== $handledException->getMessage()) { + $modifiedException = new Exception($message, $handledException->getCode(), $handledException); } else { - $modifiedException = $exception; + $modifiedException = $handledException; } $token->logError($modifiedException, $element); Log::error('Service task failed: ' . $implementation . ' - ' . $message); - Log::debug($exception->getTraceAsString()); + Log::debug($handledException->getTraceAsString()); } } + /** + * Hide executor diagnostics from Smart Extract request errors while keeping them in logs. + */ + protected function prepareExceptionForHandling(string $implementation, Throwable $exception): Throwable + { + if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) { + return $exception; + } + + Log::error('Smart Extract document-send executor failed', [ + 'message' => ErrorHandling::redactScriptErrorDetails($exception->getMessage()), + 'trace' => ErrorHandling::redactScriptErrorDetails($exception->getTraceAsString()), + ]); + + return new ScriptException( + ErrorHandling::sanitizeScriptErrorMessage($exception->getMessage()), + $exception->getCode(), + $exception + ); + } + private function updateData($response) { $this->withUpdatedContext(function ($engine, $instance, $element, $processModel, $token) use ($response) { diff --git a/ProcessMaker/Services/SmartExtractConfiguration.php b/ProcessMaker/Services/SmartExtractConfiguration.php index a55146c56b..2bbce5da3d 100644 --- a/ProcessMaker/Services/SmartExtractConfiguration.php +++ b/ProcessMaker/Services/SmartExtractConfiguration.php @@ -6,6 +6,8 @@ class SmartExtractConfiguration { + public const SEND_DOCUMENT_SCRIPT_KEY = 'package-smart-extract/document-send'; + public const API_HOST = 'SMART_EXTRACT_API_HOST'; public const CLIENT_ID = 'SMART_EXTRACT_CLIENT_ID'; diff --git a/tests/unit/ErrorHandlingTest.php b/tests/unit/ErrorHandlingTest.php index 2e1297e1f4..004152adcb 100644 --- a/tests/unit/ErrorHandlingTest.php +++ b/tests/unit/ErrorHandlingTest.php @@ -2,10 +2,14 @@ namespace Tests\Unit; -use PHPUnit\Framework\TestCase; +use Illuminate\Support\Facades\Log; use ProcessMaker\Exception\ScriptException; use ProcessMaker\Exception\ScriptTimeoutException; use ProcessMaker\Jobs\ErrorHandling; +use ProcessMaker\Jobs\RunServiceTask; +use ProcessMaker\Services\SmartExtractConfiguration; +use ReflectionClass; +use Tests\TestCase; class ErrorHandlingTest extends TestCase { @@ -118,4 +122,78 @@ public function testSanitizesMicroserviceErrorMessage(): void ErrorHandling::convertResponseToException($result); } + + public function testRedactsCompleteAuthorizationValues(): void + { + $this->assertSame( + 'Authorization=[REDACTED]', + ErrorHandling::sanitizeScriptErrorMessage('Authorization: Basic dXNlcjpwYXNz') + ); + $this->assertSame( + 'Authorization=[REDACTED]', + ErrorHandling::sanitizeScriptErrorMessage('authorization=Token abc123') + ); + } + + public function testTruncatesMessagesWithoutBreakingUtf8(): void + { + $message = ErrorHandling::sanitizeScriptErrorMessage(str_repeat('a', 399) . '😀'); + + $this->assertTrue(mb_check_encoding($message, 'UTF-8')); + $this->assertSame(str_repeat('a', 399) . '…', $message); + } + + public function testRedactsMultilineDiagnosticsWithoutRemovingTheStack(): void + { + $diagnostic = "Authorization: Basic dXNlcjpwYXNz\nStack trace:\n#0 Bearer secret-token"; + $redacted = ErrorHandling::redactScriptErrorDetails($diagnostic); + + $this->assertSame( + "Authorization=[REDACTED]\nStack trace:\n#0 Bearer [REDACTED]", + $redacted + ); + } + + public function testOnlySmartExtractDocumentSendIsShortenedBeforeRetryHandling(): void + { + Log::spy(); + $job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor(); + $prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling'); + $exception = new ScriptException( + "PHP Fatal error: Uncaught Exception: Failed to apply Smart Extract model: image/gif " + . "in /opt/executor/script.php:42\nStack trace:\n#0 Authorization: Basic dXNlcjpwYXNz" + ); + + $handled = $prepare->invoke($job, SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, $exception); + $unchanged = $prepare->invoke($job, 'another-package/script', $exception); + $element = new class { + public function getProperty(string $property): ?string + { + return null; + } + }; + $errorHandling = new class($element, null) extends ErrorHandling { + public ?string $notificationMessage = null; + + public function sendExecutionErrorNotification(string $message) + { + $this->notificationMessage = $message; + } + }; + [$retryMessage] = $errorHandling->handleRetries((object) ['attemptNum' => 1], $handled); + + $this->assertInstanceOf(ScriptException::class, $handled); + $this->assertNotSame($exception, $handled); + $this->assertSame('Failed to apply Smart Extract model: image/gif', $handled->getMessage()); + $this->assertSame('Failed to apply Smart Extract model: image/gif', $retryMessage); + $this->assertSame('Failed to apply Smart Extract model: image/gif', $errorHandling->notificationMessage); + $this->assertSame($exception, $unchanged); + + Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context): bool { + return $message === 'Smart Extract document-send executor failed' + && str_contains($context['message'], 'Stack trace:') + && str_contains($context['message'], 'Authorization=[REDACTED]') + && !str_contains($context['message'], 'dXNlcjpwYXNz'); + }); + } } From afa24ff7ed681bb79f96985d96138adbb978276f Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 18 Aug 2026 09:29:22 -0600 Subject: [PATCH 3/3] fix(smart-extract): secure custom executor diagnostics --- ProcessMaker/Jobs/RunServiceTask.php | 2 +- .../Models/ScriptDockerBindingFilesTrait.php | 2 +- ProcessMaker/ScriptRunners/Base.php | 22 ++++++- ProcessMaker/ScriptRunners/ScriptRunner.php | 5 +- tests/unit/ErrorHandlingTest.php | 12 ++++ .../SmartExtractEnvironmentVariablesTest.php | 57 +++++++++++++++++++ 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/ProcessMaker/Jobs/RunServiceTask.php b/ProcessMaker/Jobs/RunServiceTask.php index 6dbffd61d8..b0b349252b 100644 --- a/ProcessMaker/Jobs/RunServiceTask.php +++ b/ProcessMaker/Jobs/RunServiceTask.php @@ -145,7 +145,7 @@ public function action(ProcessRequestToken $token = null, ServiceTaskInterface $ /** * Hide executor diagnostics from Smart Extract request errors while keeping them in logs. */ - protected function prepareExceptionForHandling(string $implementation, Throwable $exception): Throwable + protected function prepareExceptionForHandling(mixed $implementation, Throwable $exception): Throwable { if ($implementation !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) { return $exception; diff --git a/ProcessMaker/Models/ScriptDockerBindingFilesTrait.php b/ProcessMaker/Models/ScriptDockerBindingFilesTrait.php index 549827a1a3..5c4e99c1fb 100644 --- a/ProcessMaker/Models/ScriptDockerBindingFilesTrait.php +++ b/ProcessMaker/Models/ScriptDockerBindingFilesTrait.php @@ -84,7 +84,7 @@ private function runContainer($image, $command, $parameters, $bindings, $timeout . implode("\n", $output) ); } - Log::error('Script threw return code ' . $returnCode . ' Message: ' . implode("\n", $output)); + Log::error($this->dockerFailureLogMessage($returnCode, $output)); $message = implode("\n", $output); $message .= "\n\nProcessMaker Stack:\n"; diff --git a/ProcessMaker/ScriptRunners/Base.php b/ProcessMaker/ScriptRunners/Base.php index 8d6b767053..a42afb93fa 100644 --- a/ProcessMaker/ScriptRunners/Base.php +++ b/ProcessMaker/ScriptRunners/Base.php @@ -49,9 +49,29 @@ abstract public function config($code, array $dockerConfig); */ private $scriptExecutor; - public function __construct(ScriptExecutor $scriptExecutor) + /** + * Key of the script being executed. + */ + private ?string $scriptKey; + + public function __construct(ScriptExecutor $scriptExecutor, ?string $scriptKey = null) { $this->scriptExecutor = $scriptExecutor; + $this->scriptKey = $scriptKey; + } + + /** + * Build the executor-level failure log without exposing Smart Extract diagnostics. + */ + protected function dockerFailureLogMessage($returnCode, array $output): string + { + $message = 'Script threw return code ' . $returnCode; + + if ($this->scriptKey !== SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY) { + $message .= ' Message: ' . implode("\n", $output); + } + + return $message; } /** diff --git a/ProcessMaker/ScriptRunners/ScriptRunner.php b/ProcessMaker/ScriptRunners/ScriptRunner.php index 1516b3d5eb..0393f68c4b 100644 --- a/ProcessMaker/ScriptRunners/ScriptRunner.php +++ b/ProcessMaker/ScriptRunners/ScriptRunner.php @@ -58,7 +58,10 @@ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserv } else { $class = "ProcessMaker\\ScriptRunners\\{$runner}"; - return app()->make($class, ['scriptExecutor' => $executor]); + return app()->make($class, [ + 'scriptExecutor' => $executor, + 'scriptKey' => $this->script->key, + ]); } } else { return new ScriptMicroserviceRunner($this->script); diff --git a/tests/unit/ErrorHandlingTest.php b/tests/unit/ErrorHandlingTest.php index 004152adcb..a639145b82 100644 --- a/tests/unit/ErrorHandlingTest.php +++ b/tests/unit/ErrorHandlingTest.php @@ -196,4 +196,16 @@ public function sendExecutionErrorNotification(string $message) && !str_contains($context['message'], 'dXNlcjpwYXNz'); }); } + + public function testMissingServiceTaskImplementationDoesNotCauseATypeError(): void + { + $job = (new ReflectionClass(RunServiceTask::class))->newInstanceWithoutConstructor(); + $prepare = (new ReflectionClass(RunServiceTask::class))->getMethod('prepareExceptionForHandling'); + $exception = new ScriptException('Service task implementation not defined'); + + $handled = $prepare->invoke($job, null, $exception); + + $this->assertSame($exception, $handled); + $this->assertSame('Service task implementation not defined', $handled->getMessage()); + } } diff --git a/tests/unit/ProcessMaker/ScriptRunners/SmartExtractEnvironmentVariablesTest.php b/tests/unit/ProcessMaker/ScriptRunners/SmartExtractEnvironmentVariablesTest.php index 2cb283786e..1349a02ee4 100644 --- a/tests/unit/ProcessMaker/ScriptRunners/SmartExtractEnvironmentVariablesTest.php +++ b/tests/unit/ProcessMaker/ScriptRunners/SmartExtractEnvironmentVariablesTest.php @@ -3,14 +3,17 @@ namespace Tests\Unit\ProcessMaker\ScriptRunners; use Illuminate\Support\Facades\Cache; +use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Models\EnvironmentVariable; use ProcessMaker\Models\Script; use ProcessMaker\Models\ScriptExecutor; use ProcessMaker\Models\User; use ProcessMaker\ScriptRunners\Base; use ProcessMaker\ScriptRunners\ScriptMicroserviceRunner; +use ProcessMaker\ScriptRunners\ScriptRunner; use ProcessMaker\Services\SmartExtractConfiguration; use ReflectionMethod; +use ReflectionProperty; use Tests\TestCase; class SmartExtractEnvironmentVariablesTest extends TestCase @@ -129,6 +132,52 @@ public function config($code, array $dockerConfig) $this->assertArrayNotHasKey(SmartExtractConfiguration::API_HOST, $microserviceVariables); } + public function test_smart_extract_docker_failure_log_omits_raw_output(): void + { + $executor = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'type' => ScriptExecutorType::Custom, + ]); + $script = Script::factory()->create([ + 'key' => SmartExtractConfiguration::SEND_DOCUMENT_SCRIPT_KEY, + 'language' => 'php', + 'script_executor_id' => $executor->id, + ]); + config([ + 'script-runner-microservice.enabled' => false, + 'script-runners.php.runner' => 'PhpRunner', + ]); + + $scriptRunner = new ScriptRunner($script); + $runnerProperty = new ReflectionProperty(ScriptRunner::class, 'runner'); + $runnerProperty->setAccessible(true); + $runner = $runnerProperty->getValue($scriptRunner); + + $message = $this->dockerFailureLogMessage($runner, [ + 'Authorization: Basic dXNlcjpwYXNz', + 'Bearer secret-token', + ]); + + $this->assertSame('Script threw return code 255', $message); + $this->assertStringNotContainsString('dXNlcjpwYXNz', $message); + $this->assertStringNotContainsString('secret-token', $message); + } + + public function test_other_docker_failure_logs_keep_their_raw_output(): void + { + $executor = ScriptExecutor::factory()->create(['language' => 'php']); + $runner = new class ($executor, 'another-package/script') extends Base { + public function config($code, array $dockerConfig) + { + return $dockerConfig; + } + }; + + $message = $this->dockerFailureLogMessage($runner, ['Existing diagnostic']); + + $this->assertSame('Script threw return code 255 Message: Existing diagnostic', $message); + } + private function createApiHost(): void { EnvironmentVariable::factory()->create([ @@ -136,4 +185,12 @@ private function createApiHost(): void 'value' => 'https://database.example.com', ]); } + + private function dockerFailureLogMessage(Base $runner, array $output): string + { + $method = new ReflectionMethod(Base::class, 'dockerFailureLogMessage'); + $method->setAccessible(true); + + return $method->invoke($runner, 255, $output); + } }