diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 997ecaaa76348..778483073de6b 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -460,6 +460,7 @@ public function setProgress(int $taskId, float $progress): DataResponse { * @param int $taskId The id of the task * @param array|null $output The resulting task output, files are represented by their IDs * @param string|null $errorMessage An error message if the task failed + * @param string|null $userFacingErrorMessage An error message that will be shown to the user * @return DataResponse|DataResponse * * 200: Result updated successfully @@ -467,10 +468,10 @@ public function setProgress(int $taskId, float $progress): DataResponse { */ #[ExAppRequired] #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/result', root: '/taskprocessing')] - public function setResult(int $taskId, ?array $output = null, ?string $errorMessage = null): DataResponse { + public function setResult(int $taskId, ?array $output = null, ?string $errorMessage = null, ?string $userFacingErrorMessage = null): DataResponse { try { // set result - $this->taskProcessingManager->setTaskResult($taskId, $errorMessage, $output, true); + $this->taskProcessingManager->setTaskResult($taskId, $errorMessage, $output, isUsingFileIds: true, userFacingError: $userFacingErrorMessage); $task = $this->taskProcessingManager->getTask($taskId); /** @var CoreTaskProcessingTask $json */ diff --git a/core/Migrations/Version33000Date20251013110519.php b/core/Migrations/Version33000Date20251013110519.php new file mode 100644 index 0000000000000..c43979170eabd --- /dev/null +++ b/core/Migrations/Version33000Date20251013110519.php @@ -0,0 +1,48 @@ +hasTable('taskprocessing_tasks')) { + $table = $schema->getTable('taskprocessing_tasks'); + if (!$table->hasColumn('user_facing_error_message')) { + $table->addColumn('user_facing_error_message', Types::STRING, [ + 'notnull' => false, + 'length' => 4000, + ]); + return $schema; + } + } + + return null; + } +} diff --git a/core/openapi-ex_app.json b/core/openapi-ex_app.json index 569f25830bcf1..5269e76c9f3c2 100644 --- a/core/openapi-ex_app.json +++ b/core/openapi-ex_app.json @@ -960,6 +960,12 @@ "nullable": true, "default": null, "description": "An error message if the task failed" + }, + "userFacingErrorMessage": { + "type": "string", + "nullable": true, + "default": null, + "description": "An error message that will be shown to the user" } } } diff --git a/core/openapi-full.json b/core/openapi-full.json index 622c23265cc1b..f7bb992043f62 100644 --- a/core/openapi-full.json +++ b/core/openapi-full.json @@ -11068,6 +11068,12 @@ "nullable": true, "default": null, "description": "An error message if the task failed" + }, + "userFacingErrorMessage": { + "type": "string", + "nullable": true, + "default": null, + "description": "An error message that will be shown to the user" } } } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index e883f4198330d..d16c6a0951e24 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -868,6 +868,7 @@ 'OCP\\TaskProcessing\\Exception\\PreConditionNotMetException' => $baseDir . '/lib/public/TaskProcessing/Exception/PreConditionNotMetException.php', 'OCP\\TaskProcessing\\Exception\\ProcessingException' => $baseDir . '/lib/public/TaskProcessing/Exception/ProcessingException.php', 'OCP\\TaskProcessing\\Exception\\UnauthorizedException' => $baseDir . '/lib/public/TaskProcessing/Exception/UnauthorizedException.php', + 'OCP\\TaskProcessing\\Exception\\UserFacingProcessingException' => $baseDir . '/lib/public/TaskProcessing/Exception/UserFacingProcessingException.php', 'OCP\\TaskProcessing\\Exception\\ValidationException' => $baseDir . '/lib/public/TaskProcessing/Exception/ValidationException.php', 'OCP\\TaskProcessing\\IInternalTaskType' => $baseDir . '/lib/public/TaskProcessing/IInternalTaskType.php', 'OCP\\TaskProcessing\\IManager' => $baseDir . '/lib/public/TaskProcessing/IManager.php', @@ -1530,6 +1531,7 @@ 'OC\\Core\\Migrations\\Version32000Date20250731062008' => $baseDir . '/core/Migrations/Version32000Date20250731062008.php', 'OC\\Core\\Migrations\\Version32000Date20250806110519' => $baseDir . '/core/Migrations/Version32000Date20250806110519.php', 'OC\\Core\\Migrations\\Version33000Date20250819110529' => $baseDir . '/core/Migrations/Version33000Date20250819110529.php', + 'OC\\Core\\Migrations\\Version33000Date20251013110519' => $baseDir . '/core/Migrations/Version33000Date20251013110519.php', 'OC\\Core\\Migrations\\Version33000Date20251023110529' => $baseDir . '/core/Migrations/Version33000Date20251023110529.php', 'OC\\Core\\Migrations\\Version33000Date20251023120529' => $baseDir . '/core/Migrations/Version33000Date20251023120529.php', 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index a24c51fbf3700..4fd57cdbe78ba 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -21,6 +21,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 array ( 'NCU\\' => 4, ), + 'B' => + array ( + 'Bamarni\\Composer\\Bin\\' => 21, + ), ); public static $prefixDirsPsr4 = array ( @@ -40,6 +44,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 array ( 0 => __DIR__ . '/../../..' . '/lib/unstable', ), + 'Bamarni\\Composer\\Bin\\' => + array ( + 0 => __DIR__ . '/..' . '/bamarni/composer-bin-plugin/src', + ), ); public static $fallbackDirsPsr4 = array ( @@ -909,6 +917,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\TaskProcessing\\Exception\\PreConditionNotMetException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/PreConditionNotMetException.php', 'OCP\\TaskProcessing\\Exception\\ProcessingException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ProcessingException.php', 'OCP\\TaskProcessing\\Exception\\UnauthorizedException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/UnauthorizedException.php', + 'OCP\\TaskProcessing\\Exception\\UserFacingProcessingException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/UserFacingProcessingException.php', 'OCP\\TaskProcessing\\Exception\\ValidationException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ValidationException.php', 'OCP\\TaskProcessing\\IInternalTaskType' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IInternalTaskType.php', 'OCP\\TaskProcessing\\IManager' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IManager.php', @@ -1571,6 +1580,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Migrations\\Version32000Date20250731062008' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250731062008.php', 'OC\\Core\\Migrations\\Version32000Date20250806110519' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250806110519.php', 'OC\\Core\\Migrations\\Version33000Date20250819110529' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20250819110529.php', + 'OC\\Core\\Migrations\\Version33000Date20251013110519' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20251013110519.php', 'OC\\Core\\Migrations\\Version33000Date20251023110529' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20251023110529.php', 'OC\\Core\\Migrations\\Version33000Date20251023120529' => __DIR__ . '/../../..' . '/core/Migrations/Version33000Date20251023120529.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', diff --git a/lib/private/TaskProcessing/Db/Task.php b/lib/private/TaskProcessing/Db/Task.php index 05c0ae9ac742d..74bd154ae7d84 100644 --- a/lib/private/TaskProcessing/Db/Task.php +++ b/lib/private/TaskProcessing/Db/Task.php @@ -47,6 +47,8 @@ * @method int getEndedAt() * @method setAllowCleanup(int $allowCleanup) * @method int getAllowCleanup() + * @method setUserFacingErrorMessage(null|string $message) + * @method null|string getUserFacingErrorMessage() */ class Task extends Entity { protected $lastUpdated; @@ -66,16 +68,17 @@ class Task extends Entity { protected $startedAt; protected $endedAt; protected $allowCleanup; + protected $userFacingErrorMessage; /** * @var string[] */ - public static array $columns = ['id', 'last_updated', 'type', 'input', 'output', 'status', 'user_id', 'app_id', 'custom_id', 'completion_expected_at', 'error_message', 'progress', 'webhook_uri', 'webhook_method', 'scheduled_at', 'started_at', 'ended_at', 'allow_cleanup']; + public static array $columns = ['id', 'last_updated', 'type', 'input', 'output', 'status', 'user_id', 'app_id', 'custom_id', 'completion_expected_at', 'error_message', 'progress', 'webhook_uri', 'webhook_method', 'scheduled_at', 'started_at', 'ended_at', 'allow_cleanup', 'user_facing_error_message']; /** * @var string[] */ - public static array $fields = ['id', 'lastUpdated', 'type', 'input', 'output', 'status', 'userId', 'appId', 'customId', 'completionExpectedAt', 'errorMessage', 'progress', 'webhookUri', 'webhookMethod', 'scheduledAt', 'startedAt', 'endedAt', 'allowCleanup']; + public static array $fields = ['id', 'lastUpdated', 'type', 'input', 'output', 'status', 'userId', 'appId', 'customId', 'completionExpectedAt', 'errorMessage', 'progress', 'webhookUri', 'webhookMethod', 'scheduledAt', 'startedAt', 'endedAt', 'allowCleanup', 'userFacingErrorMessage']; public function __construct() { @@ -98,6 +101,7 @@ public function __construct() { $this->addType('startedAt', 'integer'); $this->addType('endedAt', 'integer'); $this->addType('allowCleanup', 'integer'); + $this->addType('userFacingErrorMessage', 'string'); } public function toRow(): array { @@ -127,6 +131,7 @@ public static function fromPublicTask(OCPTask $task): self { 'startedAt' => $task->getStartedAt(), 'endedAt' => $task->getEndedAt(), 'allowCleanup' => $task->getAllowCleanup() ? 1 : 0, + 'userFacingErrorMessage' => $task->getUserFacingErrorMessage(), ]); return $taskEntity; } @@ -150,6 +155,7 @@ public function toPublicTask(): OCPTask { $task->setStartedAt($this->getStartedAt()); $task->setEndedAt($this->getEndedAt()); $task->setAllowCleanup($this->getAllowCleanup() !== 0); + $task->setUserFacingErrorMessage($this->getUserFacingErrorMessage()); return $task; } } diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 6766e66a6c513..73bf93c593490 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -50,6 +50,7 @@ use OCP\TaskProcessing\Exception\NotFoundException; use OCP\TaskProcessing\Exception\ProcessingException; use OCP\TaskProcessing\Exception\UnauthorizedException; +use OCP\TaskProcessing\Exception\UserFacingProcessingException; use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\IInternalTaskType; use OCP\TaskProcessing\IManager; @@ -211,7 +212,7 @@ public function process(?string $userId, array $input, callable $reportProgress) try { return ['output' => $this->provider->process($input['input'])]; } catch (\RuntimeException $e) { - throw new ProcessingException($e->getMessage(), 0, $e); + throw new ProcessingException($e->getMessage(), previous: $e); } } @@ -362,7 +363,7 @@ public function process(?string $userId, array $input, callable $reportProgress) try { $this->provider->generate($input['input'], $resources); } catch (\RuntimeException $e) { - throw new ProcessingException($e->getMessage(), 0, $e); + throw new ProcessingException($e->getMessage(), previous: $e); } for ($i = 0; $i < $input['numberOfImages']; $i++) { if (is_resource($resources[$i])) { @@ -480,7 +481,7 @@ public function process(?string $userId, array $input, callable $reportProgress) try { $result = $this->provider->transcribeFile($input['input']); } catch (\RuntimeException $e) { - throw new ProcessingException($e->getMessage(), 0, $e); + throw new ProcessingException($e->getMessage(), previous: $e); } return ['output' => $result]; } @@ -1041,7 +1042,8 @@ public function processTask(Task $task, ISynchronousProvider $provider): bool { $output = $provider->process($task->getUserId(), $input, fn (float $progress) => $this->setTaskProgress($task->getId(), $progress)); } catch (ProcessingException $e) { $this->logger->warning('Failed to process a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]); - $this->setTaskResult($task->getId(), $e->getMessage(), null); + $userFacingErrorMessage = $e instanceof UserFacingProcessingException ? $e->getUserFacingMessage() : null; + $this->setTaskResult($task->getId(), $e->getMessage(), null, userFacingError: $userFacingErrorMessage); return false; } catch (\Throwable $e) { $this->logger->error('Unknown error while processing TaskProcessing task', ['exception' => $e]); @@ -1112,7 +1114,7 @@ public function setTaskProgress(int $id, float $progress): bool { return true; } - public function setTaskResult(int $id, ?string $error, ?array $result, bool $isUsingFileIds = false): void { + public function setTaskResult(int $id, ?string $error, ?array $result, bool $isUsingFileIds = false, ?string $userFacingError = null): void { // TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently $task = $this->getTask($id); if ($task->getStatus() === Task::STATUS_CANCELLED) { @@ -1122,8 +1124,12 @@ public function setTaskResult(int $id, ?string $error, ?array $result, bool $isU if ($error !== null) { $task->setStatus(Task::STATUS_FAILED); $task->setEndedAt(time()); - // truncate error message to 1000 characters - $task->setErrorMessage(mb_substr($error, 0, 1000)); + // truncate error message to 4000 characters + $task->setErrorMessage(substr($error, 0, 4000)); + // truncate error message to 4000 characters + if ($userFacingError !== null) { + $task->setUserFacingErrorMessage(substr($userFacingError, 0, 4000)); + } $this->logger->warning('A TaskProcessing ' . $task->getTaskTypeId() . ' task with id ' . $id . ' failed with the following message: ' . $error); } elseif ($result !== null) { $taskTypes = $this->getAvailableTaskTypes(); diff --git a/lib/public/TaskProcessing/Exception/ProcessingException.php b/lib/public/TaskProcessing/Exception/ProcessingException.php index ca69766b11842..3f332e77b9d8c 100644 --- a/lib/public/TaskProcessing/Exception/ProcessingException.php +++ b/lib/public/TaskProcessing/Exception/ProcessingException.php @@ -10,10 +10,14 @@ namespace OCP\TaskProcessing\Exception; +use OCP\AppFramework\Attribute\Consumable; + /** * Exception thrown during processing of a task * by a synchronous provider + * * @since 30.0.0 */ +#[Consumable(since: '30.0.0')] class ProcessingException extends \RuntimeException { } diff --git a/lib/public/TaskProcessing/Exception/UserFacingProcessingException.php b/lib/public/TaskProcessing/Exception/UserFacingProcessingException.php new file mode 100644 index 0000000000000..f2669a30e7175 --- /dev/null +++ b/lib/public/TaskProcessing/Exception/UserFacingProcessingException.php @@ -0,0 +1,55 @@ +userFacingMessage; + } + + /** + * @param null|string $userFacingMessage Must be already translated into the language of the user + * @since 33.0.0 + */ + public function setUserFacingMessage(?string $userFacingMessage): void { + $this->userFacingMessage = $userFacingMessage; + } +} diff --git a/lib/public/TaskProcessing/IManager.php b/lib/public/TaskProcessing/IManager.php index 28c99d5299b3b..878acfc134c0d 100644 --- a/lib/public/TaskProcessing/IManager.php +++ b/lib/public/TaskProcessing/IManager.php @@ -10,6 +10,7 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Consumable; use OCP\Files\File; use OCP\Files\GenericFileException; use OCP\Files\NotPermittedException; @@ -25,6 +26,7 @@ * without known which providers are installed * @since 30.0.0 */ +#[Consumable(since: '30.0.0')] interface IManager { /** @@ -133,11 +135,13 @@ public function cancelTask(int $id): void; * @param string|null $error * @param array|null $result * @param bool $isUsingFileIds + * @param string|null $userFacingError * @throws Exception If the query failed * @throws NotFoundException If the task could not be found * @since 30.0.0 + * @since 33.0.0 Added `userFacingError` parameter */ - public function setTaskResult(int $id, ?string $error, ?array $result, bool $isUsingFileIds = false): void; + public function setTaskResult(int $id, ?string $error, ?array $result, bool $isUsingFileIds = false, ?string $userFacingError = null): void; /** * @param int $id diff --git a/lib/public/TaskProcessing/Task.php b/lib/public/TaskProcessing/Task.php index 06dc84d59ff4a..90b9be4cafd1b 100644 --- a/lib/public/TaskProcessing/Task.php +++ b/lib/public/TaskProcessing/Task.php @@ -31,8 +31,24 @@ final class Task implements \JsonSerializable { protected int $lastUpdated; protected ?string $webhookUri = null; + protected ?string $webhookMethod = null; + /** + * @psalm-var self::STATUS_* + */ + protected int $status = self::STATUS_UNKNOWN; + + protected ?int $scheduledAt = null; + + protected ?int $startedAt = null; + + protected ?int $endedAt = null; + + protected bool $allowCleanup = true; + + protected ?string $userFacingErrorMessage = null; + /** * @since 30.0.0 */ @@ -58,15 +74,6 @@ final class Task implements \JsonSerializable { */ public const STATUS_UNKNOWN = 0; - /** - * @psalm-var self::STATUS_* - */ - protected int $status = self::STATUS_UNKNOWN; - - protected ?int $scheduledAt = null; - protected ?int $startedAt = null; - protected ?int $endedAt = null; - protected bool $allowCleanup = true; /** * @param string $taskTypeId @@ -389,4 +396,18 @@ final public static function statusToString(int $status): string { default => 'STATUS_UNKNOWN', }; } + + /** + * @since 33.0.0 + */ + public function setUserFacingErrorMessage(?string $userFacingErrorMessage): void { + $this->userFacingErrorMessage = $userFacingErrorMessage; + } + + /** + * @since 33.0.0 + */ + public function getUserFacingErrorMessage(): ?string { + return $this->userFacingErrorMessage; + } } diff --git a/openapi.json b/openapi.json index 2025eef047901..e0c9d77d2e883 100644 --- a/openapi.json +++ b/openapi.json @@ -14584,6 +14584,12 @@ "nullable": true, "default": null, "description": "An error message if the task failed" + }, + "userFacingErrorMessage": { + "type": "string", + "nullable": true, + "default": null, + "description": "An error message that will be shown to the user" } } } diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index 30eb33126cf06..72a7f2c62b4b7 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -42,6 +42,7 @@ use OCP\TaskProcessing\Exception\PreConditionNotMetException; use OCP\TaskProcessing\Exception\ProcessingException; use OCP\TaskProcessing\Exception\UnauthorizedException; +use OCP\TaskProcessing\Exception\UserFacingProcessingException; use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\IManager; use OCP\TaskProcessing\IProvider; @@ -261,6 +262,67 @@ public function getOptionalOutputShapeEnumValues(): array { } } + +class FailingSyncProviderWithUserFacingError implements IProvider, ISynchronousProvider { + public const ERROR_MESSAGE = 'Failure'; + public const USER_FACING_ERROR_MESSAGE = 'User-facing Failure'; + public function getId(): string { + return 'test:sync:fail:user-facing'; + } + + public function getName(): string { + return self::class; + } + + public function getTaskTypeId(): string { + return TextToText::ID; + } + + public function getExpectedRuntime(): int { + return 10; + } + + public function getOptionalInputShape(): array { + return [ + 'optionalKey' => new ShapeDescriptor('optional Key', 'AN optional key', EShapeType::Text), + ]; + } + + public function getOptionalOutputShape(): array { + return [ + 'optionalKey' => new ShapeDescriptor('optional Key', 'AN optional key', EShapeType::Text), + ]; + } + + public function process(?string $userId, array $input, callable $reportProgress): array { + throw new UserFacingProcessingException(self::ERROR_MESSAGE, userFacingMessage: self::USER_FACING_ERROR_MESSAGE); + } + + public function getInputShapeEnumValues(): array { + return []; + } + + public function getInputShapeDefaults(): array { + return []; + } + + public function getOptionalInputShapeEnumValues(): array { + return []; + } + + public function getOptionalInputShapeDefaults(): array { + return []; + } + + public function getOutputShapeEnumValues(): array { + return []; + } + + public function getOptionalOutputShapeEnumValues(): array { + return []; + } +} + class BrokenSyncProvider implements IProvider, ISynchronousProvider { public function getId(): string { return 'test:sync:broken-output'; @@ -593,6 +655,7 @@ protected function setUp(): void { $this->providers = [ SuccessfulSyncProvider::class => new SuccessfulSyncProvider(), FailingSyncProvider::class => new FailingSyncProvider(), + FailingSyncProviderWithUserFacingError::class => new FailingSyncProviderWithUserFacingError(), BrokenSyncProvider::class => new BrokenSyncProvider(), AsyncProvider::class => new AsyncProvider(), AudioToImage::class => new AudioToImage(), @@ -773,6 +836,36 @@ public function testProviderShouldBeRegisteredAndFail(): void { self::assertEquals(FailingSyncProvider::ERROR_MESSAGE, $task->getErrorMessage()); } + public function testProviderShouldBeRegisteredAndFailWithUserFacingMessage(): void { + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', FailingSyncProviderWithUserFacingError::class) + ]); + self::assertCount(1, $this->manager->getAvailableTaskTypes()); + self::assertCount(1, $this->manager->getAvailableTaskTypeIds()); + self::assertTrue($this->manager->hasProviders()); + $task = new Task(TextToText::ID, ['input' => 'Hello'], 'test', null); + self::assertNull($task->getId()); + self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); + $this->manager->scheduleTask($task); + self::assertNotNull($task->getId()); + self::assertEquals(Task::STATUS_SCHEDULED, $task->getStatus()); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskFailedEvent::class)); + + $backgroundJob = new SynchronousBackgroundJob( + Server::get(ITimeFactory::class), + $this->manager, + $this->jobList, + Server::get(LoggerInterface::class), + ); + $backgroundJob->start($this->jobList); + + $task = $this->manager->getTask($task->getId()); + self::assertEquals(Task::STATUS_FAILED, $task->getStatus()); + self::assertEquals(FailingSyncProviderWithUserFacingError::ERROR_MESSAGE, $task->getErrorMessage()); + self::assertEquals(FailingSyncProviderWithUserFacingError::USER_FACING_ERROR_MESSAGE, $task->getUserFacingErrorMessage()); + } + public function testProviderShouldBeRegisteredAndFailOutputValidation(): void { $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ new ServiceRegistration('test', BrokenSyncProvider::class)