From f74053b118e0f91bb368c751a8131923239ef876 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 11 Jul 2026 10:34:57 -0300 Subject: [PATCH] Extract alt-text truncation onto MediaItem and close test gaps Centralize the per-platform mb_substr truncation that every publisher was repeating into MediaItem::altTextFor(Platform), delete each publisher's private altFor() helper, and clarify the Platform::altTextMaxLength() docblock so it doesn't imply Instagram's documented 1000-char cap is a guess. Add the assertions review flagged as missing: LinkedInPage/ InstagramFacebook alt-text caps, non-string and literal-"0" alt_text normalization, altTextFor() truncation/unsupported-platform behavior, Mastodon's no-description-part case, and the public API's accept/reject path for media.*.meta.alt_text. --- app/DataTransferObjects/MediaItem.php | 18 +++++ app/Enums/SocialAccount/Platform.php | 8 ++- .../Social/AbstractLinkedInPublisher.php | 17 +---- app/Services/Social/BlueskyPublisher.php | 18 +---- .../Social/Discord/DiscordPublisher.php | 4 +- app/Services/Social/FacebookPublisher.php | 15 +---- app/Services/Social/InstagramPublisher.php | 17 +---- app/Services/Social/MastodonPublisher.php | 4 +- app/Services/Social/PinterestPublisher.php | 4 +- app/Services/Social/ThreadsPublisher.php | 16 +---- app/Services/Social/XPublisher.php | 4 +- tests/Feature/Api/PostMediaApiTest.php | 66 +++++++++++++++++++ .../Services/Social/MastodonPublisherTest.php | 65 ++++++++++++++++++ tests/Unit/MediaItemAltTextTest.php | 21 ++++++ tests/Unit/PlatformAltTextTest.php | 4 ++ 15 files changed, 196 insertions(+), 85 deletions(-) diff --git a/app/DataTransferObjects/MediaItem.php b/app/DataTransferObjects/MediaItem.php index a81b0ae7..60718a1d 100644 --- a/app/DataTransferObjects/MediaItem.php +++ b/app/DataTransferObjects/MediaItem.php @@ -6,6 +6,7 @@ use App\Enums\Media\Source; use App\Enums\Media\Type; +use App\Enums\SocialAccount\Platform; class MediaItem { @@ -75,6 +76,23 @@ public function altText(): ?string return $alt === '' ? null : $alt; } + /** + * The alt text truncated to the given platform's cap, or null when no alt + * text is set or the platform doesn't support it. Single place that applies + * the per-platform cap so publishers don't each repeat the truncation. + */ + public function altTextFor(Platform $platform): ?string + { + $alt = $this->altText(); + $max = $platform->altTextMaxLength(); + + if ($alt === null || $max === null) { + return null; + } + + return mb_substr($alt, 0, $max); + } + /** * @param array $data */ diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 73629c8f..64db3849 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -129,9 +129,11 @@ public function maxImages(): int /** * Character cap the platform's API accepts for image alt text (accessibility - * description), or null when the platform has no alt-text field. Facebook and - * Threads document no limit, so a defensive 1000 is used. Single source of - * truth — publishers truncate to this value, never a literal. + * description), or null when the platform has no alt-text field. X, LinkedIn, + * Instagram, Pinterest, and Discord use documented API maxes. Facebook, + * Threads, Mastodon, and Bluesky document no limit, so a defensive cap is + * used instead. Single source of truth — publishers truncate to this value, + * never a literal. */ public function altTextMaxLength(): ?int { diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 6922e876..4fba3190 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -4,7 +4,6 @@ namespace App\Services\Social; -use App\DataTransferObjects\MediaItem; use App\Enums\Media\Type as MediaType; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; @@ -127,7 +126,7 @@ private function publishPost(?string $content, $media): array if ($mediaUrn) { $payload['content'] = ['media' => array_filter([ 'id' => $mediaUrn, - 'altText' => $this->altFor($media->first()), + 'altText' => $media->first()->altTextFor($this->platform()), ], fn ($v) => $v !== null)]; } } @@ -149,7 +148,7 @@ private function publishCarousel(?string $content, $media): array if ($imageUrn) { $images[] = array_filter([ 'id' => $imageUrn, - 'altText' => $this->altFor($item), + 'altText' => $item->altTextFor($this->platform()), ], fn ($v) => $v !== null); } } @@ -247,18 +246,6 @@ private function resolveDocumentTitle(PostPlatform $postPlatform): string return $postPlatform->post->mediaItems->first(fn ($media) => $media->isDocument())?->original_filename ?? 'Document'; } - /** - * The accessibility description to send for an uploaded image, capped to - * what the platform accepts. Null when the user hasn't set one, so the - * caller can omit the `altText` key entirely rather than sending it empty. - */ - private function altFor(MediaItem $media): ?string - { - $alt = $media->altText(); - - return $alt === null ? null : mb_substr($alt, 0, $this->platform()->altTextMaxLength()); - } - private function uploadMedia($mediaItem): ?string { return match (true) { diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index ae418c3e..f45f541c 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -4,7 +4,6 @@ namespace App\Services\Social; -use App\DataTransferObjects\MediaItem; use App\Enums\Media\Type as MediaType; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\BlueskyPublishException; @@ -64,7 +63,7 @@ public function publish(PostPlatform $postPlatform): array $blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type); if ($blob) { $images[] = [ - 'alt' => $this->altFor($media), + 'alt' => $media->altTextFor(Platform::Bluesky) ?? '', 'image' => $blob, ]; } @@ -702,21 +701,6 @@ private function buildPostUrl(string $handle, string $postId): string return "{$webApp}/profile/{$handle}/post/{$postId}"; } - /** - * Bluesky requires `alt` on every image, so this returns '' (never null) - * when the user set no description, and the capped description otherwise. - */ - private function altFor(MediaItem $media): string - { - $alt = $media->altText(); - - if ($alt === null) { - return ''; - } - - return mb_substr($alt, 0, Platform::Bluesky->altTextMaxLength()); - } - private function handleApiError(Response $response): never { throw BlueskyPublishException::fromApiResponse($response); diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php index f4d88a60..3cbbb4c4 100644 --- a/app/Services/Social/Discord/DiscordPublisher.php +++ b/app/Services/Social/Discord/DiscordPublisher.php @@ -129,10 +129,10 @@ private function sendWithMedia(string $channelId, array $payload, Collection $me $attachment = ['id' => $index, 'filename' => $filename]; - $alt = $item->altText(); + $alt = $item->altTextFor(Platform::Discord); if ($alt !== null) { - $attachment['description'] = mb_substr($alt, 0, Platform::Discord->altTextMaxLength()); + $attachment['description'] = $alt; } $attachments[] = $attachment; diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 6386bb1b..64a7e1a2 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -4,7 +4,6 @@ namespace App\Services\Social; -use App\DataTransferObjects\MediaItem; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; @@ -136,7 +135,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st $payload['message'] = $content; } - $alt = $this->altFor($media); + $alt = $media->altTextFor(Platform::Facebook); if ($alt !== null) { $payload['alt_text_custom'] = $alt; @@ -177,7 +176,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str 'access_token' => $accessToken, ]; - $alt = $this->altFor($media); + $alt = $media->altTextFor(Platform::Facebook); if ($alt !== null) { $uploadPayload['alt_text_custom'] = $alt; @@ -428,16 +427,6 @@ private function handleApiError(Response $response): never throw FacebookPublishException::fromApiResponse($response); } - /** - * User-provided alt text for the image, capped to Facebook's accepted length. - */ - private function altFor(MediaItem $media): ?string - { - $alt = $media->altText(); - - return $alt === null ? null : mb_substr($alt, 0, Platform::Facebook->altTextMaxLength()); - } - protected function cropFailureException(string $message): SocialPublishException { return new FacebookPublishException( diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 2fe669f6..79aafc4e 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -4,7 +4,6 @@ namespace App\Services\Social; -use App\DataTransferObjects\MediaItem; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; @@ -89,7 +88,7 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s 'access_token' => $accessToken, ]; - $alt = $this->altFor($media); + $alt = $media->altTextFor(Platform::Instagram); if ($alt !== null) { $params['alt_text'] = $alt; @@ -217,7 +216,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri } else { $params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio); - $alt = $this->altFor($media); + $alt = $media->altTextFor(Platform::Instagram); if ($alt !== null) { $params['alt_text'] = $alt; @@ -371,16 +370,4 @@ private function handleApiError(Response $response): never { throw InstagramPublishException::fromApiResponse($response); } - - /** - * User-provided alt text for the image, capped to Instagram's accepted - * length. Instagram only accepts `alt_text` on image containers/children, - * never on video/reel containers. - */ - private function altFor(MediaItem $media): ?string - { - $alt = $media->altText(); - - return $alt === null ? null : mb_substr($alt, 0, Platform::Instagram->altTextMaxLength()); - } } diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index 8edc58b7..ec5799df 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array // Upload media first (max 4) foreach ($medias->take(4) as $media) { - $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->altText()); + $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->altTextFor(Platform::Mastodon)); if ($mediaId) { $mediaIds[] = $mediaId; } @@ -105,7 +105,7 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u ->attach('file', $stream, $name); if ($altText !== null) { - $request = $request->attach('description', mb_substr($altText, 0, Platform::Mastodon->altTextMaxLength())); + $request = $request->attach('description', $altText); } $response = $request->post("{$instance}/api/v1/media"); diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 8e7b087f..2d97128d 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -119,10 +119,10 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content): $payload['link'] = data_get($postPlatform->meta, 'link'); } - $alt = $postPlatform->post->mediaItems->first(fn ($m) => $m->isImage())?->altText(); + $alt = $postPlatform->post->mediaItems->first(fn ($m) => $m->isImage())?->altTextFor(Platform::Pinterest); if ($alt !== null) { - $payload['alt_text'] = mb_substr($alt, 0, Platform::Pinterest->altTextMaxLength()); + $payload['alt_text'] = $alt; } $response = $this->socialHttp()->withToken($account->access_token) diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 89f87cfc..593567ec 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -4,7 +4,6 @@ namespace App\Services\Social; -use App\DataTransferObjects\MediaItem; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\ThreadsPublishException; @@ -109,7 +108,7 @@ private function publishImagePost(string $userId, string $accessToken, ?string $ 'access_token' => $accessToken, ]; - $alt = $this->altFor($media); + $alt = $media->altTextFor(Platform::Threads); if ($alt !== null) { $params['alt_text'] = $alt; @@ -195,7 +194,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c $params['media_type'] = 'IMAGE'; $params['image_url'] = $media->url; - $alt = $this->altFor($media); + $alt = $media->altTextFor(Platform::Threads); if ($alt !== null) { $params['alt_text'] = $alt; @@ -347,15 +346,4 @@ private function handleApiError(Response $response): never { throw ThreadsPublishException::fromApiResponse($response); } - - /** - * User-provided alt text for the image, capped to Threads' accepted - * length. Scoped to image containers/children only. - */ - private function altFor(MediaItem $media): ?string - { - $alt = $media->altText(); - - return $alt === null ? null : mb_substr($alt, 0, Platform::Threads->altTextMaxLength()); - } } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 5b794ac5..61b7e3bd 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -114,7 +114,7 @@ private function getHttpClient(): PendingRequest */ private function uploadAltText(string $mediaId, MediaItem $mediaItem): void { - $alt = $mediaItem->altText(); + $alt = $mediaItem->altTextFor(Platform::X); if ($alt === null) { return; @@ -124,7 +124,7 @@ private function uploadAltText(string $mediaId, MediaItem $mediaItem): void 'id' => $mediaId, 'metadata' => [ 'alt_text' => [ - 'text' => mb_substr($alt, 0, Platform::X->altTextMaxLength()), + 'text' => $alt, ], ], ]); diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index daa7c3eb..4bc81781 100644 --- a/tests/Feature/Api/PostMediaApiTest.php +++ b/tests/Feature/Api/PostMediaApiTest.php @@ -446,3 +446,69 @@ expect($this->post->fresh()->media)->toBe($original); }); + +it('accepts and persists media alt text on create', function () { + $this->socialAccount->update(['is_active' => true]); + Http::preventStrayRequests(); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Alt text post', + 'media' => [[ + 'id' => 'media-1', + 'path' => 'assets/foo.jpg', + 'url' => 'https://cdn.trypost.test/assets/foo.jpg', + 'type' => 'image', + 'meta' => ['alt_text' => 'A description of the photo'], + ]], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertCreated(); + + $media = Post::where('content', 'Alt text post')->firstOrFail()->media; + + expect(data_get($media, '0.meta.alt_text'))->toBe('A description of the photo'); +}); + +it('accepts and persists media alt text on update', function () { + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $this->post), [ + 'status' => 'draft', + 'media' => [[ + 'id' => 'media-1', + 'path' => 'assets/foo.jpg', + 'url' => 'https://cdn.trypost.test/assets/foo.jpg', + 'type' => 'image', + 'meta' => ['alt_text' => 'Updated alt text'], + ]], + ]) + ->assertOk(); + + expect(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBe('Updated alt text'); +}); + +it('rejects media alt text over 2000 characters', function () { + $this->socialAccount->update(['is_active' => true]); + Http::preventStrayRequests(); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Alt text too long post', + 'media' => [[ + 'id' => 'media-1', + 'path' => 'assets/foo.jpg', + 'url' => 'https://cdn.trypost.test/assets/foo.jpg', + 'type' => 'image', + 'meta' => ['alt_text' => str_repeat('a', 2001)], + ]], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media.0.meta.alt_text']); + + expect(Post::where('content', 'Alt text too long post')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Services/Social/MastodonPublisherTest.php b/tests/Feature/Services/Social/MastodonPublisherTest.php index aee4120c..5ac8087c 100644 --- a/tests/Feature/Services/Social/MastodonPublisherTest.php +++ b/tests/Feature/Services/Social/MastodonPublisherTest.php @@ -196,6 +196,71 @@ @unlink($optimizedFile); }); +test('mastodon publisher sends no description part when image has no alt text', function () { + // Minimal 1x1 JPEG bytes so mime_content_type() detects image/jpeg + $minimalJpeg = "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + ."\xFF\xDB\x00\x43\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\x09\x09\x08\x0A\x0C" + ."\x14\x0D\x0C\x0B\x0B\x0C\x19\x12\x13\x0F\x14\x1D\x1A\x1F\x1E\x1D\x1A\x1C\x1C\x20" + ."\xFF\xC0\x00\x0B\x08\x00\x01\x00\x01\x01\x01\x11\x00\xFF\xC4\x00\x1F\x00\x00\x01" + ."\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05" + ."\xFF\xDA\x00\x08\x01\x01\x00\x00\x3F\x00\xFB\xD3\xFF\xD9"; + + $optimizedFile = tempnam(sys_get_temp_dir(), 'masto_no_alt_opt_'); + file_put_contents($optimizedFile, str_repeat('x', 1024)); + + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/test-image.jpg', + 'url' => 'https://example.com/media/2026-01/test-image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'test.jpg', + ], + ], + ]); + + $this->mock(MediaOptimizer::class) + ->shouldReceive('optimizeImage') + ->once() + ->with(Mockery::any(), Platform::Mastodon) + ->andReturn($optimizedFile); + + Http::fake(function ($request) use ($minimalJpeg) { + $url = $request->url(); + + if (str_contains($url, '/api/v1/media')) { + return Http::response([ + 'id' => 'media-no-alt-123', + 'type' => 'image', + 'url' => 'https://mastodon.social/media/image.jpg', + ], 200); + } + + if (str_contains($url, '/api/v1/statuses')) { + return Http::response([ + 'id' => '109876543210', + 'url' => 'https://mastodon.social/@testuser/109876543210', + ], 200); + } + + // Media download: return valid JPEG so mime_content_type() detects image/jpeg + return Http::response($minimalJpeg, 200, ['Content-Type' => 'image/jpeg']); + }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/api/v1/media')) { + return false; + } + + return collect($request->data())->firstWhere('name', 'description') === null; + }); + + @unlink($optimizedFile); +}); + test('mastodon publisher includes media ids in post', function () { Http::fake([ 'https://mastodon.social/api/v1/statuses' => Http::response([ diff --git a/tests/Unit/MediaItemAltTextTest.php b/tests/Unit/MediaItemAltTextTest.php index 599db824..d9e20cc5 100644 --- a/tests/Unit/MediaItemAltTextTest.php +++ b/tests/Unit/MediaItemAltTextTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\DataTransferObjects\MediaItem; +use App\Enums\SocialAccount\Platform; test('altText returns the trimmed meta alt_text', function () { $item = MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => ' a cat ']]); @@ -14,3 +15,23 @@ expect(MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u'])->altText())->toBeNull() ->and(MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => ' ']])->altText())->toBeNull(); }); + +test('altText is null when meta alt_text is not a string', function () { + expect(MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => 123]])->altText())->toBeNull() + ->and(MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => ['x']]])->altText())->toBeNull(); +}); + +test('altText keeps the literal string "0"', function () { + $item = MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => '0']]); + + expect($item->altText())->toBe('0'); +}); + +test('altTextFor truncates to the platform cap and is null for an unsupported platform', function () { + $longAlt = str_repeat('a', Platform::X->altTextMaxLength() + 50); + $item = MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => $longAlt]]); + + expect($item->altTextFor(Platform::X))->toBe(mb_substr($longAlt, 0, Platform::X->altTextMaxLength())) + ->and(mb_strlen($item->altTextFor(Platform::X)))->toBe(Platform::X->altTextMaxLength()) + ->and($item->altTextFor(Platform::TikTok))->toBeNull(); +}); diff --git a/tests/Unit/PlatformAltTextTest.php b/tests/Unit/PlatformAltTextTest.php index c5777903..8a060273 100644 --- a/tests/Unit/PlatformAltTextTest.php +++ b/tests/Unit/PlatformAltTextTest.php @@ -9,7 +9,9 @@ ->and(Platform::X->altTextMaxLength())->toBe(1000) ->and(Platform::Mastodon->altTextMaxLength())->toBe(1500) ->and(Platform::LinkedIn->altTextMaxLength())->toBe(4086) + ->and(Platform::LinkedInPage->altTextMaxLength())->toBe(4086) ->and(Platform::Instagram->altTextMaxLength())->toBe(1000) + ->and(Platform::InstagramFacebook->altTextMaxLength())->toBe(1000) ->and(Platform::Pinterest->altTextMaxLength())->toBe(500) ->and(Platform::Discord->altTextMaxLength())->toBe(1024) ->and(Platform::Facebook->altTextMaxLength())->toBe(1000) @@ -24,5 +26,7 @@ test('supportsAltText mirrors altTextMaxLength', function () { expect(Platform::Bluesky->supportsAltText())->toBeTrue() + ->and(Platform::LinkedInPage->supportsAltText())->toBeTrue() + ->and(Platform::InstagramFacebook->supportsAltText())->toBeTrue() ->and(Platform::TikTok->supportsAltText())->toBeFalse(); });