Testing
The widget is a Livewire component, so your application tests it the way they test any
other Livewire component — Livewire::test(), the Laravel fakes, no browser required.
Two things are worth knowing before the first test, and both of them are the reason an otherwise correct test fails: the abuse floor runs on every submit, and it is tuned for real people rather than for a suite.
Make the floor permissive in the test environment
The floor is a honeypot, a server-anchored time trap and per-hour rate limits, and no driver
value removes it — see Abuse protection. Your suite therefore runs the
same path production does, which is the point.
With one exception, and this page is about to recommend it. min_fill_seconds = 0 disarms
the time trap outright. That is the right setting for most of a suite, and it means those tests
do not cover the trap or the fail-closed missing-open-time path. Cover them in the tests that are
about abuse, with the setting turned back up — the section below on opening a modal widget is
what makes that work.
What it costs is that a test submits instantly, and the time trap exists precisely to reject that. Relax the two settings once, in the test environment, and tighten them again in the tests that are actually about abuse:
// tests/TestCase.php, or a Pest beforeEach
config()->set('visual-feedback.abuse.min_fill_seconds', 0);
config()->set('visual-feedback.abuse.rate_limit', 1000);
config()->set('visual-feedback.abuse.guest_rate_limit', 1000);
Without the first line every submit is rejected as too fast, and the rejection is silent by design — the component reports success and stores nothing, so the test fails on a missing mail rather than on anything that names the cause.
The rate limits matter as soon as a file has more than a handful of submits: the shipped defaults are 30 per authenticated user per hour and 5 per guest IP per hour, and the counter survives from one test to the next unless the cache is reset between them.
The honeypot needs nothing: its field starts empty and only a test that fills it in trips it.
A modal widget has to be opened first
min_fill_seconds measures from the moment the panel opens, not from the moment the component
mounts — a modal widget mounts closed, so it has no open time until someone opens it. Driving it
from a test means saying so:
Livewire::test(ReportWidget::class)
->call('markOpened') // what a reporter does by clicking the trigger
->set('message', 'The export button does nothing on Safari.')
->call('submit');
Leave it out and the submission is refused before the time trap is consulted at all, because the
gate fails closed on a missing open time — and the refusal it reports is Honeypot. So a test
that means to exercise the trap gets a honeypot verdict for a form that has no honeypot filled,
and reads as proof that the honeypot works.
An inline widget is unaffected: it is open the moment it renders. And setting
min_fill_seconds to 0, as above, disarms the trap and this refusal with it — which is why the
call only matters in the tests that tighten the setting back up.
Submit a report
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
use Pushery\VisualFeedback\Channels\Mail\ReportMail;
use Pushery\VisualFeedback\Livewire\ReportWidget;
it('mails a report the reporter filed', function (): void {
Mail::fake();
config()->set('visual-feedback.mail.to', 'support@example.com');
Livewire::test(ReportWidget::class)
->set('category', 'bug')
->set('message', 'The invoice total is wrong.')
->call('submit')
->assertSet('submitted', true);
Mail::assertSent(ReportMail::class);
});
ReportWidget::class and the registered name visual-feedback.report-widget are
interchangeable here; the class is the one your editor can resolve.
Mount props go in the second argument, exactly as they do in Blade:
Livewire::test(ReportWidget::class, ['mode' => 'inline', 'recipient' => 'billing@example.com']);
The per-instance props are listed under
Placing the trigger. All of them are
#[Locked], so ->set('recipient', …) throws rather than assigning — that is the guarantee
the lock exists for, and it means a test can only reach them through the mount.
Assert on the events instead of the channels
Every submit path ends in an event, and asserting on those is both cheaper and more precise than asserting on mail, because a rejection has an event of its own:
use Illuminate\Support\Facades\Event;
use Pushery\VisualFeedback\Events\ReportRejected;
use Pushery\VisualFeedback\Events\ReportSubmitted;
Event::fake([ReportSubmitted::class, ReportRejected::class]);
// … submit …
Event::assertDispatched(ReportSubmitted::class);
Event::assertNotDispatched(ReportRejected::class);
ReportSubmitted carries the whole Report, so a single assertion can check the category,
the metadata and the reporter that were actually recorded. ReportRejected carries a
RejectionReason, which is the only way to tell a honeypot hit from a rate limit from a
validation failure — from the outside all three look like a success screen.
Faking the events does not stop delivery, and it is worth being exact about that, because
the assumption is easy to make and it costs a real mail. The channels are dispatched before
ReportSubmitted is, and they enqueue jobs rather than fire events — so an Event::fake()
leaves the whole delivery path running underneath it.
What suppresses delivery is Queue::fake(), which is what the package's own suite uses:
use Illuminate\Support\Facades\Queue;
use Pushery\VisualFeedback\Jobs\SendReportMail;
Queue::fake();
// … submit …
Queue::assertPushed(SendReportMail::class);
That also names the other half of the first example on this page. Mail::assertSent() finds
the mail because a test environment runs the queue synchronously — Laravel's own
phpunit.xml sets QUEUE_CONNECTION=sync — and the job therefore runs inline. Point a test
at a real queue connection and the same assertion fails on a report that is perfectly fine:
the job was pushed, not run. Assert on the job there, or run the queue.
Attachments and screenshots
Both ride Livewire's upload path, so Storage::fake() on the disk the package is configured
to use plus UploadedFile::fake() is the whole setup:
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
Storage::fake('local');
Livewire::test(ReportWidget::class)
->set('attachments', [UploadedFile::fake()->image('screen.png')])
->set('category', 'bug')
->set('message', 'Attached.')
->call('submit')
->assertSet('submitted', true);
Uploads are validated the moment they land, not only at submit, so a file that is too large
or of a rejected type surfaces its error on the set() and never reaches submit(). The
caps come from attachments.max_files, attachments.max_file_size and
attachments.mimes — see Configuration.
A screenshot goes on screenshot rather than attachments, and screenshotStage records
which capture stage produced it. Neither is required for a report to be valid.
What the bundled suites already prove
The package ships no tests to the published tree — they are development files, and a public checkout has nothing to run. What they cover is still worth knowing, because it decides which tests are worth writing in your application and which would be duplicates:
- Every shipped line and every shipped type, at 100%, with a mutation run on top so a test that cannot fail does not count as coverage.
- The submit pipeline end to end — validation, the abuse floor, metadata sanitization, attachment and screenshot perimeters, the events, and each delivery channel in isolation.
- Both view trees in a real browser: an axe sweep over every widget state, a keyboard-only run through the whole flow, contrast measured on the values the browser rendered, and the capture running against real Chromium.
- Real database engines, not SQLite alone: the same suites re-run against PostgreSQL and MySQL 8.4, so the optional reports table is proven on the engines it will actually meet.
What they cannot cover is everything that belongs to your page, and those are the tests worth having:
- that the widget is on the layout you think it is on, in the environments you think it is enabled in;
- that
attachments.diskpoints at a private disk in every environment — a misconfiguration here is a public URL to somebody's screen, and it looks identical to a correct setup from inside the package; - that the reports actually arrive where you sent them: a queue that is not running, a mail transport that is not configured, or a webhook endpoint that rejects the signature all produce a perfectly successful submit;
- that a redacted region on your own pages is still redacted after a redesign. The Integration contract explains why a CSS effect is not a redaction; a test that asserts the attribute is present on the element that holds the sensitive data is what keeps it from being removed by accident.
Those suites are not in the published package and not in the public repository either — the
release is rebuilt from an allowlist that has no tests/ in it, so there is nothing to click
through to. What is reproducible from here is the shape: every example on this page runs against
a real installation, and the sections above say which of them the package already covers, so you
can write the ones it does not instead of the ones it does.