Address review: cropper mime/zoom/error fixes, harden browser test

- Output a canvas-encodable mime (jpeg/png/webp, else png) and keep the File's
  name/extension in sync, so non-encodable input (gif/svg/heic) no longer ships
  PNG bytes mislabeled as the original type.
- Clamp zoom to a maximum (8x cover) so scrolling in can't collapse the crop to
  a sub-pixel region.
- Handle undecodable/zero-dimension images (@error + naturalWidth guard) with a
  crop_error message instead of a permanently-disabled Save.
- Replace the str_contains(static::class) Vite heuristic with a dedicated
  BrowserTestCase ($fakesVite = false).
- The browser test now decodes the dispatched blob and asserts a 512x512 image,
  and uses route(..., absolute: false) instead of a hardcoded path.
- Remove tests/Browser/ProbeTest.php (committed debug scratch).
This commit is contained in:
Paulo Castellano 2026-07-03 22:07:23 -03:00
parent c6369a6ddb
commit 2549a82e9b
22 changed files with 103 additions and 68 deletions

View file

@ -26,6 +26,7 @@
'crop_hint' => 'اسحب لإعادة التموضع', 'crop_hint' => 'اسحب لإعادة التموضع',
'crop_save' => 'حفظ', 'crop_save' => 'حفظ',
'crop_cancel' => 'إلغاء', 'crop_cancel' => 'إلغاء',
'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Zum Verschieben ziehen', 'crop_hint' => 'Zum Verschieben ziehen',
'crop_save' => 'Speichern', 'crop_save' => 'Speichern',
'crop_cancel' => 'Abbrechen', 'crop_cancel' => 'Abbrechen',
'crop_error' => 'Dieses Bild konnte nicht geladen werden. Versuchen Sie eine andere Datei.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Σύρετε για επανατοποθέτηση', 'crop_hint' => 'Σύρετε για επανατοποθέτηση',
'crop_save' => 'Αποθήκευση', 'crop_save' => 'Αποθήκευση',
'crop_cancel' => 'Άκυρο', 'crop_cancel' => 'Άκυρο',
'crop_error' => 'Δεν ήταν δυνατή η φόρτωση αυτής της εικόνας. Δοκιμάστε άλλο αρχείο.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Drag to reposition', 'crop_hint' => 'Drag to reposition',
'crop_save' => 'Save', 'crop_save' => 'Save',
'crop_cancel' => 'Cancel', 'crop_cancel' => 'Cancel',
'crop_error' => 'Couldn\'t load this image. Try another file.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Arrastra para reposicionar', 'crop_hint' => 'Arrastra para reposicionar',
'crop_save' => 'Guardar', 'crop_save' => 'Guardar',
'crop_cancel' => 'Cancelar', 'crop_cancel' => 'Cancelar',
'crop_error' => 'No se pudo cargar esta imagen. Prueba con otro archivo.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Faites glisser pour repositionner', 'crop_hint' => 'Faites glisser pour repositionner',
'crop_save' => 'Enregistrer', 'crop_save' => 'Enregistrer',
'crop_cancel' => 'Annuler', 'crop_cancel' => 'Annuler',
'crop_error' => 'Impossible de charger cette image. Essayez un autre fichier.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Trascina per riposizionare', 'crop_hint' => 'Trascina per riposizionare',
'crop_save' => 'Salva', 'crop_save' => 'Salva',
'crop_cancel' => 'Annulla', 'crop_cancel' => 'Annulla',
'crop_error' => 'Impossibile caricare questa immagine. Prova un altro file.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'ドラッグして移動', 'crop_hint' => 'ドラッグして移動',
'crop_save' => '保存', 'crop_save' => '保存',
'crop_cancel' => 'キャンセル', 'crop_cancel' => 'キャンセル',
'crop_error' => 'この画像を読み込めませんでした。別のファイルをお試しください。',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => '드래그하여 위치 조정', 'crop_hint' => '드래그하여 위치 조정',
'crop_save' => '저장', 'crop_save' => '저장',
'crop_cancel' => '취소', 'crop_cancel' => '취소',
'crop_error' => '이 이미지를 불러올 수 없습니다. 다른 파일을 사용해 보세요.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Sleep om te verplaatsen', 'crop_hint' => 'Sleep om te verplaatsen',
'crop_save' => 'Opslaan', 'crop_save' => 'Opslaan',
'crop_cancel' => 'Annuleren', 'crop_cancel' => 'Annuleren',
'crop_error' => 'Kan deze afbeelding niet laden. Probeer een ander bestand.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Przeciągnij, aby zmienić położenie', 'crop_hint' => 'Przeciągnij, aby zmienić położenie',
'crop_save' => 'Zapisz', 'crop_save' => 'Zapisz',
'crop_cancel' => 'Anuluj', 'crop_cancel' => 'Anuluj',
'crop_error' => 'Nie można załadować tego obrazu. Spróbuj innego pliku.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Arraste para reposicionar', 'crop_hint' => 'Arraste para reposicionar',
'crop_save' => 'Salvar', 'crop_save' => 'Salvar',
'crop_cancel' => 'Cancelar', 'crop_cancel' => 'Cancelar',
'crop_error' => 'Não foi possível carregar esta imagem. Tente outro arquivo.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Перетащите, чтобы переместить', 'crop_hint' => 'Перетащите, чтобы переместить',
'crop_save' => 'Сохранить', 'crop_save' => 'Сохранить',
'crop_cancel' => 'Отмена', 'crop_cancel' => 'Отмена',
'crop_error' => 'Не удалось загрузить это изображение. Попробуйте другой файл.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => 'Yeniden konumlandırmak için sürükleyin', 'crop_hint' => 'Yeniden konumlandırmak için sürükleyin',
'crop_save' => 'Kaydet', 'crop_save' => 'Kaydet',
'crop_cancel' => 'İptal', 'crop_cancel' => 'İptal',
'crop_error' => 'Bu görsel yüklenemedi. Başka bir dosya deneyin.',
], ],
'timezone' => [ 'timezone' => [

View file

@ -26,6 +26,7 @@
'crop_hint' => '拖动以重新定位', 'crop_hint' => '拖动以重新定位',
'crop_save' => '保存', 'crop_save' => '保存',
'crop_cancel' => '取消', 'crop_cancel' => '取消',
'crop_error' => '无法加载此图片。请尝试其他文件。',
], ],
'timezone' => [ 'timezone' => [

View file

@ -47,15 +47,25 @@ const natural = ref({ width: 0, height: 0 });
const transform = ref<CropTransform>({ scale: 1, x: 0, y: 0 }); const transform = ref<CropTransform>({ scale: 1, x: 0, y: 0 });
const processing = ref(false); const processing = ref(false);
const initialized = ref(false); const initialized = ref(false);
const imageError = ref(false);
let dragPointerId: number | null = null; let dragPointerId: number | null = null;
let dragStart = { pointerX: 0, pointerY: 0, x: 0, y: 0 }; let dragStart = { pointerX: 0, pointerY: 0, x: 0, y: 0 };
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
const encodableMimes = ['image/jpeg', 'image/png', 'image/webp'];
const extensions: Record<string, string> = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };
const ready = computed(() => viewportSize.value > 0 && natural.value.width > 0); const ready = computed(() => viewportSize.value > 0 && natural.value.width > 0);
const maskClass = computed(() => (props.shape === 'square' ? 'rounded-lg' : 'rounded-full')); const maskClass = computed(() => (props.shape === 'square' ? 'rounded-lg' : 'rounded-full'));
const outputMime = computed(() => (encodableMimes.includes(props.mimeType) ? props.mimeType : 'image/png'));
const outputFileName = computed(
() => `${props.fileName.replace(/\.[^./]+$/, '') || 'image'}.${extensions[outputMime.value]}`,
);
const imageStyle = computed(() => ({ const imageStyle = computed(() => ({
width: `${natural.value.width * transform.value.scale}px`, width: `${natural.value.width * transform.value.scale}px`,
height: `${natural.value.height * transform.value.scale}px`, height: `${natural.value.height * transform.value.scale}px`,
@ -94,10 +104,20 @@ const onImageLoad = () => {
return; return;
} }
if (img.naturalWidth === 0 || img.naturalHeight === 0) {
imageError.value = true;
return;
}
natural.value = { width: img.naturalWidth, height: img.naturalHeight }; natural.value = { width: img.naturalWidth, height: img.naturalHeight };
maybeInitialize(); maybeInitialize();
}; };
const onImageError = () => {
imageError.value = true;
};
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
if (!ready.value) { if (!ready.value) {
return; return;
@ -187,10 +207,10 @@ const save = () => {
return; return;
} }
emit('cropped', new File([blob], props.fileName, { type: props.mimeType })); emit('cropped', new File([blob], outputFileName.value, { type: outputMime.value }));
close(); close();
}, },
props.mimeType, outputMime.value,
0.92, 0.92,
); );
}; };
@ -201,6 +221,7 @@ watch(
if (isOpen) { if (isOpen) {
initialized.value = false; initialized.value = false;
processing.value = false; processing.value = false;
imageError.value = false;
await nextTick(); await nextTick();
measure(); measure();
@ -219,6 +240,7 @@ watch(
() => props.src, () => props.src,
() => { () => {
initialized.value = false; initialized.value = false;
imageError.value = false;
natural.value = { width: 0, height: 0 }; natural.value = { width: 0, height: 0 };
}, },
); );
@ -244,7 +266,7 @@ onBeforeUnmount(() => resizeObserver?.disconnect());
@wheel="onWheel" @wheel="onWheel"
> >
<img <img
v-if="src" v-if="src && !imageError"
ref="imageEl" ref="imageEl"
:src="src" :src="src"
alt="" alt=""
@ -252,8 +274,16 @@ onBeforeUnmount(() => resizeObserver?.disconnect());
class="absolute left-0 top-0 max-w-none" class="absolute left-0 top-0 max-w-none"
:style="imageStyle" :style="imageStyle"
@load="onImageLoad" @load="onImageLoad"
@error="onImageError"
/> />
<div <div
v-if="imageError"
class="absolute inset-0 flex items-center justify-center p-4 text-center text-sm text-muted-foreground"
>
{{ $t('common.photo_upload.crop_error') }}
</div>
<div
v-else
class="pointer-events-none absolute inset-0" class="pointer-events-none absolute inset-0"
:class="maskClass" :class="maskClass"
style="box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5), inset 0 0 0 2px rgba(255, 255, 255, 0.7)" style="box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5), inset 0 0 0 2px rgba(255, 255, 255, 0.7)"

View file

@ -11,6 +11,8 @@ export type SourceRect = {
sh: number; sh: number;
}; };
const MAX_ZOOM = 8;
export const coverScale = (naturalWidth: number, naturalHeight: number, viewport: number): number => { export const coverScale = (naturalWidth: number, naturalHeight: number, viewport: number): number => {
if (naturalWidth <= 0 || naturalHeight <= 0) { if (naturalWidth <= 0 || naturalHeight <= 0) {
return 1; return 1;
@ -52,7 +54,8 @@ export const zoomTransform = (
naturalHeight: number, naturalHeight: number,
viewport: number, viewport: number,
): CropTransform => { ): CropTransform => {
const nextScale = Math.max(transform.scale * factor, coverScale(naturalWidth, naturalHeight, viewport)); const minScale = coverScale(naturalWidth, naturalHeight, viewport);
const nextScale = Math.min(minScale * MAX_ZOOM, Math.max(transform.scale * factor, minScale));
const center = viewport / 2; const center = viewport / 2;
const sourceX = (center - transform.x) / transform.scale; const sourceX = (center - transform.x) / transform.scale;
const sourceY = (center - transform.y) / transform.scale; const sourceY = (center - transform.y) / transform.scale;

View file

@ -32,10 +32,11 @@ function selectPhoto(mixed $page): void
} }
/** /**
* Capture the next multipart request the page sends. The Pest browser server * Capture the next multipart upload the page sends, keeping the uploaded File so
* does not parse multipart bodies (its file handling is an open TODO), so we * the test can decode it. The Pest browser server does not parse multipart
* assert the crop dispatches the right upload rather than that it persists * bodies (its file handling is an open TODO), so we assert the crop dispatches
* server-side persistence is covered by ProfileUpdateTest. * a valid image rather than that it persists persistence is covered by
* ProfileUpdateTest.
*/ */
function recordUpload(mixed $page): void function recordUpload(mixed $page): void
{ {
@ -51,7 +52,14 @@ function recordUpload(mixed $page): void
}; };
XMLHttpRequest.prototype.send = function (body) { XMLHttpRequest.prototype.send = function (body) {
if (body instanceof FormData) { if (body instanceof FormData) {
window.__uploadRequest = { method: this.__method, url: this.__url, keys: [...body.keys()] }; const photo = body.get('photo');
window.__uploadFile = photo;
window.__uploadRequest = {
method: this.__method,
url: this.__url,
keys: [...body.keys()],
size: photo instanceof File ? photo.size : 0,
};
} }
return send.apply(this, arguments); return send.apply(this, arguments);
}; };
@ -59,7 +67,7 @@ function recordUpload(mixed $page): void
JS); JS);
} }
test('cropping a selected photo dispatches a cropped avatar upload', function () { test('cropping a selected photo dispatches a valid 512x512 avatar upload', function () {
$this->actingAs(User::factory()->create()); $this->actingAs(User::factory()->create());
$page = visit(route('app.profile.edit')); $page = visit(route('app.profile.edit'));
@ -67,9 +75,6 @@ function recordUpload(mixed $page): void
selectPhoto($page); selectPhoto($page);
recordUpload($page); recordUpload($page);
// Auto-waits for the cropper dialog to open and its Save button to become
// enabled — the button only enables once the image has loaded and been
// measured inside the modal, which is exactly what a broken cropper fails.
$page->click('@crop-save') $page->click('@crop-save')
->assertNoJavaScriptErrors(); ->assertNoJavaScriptErrors();
@ -78,12 +83,19 @@ function recordUpload(mixed $page): void
for (let attempt = 0; attempt < 80 && !window.__uploadRequest; attempt++) { for (let attempt = 0; attempt < 80 && !window.__uploadRequest; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
return JSON.stringify(window.__uploadRequest); if (!window.__uploadRequest) {
return 'null';
}
const bitmap = await createImageBitmap(window.__uploadFile);
return JSON.stringify({ ...window.__uploadRequest, width: bitmap.width, height: bitmap.height });
})(); })();
JS), true); JS), true);
expect($request)->not->toBeNull() expect($request)->not->toBeNull()
->and($request['method'])->toBe('POST') ->and($request['method'])->toBe('POST')
->and($request['url'])->toContain('/settings/profile/photo') ->and($request['url'])->toContain(route('app.profile.upload-photo', absolute: false))
->and($request['keys'])->toContain('photo'); ->and($request['keys'])->toContain('photo')
->and($request['size'])->toBeGreaterThan(0)
->and($request['width'])->toBe(512)
->and($request['height'])->toBe(512);
}); });

