Indigo (the default), Blue, Teal, Green, Amber, Rose and Violet in the Vibrant style and Graphite in the Neutral style are generated from config into the stylesheet. Admin settings opens with a colour profile card: a swatch previews the profile on the page, and Save Settings stores it as color_profile, which AppServiceProvider hands to the package's resolver, so every page, mail and error page wears it. An unknown profile is refused, and a saved one that disappears falls back to indigo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
179 lines
8.7 KiB
PHP
179 lines
8.7 KiB
PHP
<?php
|
|
|
|
use App\Models\Setting;
|
|
use App\Models\Share;
|
|
use App\Models\User;
|
|
use App\Services\ShareService;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
|
|
|
/**
|
|
* A page of SealShare, once it can be used: loaded, with Alpine and Livewire started.
|
|
*/
|
|
function ready(mixed $page): mixed
|
|
{
|
|
return $page->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
}
|
|
|
|
beforeEach(function () {
|
|
// Sessions have to outlive a request here: a sign-in, a verified share password.
|
|
config(['session.driver' => 'file']);
|
|
Storage::fake('shares');
|
|
});
|
|
|
|
test('files dragged over the drop zone turn its shape into a burst', function () {
|
|
$page = ready(visit('/upload'));
|
|
|
|
$burst = "getComputedStyle(document.querySelectorAll('[data-test=drop-zone] span.absolute')[1]).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'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
// Pest's in-process server does not store a multipart upload, so the upload itself is covered by
|
|
// FileUploadTest; this picks up where it ends, on the page the upload leads to.
|
|
test('a new share\'s link can be copied from the page the upload leads to', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
|
|
[],
|
|
);
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
$page->assertSee('Share Created!')
|
|
->assertScript("document.querySelector('[data-test=\"share-link\"]').value.includes('/s/')");
|
|
|
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
|
|
|
$page->click('[data-field-copy]')
|
|
->assertScript("typeof window.copied === 'string' && window.copied.includes('/s/')")
|
|
->assertSee('Copied to the clipboard');
|
|
});
|
|
|
|
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
|
|
$share = Share::factory()->withPassword()->create();
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
$page->click('[data-test="show-qr-code"]')
|
|
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open")
|
|
->assertScript("getComputedStyle(document.querySelector('[data-qr-code]')).backgroundColor === 'rgb(255, 255, 255)'")
|
|
->assertScript("document.querySelector('[data-qr-code] svg').getBoundingClientRect().width > 200")
|
|
->assertSee('Recipients also need the password.');
|
|
|
|
// Record what would be saved instead of saving it.
|
|
$page->script('window.eval("URL.revokeObjectURL = () => {}; HTMLAnchorElement.prototype.click = function () { window.saved = { name: this.download, href: this.href } }")');
|
|
|
|
$page->click('[data-test="download-qr-code"]')
|
|
->assertScript("window.eval('window.saved?.name') === 'share-{$share->token}.png'");
|
|
|
|
// A QR code is roughly a third to a half dark; a blank or failed drawing is not.
|
|
$page->script("window.eval(\"(async () => { const blob = await (await fetch(window.saved.href)).blob(); const bitmap = await createImageBitmap(blob); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data; let dark = 0; for (let i = 0; i < pixels.length; i += 4) { if (pixels[i] < 128) { dark++ } } window.png = { type: blob.type, width: bitmap.width, dark: dark / (pixels.length / 4) } })()\")");
|
|
|
|
$page->assertScript("window.eval('window.png?.type') === 'image/png'")
|
|
->assertScript("window.eval('window.png.width') === 1024")
|
|
->assertScript("window.eval('window.png.dark') > 0.2 && window.eval('window.png.dark') < 0.6")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('the share sheet gets the link, and says so only when it fails for another reason than a cancel', function () {
|
|
$share = Share::factory()->create();
|
|
$actions = "Alpine.\$data(document.querySelector('[data-test=share-actions]'))";
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
// Shown only where the browser has a share sheet.
|
|
$page->assertScript("window.eval(\"getComputedStyle(document.querySelector('[data-test=share-sheet]').parentElement).display === 'none'\") === (typeof navigator.share !== 'function')");
|
|
|
|
$page->script("window.eval(\"navigator.share = async (data) => { window.shared = data }; {$actions}.share()\")");
|
|
$page->assertScript("window.eval('window.shared?.url') === '".route('share.download', $share)."'");
|
|
|
|
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Cancelled', 'AbortError') }; {$actions}.share()\")");
|
|
$page->wait(0.3)->assertDontSee('The share sheet could not open.');
|
|
|
|
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Not allowed', 'NotAllowedError') }; {$actions}.share()\")");
|
|
$page->assertSee('The share sheet could not open.');
|
|
});
|
|
|
|
test('a recipient on a phone unlocks a password-protected share and sees its files', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 120), 'relativePath' => null]],
|
|
['password' => 'correct horse'],
|
|
);
|
|
|
|
$page = ready(visit(route('share.download', $share, false))->resize(393, 852));
|
|
|
|
$page->assertSee('Password Required')
|
|
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
|
->assertNoJavaScriptErrors()
|
|
->type('input[type="password"]', 'correct horse')
|
|
->press('Unlock')
|
|
->assertSee('Shared Files')
|
|
->assertSee('holiday-photos.zip')
|
|
->assertScript("document.querySelectorAll('[popover]').length === 0")
|
|
->assertScript('document.documentElement.scrollWidth <= window.innerWidth');
|
|
});
|
|
|
|
test('an admin sorts the shares table and deletes a share through its dialog', function () {
|
|
$admin = User::factory()->admin()->create();
|
|
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 9]);
|
|
$doomed = Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 1]);
|
|
|
|
$this->actingAs($admin);
|
|
|
|
$page = ready(visit('/admin/dashboard'));
|
|
|
|
$page->click('th button:has-text("Downloads")')
|
|
->assertScript("document.querySelector('tbody tr td').textContent.trim() === 'zzzzzzzzzzzzzzzz'");
|
|
|
|
$page->click("[data-test=\"delete-share-{$doomed->id}\"]")
|
|
->assertScript("[...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
|
->click('[data-test="confirm-delete-share"]')
|
|
->assertScript("! [...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
|
->assertDontSee('zzzzzzzzzzzzzzzz');
|
|
|
|
expect(Share::query()->find($doomed->id))->toBeNull();
|
|
});
|
|
|
|
test('a first visit follows the system theme, and Appearance switches it', function () {
|
|
ready(visit('/upload')->inDarkMode())
|
|
->assertScript("document.documentElement.dataset.theme === 'dark'")
|
|
->assertScript("document.documentElement.dataset.themeChoice === 'system'");
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$page = ready(visit('/settings/appearance')->inDarkMode());
|
|
|
|
$page->click('[data-theme-option="light"]')
|
|
->assertScript("document.documentElement.dataset.theme === 'light'")
|
|
->assertScript("localStorage.getItem('sealshare-theme') === 'light'");
|
|
});
|
|
|
|
test('an admin previews a colour profile, saves it, and every page wears it', function () {
|
|
$this->actingAs(User::factory()->admin()->create());
|
|
|
|
$page = ready(visit('/admin/settings'));
|
|
|
|
$page->assertScript("document.documentElement.getAttribute('data-scheme') === 'indigo'")
|
|
->click('[data-test="color-profile"] [data-scheme-option="teal"]')
|
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
|
|
|
|
expect(Setting::get('color_profile'))->toBeNull();
|
|
|
|
$page->click('[data-test="save-settings"]')
|
|
->assertSee('Settings saved successfully.');
|
|
|
|
expect(Setting::get('color_profile'))->toBe('teal');
|
|
|
|
ready(visit('/upload'))
|
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'")
|
|
->assertScript("getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-primary').trim() === '".Scheme::profiles()['teal']['light']['primary']."'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|