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_save' => 'حفظ',
'crop_cancel' => 'إلغاء',
'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.',
],
'timezone' => [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,6 +26,7 @@
'crop_hint' => '拖动以重新定位',
'crop_save' => '保存',
'crop_cancel' => '取消',
'crop_error' => '无法加载此图片。请尝试其他文件。',
],
'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 processing = ref(false);
const initialized = ref(false);
const imageError = ref(false);
let dragPointerId: number | null = null;
let dragStart = { pointerX: 0, pointerY: 0, x: 0, y: 0 };
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 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(() => ({
width: `${natural.value.width * transform.value.scale}px`,
height: `${natural.value.height * transform.value.scale}px`,
@ -94,10 +104,20 @@ const onImageLoad = () => {
return;
}
if (img.naturalWidth === 0 || img.naturalHeight === 0) {
imageError.value = true;
return;
}
natural.value = { width: img.naturalWidth, height: img.naturalHeight };
maybeInitialize();
};
const onImageError = () => {
imageError.value = true;
};
const onPointerDown = (event: PointerEvent) => {
if (!ready.value) {
return;
@ -187,10 +207,10 @@ const save = () => {
return;
}
emit('cropped', new File([blob], props.fileName, { type: props.mimeType }));
emit('cropped', new File([blob], outputFileName.value, { type: outputMime.value }));
close();
},
props.mimeType,
outputMime.value,
0.92,
);
};
@ -201,6 +221,7 @@ watch(
if (isOpen) {
initialized.value = false;
processing.value = false;
imageError.value = false;
await nextTick();
measure();
@ -219,6 +240,7 @@ watch(
() => props.src,
() => {
initialized.value = false;
imageError.value = false;
natural.value = { width: 0, height: 0 };
},
);
@ -244,7 +266,7 @@ onBeforeUnmount(() => resizeObserver?.disconnect());
@wheel="onWheel"
>
<img
v-if="src"
v-if="src && !imageError"
ref="imageEl"
:src="src"
alt=""
@ -252,8 +274,16 @@ onBeforeUnmount(() => resizeObserver?.disconnect());
class="absolute left-0 top-0 max-w-none"
:style="imageStyle"
@load="onImageLoad"
@error="onImageError"
/>
<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="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)"

View file

@ -11,6 +11,8 @@ export type SourceRect = {
sh: number;
};
const MAX_ZOOM = 8;
export const coverScale = (naturalWidth: number, naturalHeight: number, viewport: number): number => {
if (naturalWidth <= 0 || naturalHeight <= 0) {
return 1;
@ -52,7 +54,8 @@ export const zoomTransform = (
naturalHeight: number,
viewport: number,
): 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 sourceX = (center - transform.x) / 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
* does not parse multipart bodies (its file handling is an open TODO), so we
* assert the crop dispatches the right upload rather than that it persists
* server-side persistence is covered by ProfileUpdateTest.
* Capture the next multipart upload the page sends, keeping the uploaded File so
* the test can decode it. The Pest browser server does not parse multipart
* bodies (its file handling is an open TODO), so we assert the crop dispatches
* a valid image rather than that it persists persistence is covered by
* ProfileUpdateTest.
*/
function recordUpload(mixed $page): void
{
@ -51,7 +52,14 @@ function recordUpload(mixed $page): void
};
XMLHttpRequest.prototype.send = function (body) {
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);
};
@ -59,7 +67,7 @@ function recordUpload(mixed $page): void
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());
$page = visit(route('app.profile.edit'));
@ -67,9 +75,6 @@ function recordUpload(mixed $page): void
selectPhoto($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')
->assertNoJavaScriptErrors();
@ -78,12 +83,19 @@ function recordUpload(mixed $page): void
for (let attempt = 0; attempt < 80 && !window.__uploadRequest; attempt++) {
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);
expect($request)->not->toBeNull()
->and($request['method'])->toBe('POST')
->and($request['url'])->toContain('/settings/profile/photo')
->and($request['keys'])->toContain('photo');
->and($request['url'])->toContain(route('app.profile.upload-photo', absolute: false))
->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\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\BrowserTestCase;
use Tests\TestCase;
/*
@ -24,7 +25,11 @@
pest()->extend(TestCase::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;
/**
* 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
{
parent::setUp();
if (! $this->isBrowserTest()) {
if ($this->fakesVite) {
$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\\');
}
}