'file']); Storage::fake('shares'); }); test('tab reaches Browse Files with its focus ring, and Enter or Space opens the file picker', function () { $page = ready(visit('/upload')); // A spy, not a real dialog: Playwright would otherwise have to field a native file chooser. $page->script("window.eval(\"window.__fileInputClicks = 0; HTMLInputElement.prototype.click = function () { if (this.type === 'file') { window.__fileInputClicks++ } }\")"); // "body" alone is not a CSS-explicit selector to this plugin's guesser (no special chars) and // falls back to a text search, which never matches and times out; "html > body" is explicit and // focuses nothing new (the body takes no tabindex), so the Tab lands where a fresh page load // would send it: the first tabbable element. $page->keys('html > body', 'Tab'); $page->assertScript("document.activeElement.matches('[data-md-button]') && document.activeElement.textContent.trim() === 'Browse Files'"); $ring = $page->script('getComputedStyle(document.activeElement).outlineStyle'); expect($ring)->toBe('solid'); $page->keys(':focus', 'Enter'); $page->assertScript('window.__fileInputClicks === 1'); $page->keys(':focus', 'Space'); $page->assertScript('window.__fileInputClicks === 2') ->assertNoJavaScriptErrors(); }); test('the selected files list avoids horizontal overflow once files are chosen', function () { $path = sys_get_temp_dir().'/a-genuinely-quite-long-holiday-photos-archive-from-portugal-'.uniqid().'.zip'; file_put_contents($path, 'archive'); $page = ready(visit('/upload')->resize(393, 852)); $page->attach('[data-test="file-input"]', $path) ->waitForText('Uploaded'); $rows = $page->script("(() => { const rows = [...document.querySelectorAll('[data-test=selected-file]')]; return { count: rows.length, allFit: rows.every((row) => row.getBoundingClientRect().right <= window.innerWidth + 0.5) }; })()"); expect($rows['count'])->toBe(1); expect($rows['allFit'])->toBeTrue(); $page->assertScript('document.documentElement.scrollWidth <= window.innerWidth') ->assertNoJavaScriptErrors(); unlink($path); }); test('without a secure context the upload page says HTTPS is needed and takes no files', function () { $page = ready(visit('/upload')); $page->script("window.eval(\"Alpine.\$data(document.querySelector('[data-test=drop-zone]')).secure = false\")"); $page->assertScript("getComputedStyle(document.querySelector('[data-test=insecure-context]')).display !== 'none'") ->assertSee('Uploads need a secure connection (HTTPS).') ->assertScript("document.querySelector('[data-test=file-input]').disabled === true") ->assertScript("document.querySelector('[data-test=drop-zone]').getAttribute('aria-disabled') === 'true'") ->assertNoJavaScriptErrors(); }); test('the drop zone hides its burst again once the drag leaves', function () { $page = ready(visit('/upload')); $burst = "getComputedStyle(document.querySelector('[data-test=drop-zone-burst]')).opacity"; $page->assertScript("{$burst} === '0'"); $page->script("window.eval(\"document.querySelector('[data-test=drop-zone]').dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true }))\")"); $page->assertScript("{$burst} === '1'"); $page->script("window.eval(\"document.querySelector('[data-test=drop-zone]').dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true }))\")"); $page->assertScript("{$burst} === '0'") ->assertNoJavaScriptErrors(); }); test('the QR dialog holds its code inside the box, and Escape returns focus to the button that opened it', function () { $share = Share::factory()->create(); $page = ready(visit(route('share.created', $share, false))); $page->click('[data-test="show-qr-code"]'); $page->wait(1); $page->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open"); $inside = $page->script("(() => { const dialog = document.querySelector('[data-test=\"qr-code-dialog\"]').getBoundingClientRect(); const qr = document.querySelector('[data-qr-code] svg').getBoundingClientRect(); return qr.left >= dialog.left - 1 && qr.right <= dialog.right + 1 && qr.top >= dialog.top - 1 && qr.bottom <= dialog.bottom + 1; })()"); expect($inside)->toBeTrue(); $page->keys(':focus', 'Escape'); $page->wait(1); $page->assertScript("! document.querySelector('[data-test=\"qr-code-dialog\"]').open") ->assertScript("document.activeElement === document.querySelector('[data-test=\"show-qr-code\"]')") ->assertNoJavaScriptErrors(); }); test('the download page fits a phone before and after unlocking a password-protected share', function () { $share = app(ShareService::class)->createShare( [['file' => UploadedFile::fake()->create('a-genuinely-quite-long-holiday-photos-archive-from-portugal.zip', 120), 'relativePath' => null]], ['password' => 'let-me-in'], ); $page = ready(visit(route('share.download', $share, false))->resize(393, 852)); $metrics = $page->script("(() => { const button = document.querySelector('button[type=submit]'); const form = button.closest('[data-md-form]').getBoundingClientRect(); const rect = button.getBoundingClientRect(); return { formWidth: form.width, formRight: form.right, buttonWidth: rect.width, buttonRight: rect.right }; })()"); expect($metrics['buttonWidth'])->toBeLessThan($metrics['formWidth']); expect($metrics['buttonRight'])->toEqualWithDelta($metrics['formRight'], 1); $page->assertScript('document.documentElement.scrollWidth <= window.innerWidth'); $page->type('input[type="password"]', 'let-me-in')->press('Unlock'); $page->wait(1); $page->assertScript("document.querySelector('h2').tagName === 'H2' && document.querySelector('h2').textContent.trim() === 'Shared Files'"); $rows = $page->script("(() => { const rows = [...document.querySelectorAll('[data-md-list-item]')]; return { count: rows.length, allFit: rows.every((row) => row.getBoundingClientRect().right <= window.innerWidth + 0.5), }; })()"); expect($rows['count'])->toBeGreaterThan(0); expect($rows['allFit'])->toBeTrue(); $page->assertScript('document.documentElement.scrollWidth <= window.innerWidth') ->assertNoJavaScriptErrors(); }); test('the settings Save button is end-aligned at less than the form\'s width', function (string $url, string $button) { $this->actingAs($url === '/admin/settings' ? User::factory()->admin()->create() : User::factory()->create()); $page = ready(visit($url)->resize(1280, 800)); $metrics = $page->script("(() => { const button = document.querySelector('{$button}'); const form = button.closest('[data-md-form]').getBoundingClientRect(); const rect = button.getBoundingClientRect(); return { formWidth: form.width, formRight: form.right, buttonWidth: rect.width, buttonRight: rect.right }; })()"); expect($metrics['buttonWidth'])->toBeLessThan($metrics['formWidth']); expect($metrics['buttonRight'])->toEqualWithDelta($metrics['formRight'], 1); })->with([ ['/settings/profile', '[data-test="update-profile-button"]'], ['/admin/settings', '[data-test="save-settings"]'], ]); test('the admin dashboard heads its shares list with an h2, both in one card', function () { $admin = User::factory()->admin()->create(); Share::factory()->count(2)->create(); $this->actingAs($admin); $page = ready(visit('/admin/dashboard')); $result = $page->script("(() => { const heading = [...document.querySelectorAll('h2')].find((h) => h.textContent.trim() === 'All Shares'); const list = document.querySelector('[data-test=\"share-row\"]'); const card = heading?.closest('[data-md-card]'); return { headingIsCardTitle: heading?.matches('[data-md-card-title]') ?? false, listInSameCard: !!card && card.contains(list), headingBeforeList: !!heading && !!list && !!(heading.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING), }; })()"); expect($result['headingIsCardTitle'])->toBeTrue(); expect($result['listInSameCard'])->toBeTrue(); expect($result['headingBeforeList'])->toBeTrue(); }); test('the admin settings page has six sections, each a card headed by an h2', function () { $this->actingAs(User::factory()->admin()->create()); $page = ready(visit('/admin/settings')); $result = $page->script("(() => { // Every dialog's own title is an h2 too (components/modal.blade.php), whether open or // not: exclude those to count only the page's own section headings. const headings = [...document.querySelectorAll('h2:not([data-md-modal-title])')]; const cards = [...document.querySelectorAll('[data-md-card]')]; return { headings: headings.map((h) => h.textContent.trim()), cardCount: cards.length, everyCardHeaded: cards.every((card) => card.querySelector('h2[data-md-card-title]') !== null), }; })()"); expect($result['headings'])->toBe(['Colour profile', 'Branding', 'Upload Protection', 'Share Passwords', 'Upload Limits', 'Storage']); expect($result['cardCount'])->toBe(6); expect($result['everyCardHeaded'])->toBeTrue(); });