trypost/tests/Browser/AltTextTest.php
Paulo Castellano cf045f2cdb Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.

Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.

Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.

Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.

Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 13:55:33 -03:00

243 lines
8.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
/**
* Flag when the composer's debounced autosave PUT finishes so the test can wait
* on the real round-trip instead of a fixed sleep. Inertia v3 issues the update
* over the built-in XHR client, so the completion is observable via loadend.
*/
function trackAutosave(mixed $page): void
{
$page->script(<<<'JS'
(() => {
window.__autosaveDone = false;
const open = XMLHttpRequest.prototype.open;
const send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method) {
this.__method = (method || '').toUpperCase();
return open.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
this.addEventListener('loadend', () => {
if (this.__method === 'PUT') {
window.__autosaveDone = true;
}
});
return send.apply(this, arguments);
};
})();
JS);
}
/**
* Poll on the browser side until the autosave PUT has completed, keeping the
* in-process server pumped while the debounce (1.5s) elapses and the request
* round-trips.
*/
function waitForAutosave(mixed $page): void
{
$page->script(<<<'JS'
(async () => {
for (let attempt = 0; attempt < 100 && ! window.__autosaveDone; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
})();
JS);
}
/**
* Poll on the browser side until the lightbox alt-text caption has mounted and
* laid out. The lightbox is a Radix dialog with an open animation, so the
* element is not present the instant the thumbnail is clicked; the harness
* assertions do not auto-wait, so we wait here first.
*/
function waitForLightboxAltText(mixed $page): void
{
$page->script(<<<'JS'
(async () => {
const visible = () => {
const el = document.querySelector('[data-testid="lightbox-alt-text"]');
if (! el) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
for (let attempt = 0; attempt < 100 && ! visible(); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
})();
JS);
}
/**
* Poll on the browser side until the alt-text Save button reaches the desired
* disabled state. The `:disabled` binding is driven by a Vue computed that
* flushes on nextTick, and these Pest browser assertions do not auto-wait, so
* we settle the reactive state here before asserting.
*/
function waitForSaveButton(mixed $page, bool $disabled): void
{
$want = $disabled ? 'true' : 'false';
$page->script(<<<JS
(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
const el = document.querySelector('[data-testid="alt-text-save"]');
if (el && el.disabled === {$want}) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
})();
JS);
}
test('editing alt text on an attached image persists it to the post media', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'm1',
'type' => 'image',
'mime_type' => 'image/png',
'path' => 'uploads/x.png',
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
trackAutosave($page);
$page->click('@alt-text-button')
->type('@alt-text-input', 'a golden retriever on a beach')
->click('@alt-text-save');
waitForAutosave($page);
expect($post->fresh()->media[0]['meta']['alt_text'])
->toBe('a golden retriever on a beach');
$page->click('@media-thumbnail');
waitForLightboxAltText($page);
$page->assertSeeIn('@lightbox-alt-text', 'a golden retriever on a beach');
});
test('lightbox shows no alt overlay for a non-image even when meta carries alt text', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'v1',
'type' => 'video',
'mime_type' => 'video/mp4',
'path' => 'uploads/clip.mp4',
'url' => 'https://cdn.test/clip.mp4',
'meta' => ['alt_text' => 'alt that must never overlay a video'],
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
$page->click('@media-thumbnail');
$page->script(<<<'JS'
(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
if (document.querySelector('[data-testid="lightbox-video"]')) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
})();
JS);
$page->assertPresent('@lightbox-video')
->assertMissing('@lightbox-alt-text');
});
test('alt text dialog blocks saving when the description exceeds the limit', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'm1',
'type' => 'image',
'mime_type' => 'image/png',
'path' => 'uploads/x.png',
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
$page->click('@alt-text-button')
->fill('@alt-text-input', str_repeat('a', 2001));
waitForSaveButton($page, disabled: true);
$page->assertDisabled('@alt-text-save')
->fill('@alt-text-input', 'a short and valid description');
waitForSaveButton($page, disabled: false);
$page->assertEnabled('@alt-text-save');
$page->fill('@alt-text-input', str_repeat('a', 1999).str_repeat(' ', 100));
waitForSaveButton($page, disabled: false);
$page->assertEnabled('@alt-text-save');
});
test('alt text dialog counts emoji by code point, not UTF-16 units', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'm1',
'type' => 'image',
'mime_type' => 'image/png',
'path' => 'uploads/x.png',
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
$page->click('@alt-text-button')
->fill('@alt-text-input', str_repeat('😀', 1500));
waitForSaveButton($page, disabled: false);
$page->assertEnabled('@alt-text-save');
});