View file

@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\User;
test('probe upload response', function () {
$user = User::factory()->create();
$this->actingAs($user);
$base64 = base64_encode((string) file_get_contents(base_path('tests/fixtures/blue-logo.png')));
$page = visit(route('app.profile.edit'));
$page->script(<<<JS
(async () => {
const findInput = () => document.querySelector('input[type="file"]');
for (let i = 0; i < 50 && !findInput(); i++) { await new Promise(r => setTimeout(r, 100)); }
const input = findInput();
const bytes = Uint8Array.from(atob('{$base64}'), (c) => c.charCodeAt(0));
const file = new File([bytes], 'logo.png', { type: 'image/png' });
const dt = new DataTransfer(); dt.items.add(file);
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
})();
JS);
$page->script(<<<'JS'
(() => {
window.__resp = null;
const oOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (m, u) { this.__u = u; this.addEventListener('loadend', () => {
if (String(this.__u).includes('/photo')) window.__resp = { status: this.status, body: (this.responseText || '').slice(0, 300) };
}); return oOpen.apply(this, arguments); };
})();
JS);
$page->click('@crop-save');
$info = $page->script(<<<'JS'
(async () => { for (let i = 0; i < 80 && !window.__resp; i++) { await new Promise(r => setTimeout(r, 100)); } return JSON.stringify(window.__resp || 'NO RESPONSE'); })();
JS);
fwrite(STDERR, "\nUPLOAD_RESP => {$info}\n");
fwrite(STDERR, 'DB_HAS_PHOTO => '.json_encode($user->fresh()->has_photo)."\n");
expect(true)->toBeTrue();
});

