A 29 GB share with 254 files failed as one ZIP: the single request ran into the 300-second request limit and, behind a proxy we cannot configure, could be cut at any time with no way to resume. Single-file downloads now answer HTTP Range requests (206, ETag and If-Range, 416 past the end): a SEALCHK2 file is decrypted from the chunk holding the first byte asked for. On that, the download page's new "Download all to a folder" (File System Access API, Chrome and Edge on a computer) saves every file into a folder the recipient picks, one request per file, in its folder path; a broken connection is retried from the byte on disk, complete files are skipped, and failed files get a Retry button. Other browsers keep the ZIP and the per-file buttons. The ZIP is sized up front with ZipStream's strict simulation and sent with Content-Length, so the browser shows progress and notices a cut. Both download routes lift the time limit for their streams. The recipient's download window now restarts with each download, so a long file-by-file download never outlasts it. The download page shows the file count and total size and marks the files this browser has downloaded, and the upload page gains "Browse Folder", which keeps each file's path as a dropped folder does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
14 KiB
PHP
300 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Models\Share;
|
|
use App\Models\User;
|
|
use App\Services\ShareService;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* The share flow (upload, share created, share download) and today's rework of it, plus the
|
|
* settings/admin buttons and sections reworked alongside it — all changed since the last browser
|
|
* run (148ff38) without one. tests/Browser/SealShareTest.php, FrameTest.php and
|
|
* SettingsAndAdminTest.php already cover the flow's existing behaviour; this file adds what that
|
|
* rework introduced and does not re-assert what those already do (the copy-to-clipboard toast, the
|
|
* dragover burst, the QR code's fill and PNG export).
|
|
*/
|
|
beforeEach(function () {
|
|
config(['session.driver' => '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('a recipient reveals a share\'s private text on a phone, and the page has no popovers', function () {
|
|
$page = ready(visit('/upload'));
|
|
|
|
$page->type('[data-test="share-text"]', 'the wifi password is sunflower')
|
|
->click('[data-test="create-share"]');
|
|
$page->wait(1);
|
|
$page->assertSee('Share Created!');
|
|
|
|
$shareUrl = $page->script('document.querySelector(\'[data-test="share-link"]\').value');
|
|
|
|
$page = ready(visit($shareUrl)->resize(393, 852));
|
|
|
|
$page->click('[data-test="show-text"]');
|
|
$page->wait(1);
|
|
$page->assertSeeIn('[data-test="shared-text"]', 'the wifi password is sunflower');
|
|
|
|
$page
|
|
->assertScript("document.querySelectorAll('[popover]').length === 0")
|
|
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
test('download all to a folder writes every file in its folder path, resumes a partial file and marks them downloaded', function () {
|
|
config(['uploads.chunk_size' => 1000]);
|
|
$binary = random_bytes(2500);
|
|
$share = app(ShareService::class)->createShare([
|
|
['file' => UploadedFile::fake()->createWithContent('notes.txt', 'hello folder download'), 'relativePath' => null],
|
|
['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => 'holiday/photo.bin'],
|
|
]);
|
|
|
|
$page = ready(visit(route('share.download', $share, false)));
|
|
|
|
// The browser's private file system stands in for the folder the recipient would pick; notes.txt
|
|
// is already half there, as a stopped run leaves it.
|
|
$page->script("(async () => {
|
|
const root = await navigator.storage.getDirectory();
|
|
for await (const name of root.keys()) { await root.removeEntry(name, { recursive: true }); }
|
|
const partial = await (await root.getFileHandle('notes.txt', { create: true })).createWritable();
|
|
await partial.write('hello ');
|
|
await partial.close();
|
|
window.showDirectoryPicker = () => Promise.resolve(root);
|
|
Alpine.\$data(document.querySelector('[data-test=download-to-folder]')).supported = true;
|
|
})()");
|
|
|
|
$page->click('[data-test="download-to-folder"]');
|
|
$page->assertScript("Alpine.\$data(document.querySelector('[data-test=folder-download]')).finished === true");
|
|
|
|
$files = $page->script("(async () => {
|
|
const root = await navigator.storage.getDirectory();
|
|
const notes = await (await root.getFileHandle('notes.txt')).getFile();
|
|
const photo = await (await (await root.getDirectoryHandle('holiday')).getFileHandle('photo.bin')).getFile();
|
|
const bytes = new Uint8Array(await photo.arrayBuffer());
|
|
return { notes: await notes.text(), photo: btoa(String.fromCharCode(...bytes)) };
|
|
})()");
|
|
|
|
expect($files['notes'])->toBe('hello folder download');
|
|
expect(base64_decode($files['photo']))->toBe($binary);
|
|
$page->assertScript("[...document.querySelectorAll('[data-test=file-downloaded]')].filter((mark) => mark.offsetParent !== null).length === 2")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('Browse Folder picks a whole folder and keeps each file\'s path in it', function () {
|
|
$page = ready(visit('/upload'));
|
|
|
|
$page->assertScript("document.querySelector('[data-test=folder-input]').webkitdirectory === true");
|
|
|
|
// A folder picker's files, as the browser hands them over: each with its path from the folder.
|
|
$page->script("(() => {
|
|
const file = new File(['inside the folder'], 'notes.txt', { type: 'text/plain' });
|
|
Object.defineProperty(file, 'webkitRelativePath', { value: 'holiday/docs/notes.txt' });
|
|
Alpine.\$data(document.querySelector('[data-test=drop-zone]')).choose({ target: { files: [file], value: '' } });
|
|
})()");
|
|
|
|
$page->assertSee('holiday/docs/notes.txt')
|
|
->assertNoJavaScriptErrors();
|
|
});
|