14
tests/BrowserTestCase.php Normal file
View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Tests;
abstract class BrowserTestCase extends TestCase
{
/**
* Browser tests drive a real browser and load the built Vite assets, so the
* manifest must not be faked away.
*/
protected bool $fakesVite = false;
}

View file

@ -9,6 +9,7 @@
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\BrowserTestCase;
use Tests\TestCase; use Tests\TestCase;
/* /*
@ -24,7 +25,11 @@
pest()->extend(TestCase::class) pest()->extend(TestCase::class)
->use(RefreshDatabase::class) ->use(RefreshDatabase::class)
->in('Feature', 'Unit', 'Browser'); ->in('Feature', 'Unit');
pest()->extend(BrowserTestCase::class)
->use(RefreshDatabase::class)
->in('Browser');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View file

@ -17,21 +17,18 @@ abstract class TestCase extends BaseTestCase
*/ */
protected $seed = true; protected $seed = true;
/**
* Whether to fake the Vite manifest. Browser tests drive a real browser and
* need the built assets, so they opt out via BrowserTestCase.
*/
protected bool $fakesVite = true;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
if (! $this->isBrowserTest()) { if ($this->fakesVite) {
$this->withoutVite(); $this->withoutVite();
} }
} }
/**
* Browser tests drive a real browser and need the built Vite assets, so the
* Vite manifest must not be faked away for them.
*/
private function isBrowserTest(): bool
{
return str_contains(static::class, '\\Browser\\');
}
} }