command: sed -n '1,180p' tests/Browser/Admin/AuthenticationTest.php

aggregated_output:
<?php

use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->seed(DatabaseSeeder::class);
});

function adminAuthenticationHost(): array
{
    return ['host' => 'shop.test'];
}

function adminAuthenticationLogin(): mixed
{
    return visit('/admin/login', adminAuthenticationHost())
        ->fill('input[type=email]', 'admin@acme.test')
        ->fill('input[type=password]', 'password')
        ->click('@admin-login-button')
        ->wait(1)
        ->assertPathIs('/admin')
        ->assertSee('Dashboard')
        ->assertNoJavaScriptErrors();
}

function adminAuthenticationSubmitWithoutBrowserRequired(string $email, string $password): mixed
{
    $page = visit('/admin/login', adminAuthenticationHost());

    $page->script('() => document.querySelectorAll("[required]").forEach((element) => element.removeAttribute("required"))');

    if ($email !== '') {
        $page->fill('input[type=email]', $email);
    }

    if ($password !== '') {
        $page->fill('input[type=password]', $password);
    }

    return $page->click('@admin-login-button')->wait(1);
}

test('can log in as admin', function (): void {
    adminAuthenticationLogin();
});

test('shows error for invalid credentials', function (): void {
    visit('/admin/login', adminAuthenticationHost())
        ->fill('input[type=email]', 'admin@acme.test')
        ->fill('input[type=password]', 'wrongpassword')
        ->click('@admin-login-button')
        ->wait(1)
        ->assertPathIs('/admin/login')
        ->assertSee('Invalid credentials')
        ->assertNoJavaScriptErrors();
});

test('shows error for empty email', function (): void {
    adminAuthenticationSubmitWithoutBrowserRequired('', 'password')
        ->assertPathIs('/admin/login')
        ->assertSee('email field is required')
        ->assertNoJavaScriptErrors();
});

test('shows error for empty password', function (): void {
    adminAuthenticationSubmitWithoutBrowserRequired('admin@acme.test', '')
        ->assertPathIs('/admin/login')
        ->assertSee('password field is required')
        ->assertNoJavaScriptErrors();
});

test('redirects unauthenticated users to login from dashboard', function (): void {
    visit('/admin', adminAuthenticationHost())
        ->wait(1)
        ->assertPathIs('/admin/login')
        ->assertSee('Sign in')
        ->assertNoJavaScriptErrors();
});

test('redirects unauthenticated users to login from products', function (): void {
    visit('/admin/products', adminAuthenticationHost())
        ->wait(1)
        ->assertPathIs('/admin/login')
        ->assertSee('Sign in')
        ->assertNoJavaScriptErrors();
});

test('can log out', function (): void {
    adminAuthenticationLogin()
        ->click('@sidebar-menu-button')
        ->click('button[data-test="logout-button"]:visible')
        ->wait(1)
        ->assertPathIs('/admin/login')
        ->assertSee('Sign in')
        ->assertNoJavaScriptErrors();
});

test('can navigate through admin sidebar sections', function (): void {
    $page = adminAuthenticationLogin();

    foreach ([
        '/admin/products' => 'Products',
        '/admin/orders' => 'Orders',
        '/admin/customers' => 'Customers',
        '/admin/discounts' => 'Discounts',
        '/admin/settings' => 'Store Settings',
    ] as $path => $heading) {
        $page->click("a[href$=\"{$path}\"]")
            ->wait(1)
            ->assertPathIs($path)
            ->assertSee($heading)
            ->assertNoJavaScriptErrors();
    }
});

test('can navigate to analytics from sidebar', function (): void {
    adminAuthenticationLogin()
        ->click('a[href$="/admin/analytics"]')
        ->wait(1)
        ->assertPathIs('/admin/analytics')
        ->assertSee('Analytics')
        ->assertNoJavaScriptErrors();
});

test('can navigate to themes from sidebar', function (): void {
    adminAuthenticationLogin()
        ->click('a[href$="/admin/themes"]')
        ->wait(1)
        ->assertPathIs('/admin/themes')
        ->assertSee('Themes')
        ->assertNoJavaScriptErrors();
});


output:
Chunk ID: b500e6
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 53
Output:
 M resources/views/components/desktop-user-menu.blade.php
 M resources/views/layouts/app/sidebar.blade.php
 M resources/views/livewire/admin/settings/index.blade.php
 M specs/progress.md
?? tests/Browser/Admin/


output:
Chunk ID: 79816e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 82
Output:
 resources/views/components/desktop-user-menu.blade.php  |  4 +++-
 resources/views/layouts/app/sidebar.blade.php           |  4 +++-
 resources/views/livewire/admin/settings/index.blade.php |  2 +-
 specs/progress.md                                       | 13 ++++++++++---
 4 files changed, 17 insertions(+), 6 deletions(-)


output:
Chunk ID: ea4606
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


output:
Chunk ID: 6e8495
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1035
Output:
<?php

use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->seed(DatabaseSeeder::class);
});

function adminAuthenticationHost(): array
{
    return ['host' => 'shop.test'];
}

function adminAuthenticationLogin(): mixed
{
    return visit('/admin/login', adminAuthenticationHost())
        ->fill('input[type=email]', 'admin@acme.test')
        ->fill('input[type=password]', 'password')
        ->click('@admin-login-button')
        ->wait(1)
        ->assertPathIs('/admin')
        ->assertSee('Dashboard')
        ->assertNoJavaScriptErrors();
}

function adminAuthenticationSubmitWithoutBrowserRequired(string $email, string $password): mixed
{
    $page = visit('/admin/login', adminAuthenticationHost());

    $page->script('() => document.querySelectorAll("[required]").forEach((element) => element.removeAttribute("required"))');

    if ($email !== '') {
        $page->fill('input[type=email]', $email);
    }

    if ($password !== '') {
        $page->fill('input[type=password]', $password);
    }

    return $page->click('@admin-login-button')->wait(1);
}

test('can log in as admin', function (): void {
    adminAuthenticationLogin();
});

test('shows error for invalid credentials', function (): void {
    visit('/admin/login', adminAuthenticationHost())
        ->fill('input[type=email]', 'admin@acme.test')
        ->fill('input[type=password]', 'wrongpassword')
        ->click('@admin-login-button')
        ->wait(1)
        ->assertPathIs('/admin/login')
        ->assertSee('Invalid credentials')
        ->assertNoJavaScriptErrors();
});

test('shows error for empty email', function (): void {
    adminAuthenticationSubmitWithoutBrowserRequired('', 'password')
        ->assertPathIs('/admin/login')
        ->assertSee('email field is required')
        ->assertNoJavaScriptErrors();
});

test('shows error for empty password', function (): void {
    adminAuthenticationSubmitWithoutBrowserRequired('admin@acme.test', '')
        ->assertPathIs('/admin/login')
        ->assertSee('password field is required')
        ->assertNoJavaScriptErrors();
});

test('redirects unauthenticated users to login from dashboard', function (): void {
    visit('/admin', adminAuthenticationHost())
        ->wait(1)
        ->assertPathIs('/admin/login')
        ->assertSee('Sign in')
        ->assertNoJavaScriptErrors();
});

test('redirects unauthenticated users to login from products', function (): void {
    visit('/admin/products', adminAuthenticationHost())
        ->wait(1)
        ->assertPathIs('/admin/login')
        ->assertSee('Sign in')
        ->assertNoJavaScriptErrors();
});

test('can log out', function (): void {
    adminAuthenticationLogin()
        ->click('@sidebar-menu-button')
        ->click('button[data-test="logout-button"]:visible')
        ->wait(1)
        ->assertPathIs('/admin/login')
        ->assertSee('Sign in')
        ->assertNoJavaScriptErrors();
});

test('can navigate through admin sidebar sections', function (): void {
    $page = adminAuthenticationLogin();

    foreach ([
        '/admin/products' => 'Products',
        '/admin/orders' => 'Orders',
        '/admin/customers' => 'Customers',
        '/admin/discounts' => 'Discounts',
        '/admin/settings' => 'Store Settings',
    ] as $path => $heading) {
        $page->click("a[href$=\"{$path}\"]")
            ->wait(1)
            ->assertPathIs($path)
            ->assertSee($heading)
            ->assertNoJavaScriptErrors();
    }
});

test('can navigate to analytics from sidebar', function (): void {
    adminAuthenticationLogin()
        ->click('a[href$="/admin/analytics"]')
        ->wait(1)
        ->assertPathIs('/admin/analytics')
        ->assertSee('Analytics')
        ->assertNoJavaScriptErrors();
});

test('can navigate to themes from sidebar', function (): void {
    adminAuthenticationLogin()
        ->click('a[href$="/admin/themes"]')
        ->wait(1)
        ->assertPathIs('/admin/themes')
        ->assertSee('Themes')
        ->assertNoJavaScriptErrors();
});


cmd: sed -n '1,60p' resources/views/components/desktop-user-menu.blade.php && sed -n '1,155p' resources/views/layouts/app/sidebar.blade.php


cmd: rg -n "admin authentication|Suite 2|256 tests|Open Issues|Automated browser coverage" specs/progress.md


command: sed -n '1,60p' resources/views/components/desktop-user-menu.blade.php && sed -n '1,155p' resources/views/layouts/app/sidebar.blade.php

desktop-user-menu.blade.php

type: read

sidebar.blade.php

aggregated_output:
@php($logoutRoute = request()->routeIs('admin.*') ? route('admin.logout') : route('logout'))

<flux:dropdown position="bottom" align="start">
    <flux:sidebar.profile
        {{ $attributes->only('name') }}
        :initials="auth()->user()->initials()"
        icon:trailing="chevrons-up-down"
        data-test="sidebar-menu-button"
    />

    <flux:menu>

            <flux:avatar
                :name="auth()->user()->name"
                :initials="auth()->user()->initials()"
            />

                <flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
                <flux:text class="truncate">{{ auth()->user()->email }}</flux:text>

        <flux:menu.separator />
        <flux:menu.radio.group>
            <flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
                {{ __('Settings') }}
            </flux:menu.item>
            <form method="POST" action="{{ $logoutRoute }}" class="w-full">
                @csrf
                <flux:menu.item
                    as="button"
                    type="submit"
                    icon="arrow-right-start-on-rectangle"
                    class="w-full cursor-pointer"
                    data-test="logout-button"
                >
                    {{ __('Log Out') }}
                </flux:menu.item>
            </form>
        </flux:menu.radio.group>
    </flux:menu>
</flux:dropdown>
<!DOCTYPE html>
getLocale()) }}" class="dark">

        @include('partials.head')

        @php($logoutRoute = request()->routeIs('admin.*') ? route('admin.logout') : route('logout'))

        <flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
            <flux:sidebar.header>
                <x-app-logo :sidebar="true" href="{{ route('admin.dashboard') }}" wire:navigate />
                <flux:sidebar.collapse class="lg:hidden" />
            </flux:sidebar.header>

            <flux:sidebar.nav>
                <flux:sidebar.group :heading="__('Platform')" class="grid">
                    <flux:sidebar.item icon="home" :href="route('admin.dashboard')" :current="request()->routeIs('admin.dashboard')" wire:navigate>
                        {{ __('Dashboard') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="chart-bar" :href="route('admin.analytics.index')" :current="request()->routeIs('admin.analytics.*')" wire:navigate>
                        {{ __('Analytics') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="squares-2x2" :href="route('admin.apps.index')" :current="request()->routeIs('admin.apps.*')" wire:navigate>
                        {{ __('Apps') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="code-bracket" :href="route('admin.developers.index')" :current="request()->routeIs('admin.developers.*')" wire:navigate>
                        {{ __('Developers') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Products')" class="grid">
                    <flux:sidebar.item icon="cube" :href="route('admin.products.index')" :current="request()->routeIs('admin.products.*')" wire:navigate>
                        {{ __('Products') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="rectangle-stack" :href="route('admin.collections.index')" :current="request()->routeIs('admin.collections.*')" wire:navigate>
                        {{ __('Collections') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="archive-box" :href="route('admin.inventory.index')" :current="request()->routeIs('admin.inventory.*')" wire:navigate>
                        {{ __('Inventory') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Operations')" class="grid">
                    <flux:sidebar.item icon="shopping-bag" :href="route('admin.orders.index')" :current="request()->routeIs('admin.orders.*')" wire:navigate>
                        {{ __('Orders') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="users" :href="route('admin.customers.index')" :current="request()->routeIs('admin.customers.*')" wire:navigate>
                        {{ __('Customers') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="tag" :href="route('admin.discounts.index')" :current="request()->routeIs('admin.discounts.*')" wire:navigate>
                        {{ __('Discounts') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Storefront')" class="grid">
                    <flux:sidebar.item icon="document-text" :href="route('admin.pages.index')" :current="request()->routeIs('admin.pages.*')" wire:navigate>
                        {{ __('Pages') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="bars-3" :href="route('admin.navigation.index')" :current="request()->routeIs('admin.navigation.*')" wire:navigate>
                        {{ __('Navigation') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="paint-brush" :href="route('admin.themes.index')" :current="request()->routeIs('admin.themes.*')" wire:navigate>
                        {{ __('Themes') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Configuration')" class="grid">
                    <flux:sidebar.item icon="cog-6-tooth" :href="route('admin.settings.index')" :current="request()->routeIs('admin.settings.*')" wire:navigate>
                        {{ __('Settings') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="magnifying-glass" :href="route('admin.search.settings')" :current="request()->routeIs('admin.search.*')" wire:navigate>
                        {{ __('Search') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>
            </flux:sidebar.nav>

            <flux:spacer />

            <flux:sidebar.nav>
                <flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
                    {{ __('Repository') }}
                </flux:sidebar.item>

                <flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
                    {{ __('Documentation') }}
                </flux:sidebar.item>
            </flux:sidebar.nav>

            <x-desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" />
        </flux:sidebar>

        <flux:header class="lg:hidden">
            <flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />

            <flux:spacer />

            <flux:dropdown position="top" align="end">
                <flux:profile
                    :initials="auth()->user()->initials()"
                    icon-trailing="chevron-down"
                />

                <flux:menu>
                    <flux:menu.radio.group>

                                <flux:avatar
                                    :name="auth()->user()->name"
                                    :initials="auth()->user()->initials()"
                                />

                                    <flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
                                    <flux:text class="truncate">{{ auth()->user()->email }}</flux:text>

                    </flux:menu.radio.group>

                    <flux:menu.separator />

                    <flux:menu.radio.group>
                        <flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
                            {{ __('Settings') }}
                        </flux:menu.item>
                    </flux:menu.radio.group>

                    <flux:menu.separator />

                    <form method="POST" action="{{ $logoutRoute }}" class="w-full">
                        @csrf
                        <flux:menu.item
                            as="button"
                            type="submit"
                            icon="arrow-right-start-on-rectangle"
                            class="w-full cursor-pointer"
                            data-test="logout-button"
                        >
                            {{ __('Log Out') }}
                        </flux:menu.item>
                    </form>
                </flux:menu>
            </flux:dropdown>
        </flux:header>

status: completed


output:
Chunk ID: 4b0ffa
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2385
Output:
@php($logoutRoute = request()->routeIs('admin.*') ? route('admin.logout') : route('logout'))

<flux:dropdown position="bottom" align="start">
    <flux:sidebar.profile
        {{ $attributes->only('name') }}
        :initials="auth()->user()->initials()"
        icon:trailing="chevrons-up-down"
        data-test="sidebar-menu-button"
    />

    <flux:menu>

            <flux:avatar
                :name="auth()->user()->name"
                :initials="auth()->user()->initials()"
            />

                <flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
                <flux:text class="truncate">{{ auth()->user()->email }}</flux:text>

        <flux:menu.separator />
        <flux:menu.radio.group>
            <flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
                {{ __('Settings') }}
            </flux:menu.item>
            <form method="POST" action="{{ $logoutRoute }}" class="w-full">
                @csrf
                <flux:menu.item
                    as="button"
                    type="submit"
                    icon="arrow-right-start-on-rectangle"
                    class="w-full cursor-pointer"
                    data-test="logout-button"
                >
                    {{ __('Log Out') }}
                </flux:menu.item>
            </form>
        </flux:menu.radio.group>
    </flux:menu>
</flux:dropdown>
<!DOCTYPE html>
getLocale()) }}" class="dark">

        @include('partials.head')

        @php($logoutRoute = request()->routeIs('admin.*') ? route('admin.logout') : route('logout'))

        <flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
            <flux:sidebar.header>
                <x-app-logo :sidebar="true" href="{{ route('admin.dashboard') }}" wire:navigate />
                <flux:sidebar.collapse class="lg:hidden" />
            </flux:sidebar.header>

            <flux:sidebar.nav>
                <flux:sidebar.group :heading="__('Platform')" class="grid">
                    <flux:sidebar.item icon="home" :href="route('admin.dashboard')" :current="request()->routeIs('admin.dashboard')" wire:navigate>
                        {{ __('Dashboard') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="chart-bar" :href="route('admin.analytics.index')" :current="request()->routeIs('admin.analytics.*')" wire:navigate>
                        {{ __('Analytics') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="squares-2x2" :href="route('admin.apps.index')" :current="request()->routeIs('admin.apps.*')" wire:navigate>
                        {{ __('Apps') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="code-bracket" :href="route('admin.developers.index')" :current="request()->routeIs('admin.developers.*')" wire:navigate>
                        {{ __('Developers') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Products')" class="grid">
                    <flux:sidebar.item icon="cube" :href="route('admin.products.index')" :current="request()->routeIs('admin.products.*')" wire:navigate>
                        {{ __('Products') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="rectangle-stack" :href="route('admin.collections.index')" :current="request()->routeIs('admin.collections.*')" wire:navigate>
                        {{ __('Collections') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="archive-box" :href="route('admin.inventory.index')" :current="request()->routeIs('admin.inventory.*')" wire:navigate>
                        {{ __('Inventory') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Operations')" class="grid">
                    <flux:sidebar.item icon="shopping-bag" :href="route('admin.orders.index')" :current="request()->routeIs('admin.orders.*')" wire:navigate>
                        {{ __('Orders') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="users" :href="route('admin.customers.index')" :current="request()->routeIs('admin.customers.*')" wire:navigate>
                        {{ __('Customers') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="tag" :href="route('admin.discounts.index')" :current="request()->routeIs('admin.discounts.*')" wire:navigate>
                        {{ __('Discounts') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Storefront')" class="grid">
                    <flux:sidebar.item icon="document-text" :href="route('admin.pages.index')" :current="request()->routeIs('admin.pages.*')" wire:navigate>
                        {{ __('Pages') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="bars-3" :href="route('admin.navigation.index')" :current="request()->routeIs('admin.navigation.*')" wire:navigate>
                        {{ __('Navigation') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="paint-brush" :href="route('admin.themes.index')" :current="request()->routeIs('admin.themes.*')" wire:navigate>
                        {{ __('Themes') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Configuration')" class="grid">
                    <flux:sidebar.item icon="cog-6-tooth" :href="route('admin.settings.index')" :current="request()->routeIs('admin.settings.*')" wire:navigate>
                        {{ __('Settings') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="magnifying-glass" :href="route('admin.search.settings')" :current="request()->routeIs('admin.search.*')" wire:navigate>
                        {{ __('Search') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>
            </flux:sidebar.nav>

            <flux:spacer />

            <flux:sidebar.nav>
                <flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
                    {{ __('Repository') }}
                </flux:sidebar.item>

                <flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
                    {{ __('Documentation') }}
                </flux:sidebar.item>
            </flux:sidebar.nav>

            <x-desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" />
        </flux:sidebar>

        <flux:header class="lg:hidden">
            <flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />

            <flux:spacer />

            <flux:dropdown position="top" align="end">
                <flux:profile
                    :initials="auth()->user()->initials()"
                    icon-trailing="chevron-down"
                />

                <flux:menu>
                    <flux:menu.radio.group>

                                <flux:avatar
                                    :name="auth()->user()->name"
                                    :initials="auth()->user()->initials()"
                                />

                                    <flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
                                    <flux:text class="truncate">{{ auth()->user()->email }}</flux:text>

                    </flux:menu.radio.group>

                    <flux:menu.separator />

                    <flux:menu.radio.group>
                        <flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
                            {{ __('Settings') }}
                        </flux:menu.item>
                    </flux:menu.radio.group>

                    <flux:menu.separator />

                    <form method="POST" action="{{ $logoutRoute }}" class="w-full">
                        @csrf
                        <flux:menu.item
                            as="button"
                            type="submit"
                            icon="arrow-right-start-on-rectangle"
                            class="w-full cursor-pointer"
                            data-test="logout-button"
                        >
                            {{ __('Log Out') }}
                        </flux:menu.item>
                    </form>
                </flux:menu>
            </flux:dropdown>
        </flux:header>


command: rg -n "admin authentication|Suite 2|256 tests|Open Issues|Automated browser coverage" specs/progress.md

type: search

cmd: rg -n 'admin authentication|Suite 2|256 tests|Open Issues|Automated browser coverage' specs/progress.md

query: admin authentication|Suite 2|256 tests|Open Issues|Automated browser coverage

aggregated_output:
36:| Browser E2E plan | `specs/08-PLAYWRIGHT-E2E-PLAN.md` | partial | Manual Playwright MCP smoke coverage has verified storefront catalog browsing on `shop.test` and `acme-fashion.test`, product detail, add-to-cart, cart drawer/page, checkout through order completion, order confirmation, customer order list/detail, SQLite-backed search and search API JSON, analytics event ingestion API, DB-backed content pages, seeded nested navigation dropdowns, admin dashboard on desktop/mobile, admin analytics, admin app list/detail, admin developers token/webhook flows, admin product/collection/inventory/order/customer/discount/content/navigation/settings/theme/search pages, admin checkout/notification settings pages, admin product media section rendering, admin analytics CSV export, admin customer address creation, admin discount creation/editing/filtering, admin page creation, admin nested navigation rendering/item creation/save, admin theme editor preview, admin theme file list/editor, admin search reindexing, admin fulfillment creation/shipping/delivery, and auth flows without console warnings/errors on successful pages. Automated Pest browser coverage now checks storefront core pages, storefront account auth/reset pages including the customer root reset paths and account aliases, authenticated admin navigation/pages/settings/theme/search/apps/developers surfaces, Suite 2 admin authentication login/validation/redirect/logout/sidebar navigation interactions, and mobile storefront rendering for JavaScript errors. The remaining Spec 08 interaction/permutation browser suites are still incomplete. |
430:- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing and Laravel auth/session testing docs before adding the Spec 08 admin authentication browser suite.
431:- 2026-05-04: `php artisan make:test Browser/Admin/AuthenticationTest --pest --no-interaction` created the admin authentication browser test, which was moved into the Spec 08 browser directory structure.
432:- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the admin authentication browser suite and admin logout route fix.
433:- 2026-05-04: `php artisan test --compact tests/Browser/Admin/AuthenticationTest.php` passed for the new Suite 2 admin auth browser coverage: 10 tests, 54 assertions.
435:- 2026-05-04: `php artisan test --compact` passed after adding the admin auth browser suite: 256 tests, 1527 assertions.
505:- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, and mobile storefront rendering.
507:## Open Issues
513:Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, deferred OAuth/app route stubs, expanded automated browser smoke coverage, Suite 2 admin authentication browser coverage, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented, with the remaining full browser-suite gap tracked above.


output:
Chunk ID: a94530
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 963
Output:
36:| Browser E2E plan | `specs/08-PLAYWRIGHT-E2E-PLAN.md` | partial | Manual Playwright MCP smoke coverage has verified storefront catalog browsing on `shop.test` and `acme-fashion.test`, product detail, add-to-cart, cart drawer/page, checkout through order completion, order confirmation, customer order list/detail, SQLite-backed search and search API JSON, analytics event ingestion API, DB-backed content pages, seeded nested navigation dropdowns, admin dashboard on desktop/mobile, admin analytics, admin app list/detail, admin developers token/webhook flows, admin product/collection/inventory/order/customer/discount/content/navigation/settings/theme/search pages, admin checkout/notification settings pages, admin product media section rendering, admin analytics CSV export, admin customer address creation, admin discount creation/editing/filtering, admin page creation, admin nested navigation rendering/item creation/save, admin theme editor preview, admin theme file list/editor, admin search reindexing, admin fulfillment creation/shipping/delivery, and auth flows without console warnings/errors on successful pages. Automated Pest browser coverage now checks storefront core pages, storefront account auth/reset pages including the customer root reset paths and account aliases, authenticated admin navigation/pages/settings/theme/search/apps/developers surfaces, Suite 2 admin authentication login/validation/redirect/logout/sidebar navigation interactions, and mobile storefront rendering for JavaScript errors. The remaining Spec 08 interaction/permutation browser suites are still incomplete. |
430:- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing and Laravel auth/session testing docs before adding the Spec 08 admin authentication browser suite.
431:- 2026-05-04: `php artisan make:test Browser/Admin/AuthenticationTest --pest --no-interaction` created the admin authentication browser test, which was moved into the Spec 08 browser directory structure.
432:- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding the admin authentication browser suite and admin logout route fix.
433:- 2026-05-04: `php artisan test --compact tests/Browser/Admin/AuthenticationTest.php` passed for the new Suite 2 admin auth browser coverage: 10 tests, 54 assertions.
435:- 2026-05-04: `php artisan test --compact` passed after adding the admin auth browser suite: 256 tests, 1527 assertions.
505:- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, and mobile storefront rendering.
507:## Open Issues
513:Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, deferred OAuth/app route stubs, expanded automated browser smoke coverage, Suite 2 admin authentication browser coverage, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented, with the remaining full browser-suite gap tracked above.


cmd: git add tests/Browser/Admin/AuthenticationTest.php resources/views/components/desktop-user-menu.blade.php resources/views/layouts/app/sidebar.blade.php resources/views/livewire/admin/settings/index.blade.php specs/progress.md && git commit -m "Add admin auth browser suite"


command: git add tests/Browser/Admin/AuthenticationTest.php resources/views/components/desktop-user-menu.blade.php resources/views/layouts/app/sidebar.blade.php resources/views/livewire/admin/settings/index.blade.php specs/progress.md && git commit -m "Add admin auth browser suite"

type: unknown

aggregated_output:
[2026-05-04-codex-5-5-goal 7633c0be] Add admin auth browser suite
 5 files changed, 152 insertions(+), 6 deletions(-)
 create mode 100644 tests/Browser/Admin/AuthenticationTest.php


output:
Chunk ID: e0f8e1
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 46
Output:
[2026-05-04-codex-5-5-goal 7633c0be] Add admin auth browser suite
 5 files changed, 152 insertions(+), 6 deletions(-)
 create mode 100644 tests/Browser/Admin/AuthenticationTest.php


cmd: find .playwright-mcp tests/Browser/Screenshots -maxdepth 2 -type f -print 2>/dev/null | rg '2026-05|can_navigate|Authentication' || true


command:
/bin/zsh
-lc
git log --oneline -6

cmd: git log --oneline -6

aggregated_output:
7633c0be Add admin auth browser suite
0c44c7e9 Add customer root password reset routes
d258a666 Add admin password reset aliases
256e2aff Expand browser smoke coverage
007550a9 Verify database enum constraints
cf3288fe Add admin order export API


command:
/bin/zsh
-lc
git status --short

cmd: git status --short


command: find .playwright-mcp tests/Browser/Screenshots -maxdepth 2 -type f -print 2>/dev/null | rg '2026-05|can_navigate|Authentication' || true


output:
Chunk ID: be5500
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


output:
Chunk ID: 38dbbb
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 62
Output:
7633c0be Add admin auth browser suite
0c44c7e9 Add customer root password reset routes
d258a666 Add admin password reset aliases
256e2aff Expand browser smoke coverage
007550a9 Verify database enum constraints
cf3288fe Add admin order export API


output:
Chunk ID: 7c138d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


message: The admin auth browser slice is committed as `7633c0be`, and the worktree is clean again. I’m continuing with the next self-contained Spec 08 browser suite: storefront browsing, which exercises catalog/search/page behavior without needing checkout setup.

phase: commentary


type: message

role: assistant

text: The admin auth browser slice is committed as `7633c0be`, and the worktree is clean again. I’m continuing with the next self-contained Spec 08 browser suite: storefront browsing, which exercises catalog/search/page behavior without needing checkout setup.


queries: Pest browser testing click fill select assert dont see

queries: Livewire browser testing full page components interaction

queries: Laravel browser tests seeded database RefreshDatabase


server: laravel-boost

tool: search-docs

type: text

text:
# Search Results

## Database Testing Running Seeders
Database Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md

## Running Seeders

If you would like to use [database seeders](/docs/{{version}}/seeding) to populate your database during a feature test, you may invoke the `seed` method. By default, the `seed` method will execute the `DatabaseSeeder`, which should execute all of your other seeders. Alternatively, you pass a specific seeder class name to the `seed` method:

```php tab=Pest
<?php

use Database\Seeders\OrderStatusSeeder;
use Database\Seeders\TransactionStatusSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

pest()->use(RefreshDatabase::class);

test('orders can be created', function () {
    // Run the DatabaseSeeder...
    $this->seed();

    // Run a specific seeder...
    $this->seed(OrderStatusSeeder::class);

    // ...

    // Run an array of specific seeders...
    $this->seed([
        OrderStatusSeeder::class,
        TransactionStatusSeeder::class,
        // ...
    ]);
});
```

```php tab=PHPUnit
<?php

namespace Tests\Feature;

use Database\Seeders\OrderStatusSeeder;
use Database\Seeders\TransactionStatusSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    use RefreshDatabase;

    /**
     * Test creating a new order.
     */
    public function test_orders_can_be_created(): void
    {
        // Run the DatabaseSeeder...
        $this->seed();

        // Run a specific seeder...
        $this->seed(OrderStatusSeeder::class);

        // ...

        // Run an array of specific seeders...
        $this->seed([
            OrderStatusSeeder::class,
            TransactionStatusSeeder::class,
            // ...
        ]);
    }
}
```

Alternatively, you may instruct Laravel to automatically seed the database before each test that uses the `RefreshDatabase` trait. You may accomplish this by defining a `$seed` property on your base test class:

```php
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    /**
     * Indicates whether the default seeder should run before each test.
     *
     * @var bool
     */
    protected $seed = true;
}
```

When the `$seed` property is `true`, the test will run the `Database\Seeders\DatabaseSeeder` class before each test that uses the `RefreshDatabase` trait. However, you may specify a specific seeder that should be executed by defining a `$seeder` property on your test class:

```php
use Database\Seeders\OrderStatusSeeder;

/**
 * Run a specific seeder before each test.
 *
 * @var string
 */
protected $seeder = OrderStatusSeeder::class;
```

---

## Wire Transition Browser support
Wire Transition | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-transition

## Browser support

View Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+. In browsers that don't support View Transitions, elements will appear and disappear without animation—the functionality still works, just without the visual transition.

> [!warning] Firefox has limited support
> Firefox 144+ supports basic view transitions, but does not support transition types.

[View browser support on caniuse.com →](https://caniuse.com/view-transitions)

---

## Tia
Tia | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/tia.md

---
title: Test Impact Analysis (TIA)
description: Test Impact Analysis is a great way to drastically reduce the time it takes to run your test suite by re-running only the tests affected by your latest changes.
---

# Test Impact Analysis (TIA)

> **Heads up — please keep this between us.** Test Impact Analysis has shipped quietly in Pest 4 so a small group of teams can battle-test it on real suites before we put it on stage. The official, public launch is reserved for **Pest 5**.
>
> If you're reading this, you're getting an early look as a thank-you for being part of the Pest community. We'd love your feedback, bug reports, and edge cases — but we kindly ask that you **don't share this publicly** (no tweets, blog posts, livestreams, or conference demos) until the Pest 5 announcement. Internal use within your team is absolutely fine.
>
> Thanks for helping us get this right.

Test Impact Analysis is a great way to drastically reduce the time it takes to run your test suite by re-running only the tests affected by your latest changes. The first time you run with `--tia`, Pest records a graph of which tests depend on which files. Every run after that, Pest looks at what you changed, runs only the tests that touched those files, and replays cached results for everything else.

A typical Laravel suite that takes 15 seconds replays in under a second. Edits to a single Blade template re-run a handful of feature tests. Comment-only edits, formatter passes, and README touches re-run nothing at all.

To get started, just add the `--tia` flag to any Pest invocation:

```bash
./vendor/bin/pest --parallel --tia
```

The first run is the **baseline** — Pest enables a coverage driver (PCOV or Xdebug) and records the dependency graph as your tests execute. Expect a small overhead on this run only.

Every subsequent run is a **replay**. Pest compares your working tree against the baseline and re-runs only the tests affected by your changes:

```
Tests:    774 passed (2658 assertions, 7 affected, 2 uncached, 765 replayed)
Duration: 0.74s
```

`affected` is the set of tests Pest re-ran because their dependencies changed. `uncached` means Pest had to execute a test because no cached result existed yet. `replayed` is the set whose results were served from cache.

## How Pest Decides What To Run

For each file you've changed, Pest looks for tests that depend on it:

- **PHP source files** — your `app/` classes, controllers, models, helpers — are tracked through the coverage driver. A change to `app/Models/User.php` re-runs only the tests that touched `User`.
- **Migrations** are intersected with the tables each test queried during the baseline. A column rename in `create_users_table.php` re-runs only the tests that queried the `users` table.
- **Inertia pages** under `resources/js/Pages` re-run only the tests that server-side-rendered them.
- **Shared JS components** under `resources/js/Components`, `Layouts`, and friends re-run only the tests whose pages import them — Pest walks Vite's module graph to figure this out.
- **Frontend runtime files** like `resources/js/App.jsx`, `resources/js/bootstrap.js`, `resources/js/echo.js`, and `resources/js/favicon.js` re-run tests that rendered Inertia components, because they can affect the whole client runtime.
- **Blade templates** re-run only the tests that rendered them, including renders triggered by browser tests.
- **Arch tests** re-run for project PHP source changes, because Arch expectations inspect files by namespace and path instead of executing those files.
- **Browser assets** such as CSS, public build files, static public assets, and `public/hot` re-run browser tests only.
- **Anything else** — config files, route files, fixture data, files outside the recorded graph — falls through to a broader pattern. Editing `config/app.php` re-runs every test, because Pest can't statically prove which tests depend on it.

Some files change the shape of the graph itself rather than a single test result. Pest rebuilds the graph when structural inputs drift, including `composer.json`, `composer.lock`, `phpunit.xml`, `vite.config.*`, `package.json`, Node lockfiles, and `tsconfig` / `jsconfig` files. Environment files such as `.env`, `.env.testing`, and local variants drop cached results and re-execute the suite while keeping the graph.

## Cosmetic Edits Don't Run Anything

Pest normalises file content before comparing, so cosmetic changes don't trigger any tests. PHP files have whitespace, line comments, and docblocks stripped before hashing. Blade strips `{{-- … --}}` comments. JS, TS, Vue, and Svelte files have line and block comments removed too.

The result: a comment-only edit, a Prettier reformat, a Pint pass, or a README tweak produces an identical hash, and the file never enters the changed set. Zero tests run.

## Built-in Environments

Pest ships with watch defaults for the most common PHP stacks, and applies them automatically when their packages are installed:

- **PHP** — always-on baseline rules for `composer.json`, `composer.lock`, `phpunit.xml`, and similar structural inputs.
- **Laravel** — `app/`, `routes/`, `config/`, `database/migrations/`, `resources/views/`, and friends.
- **Symfony** — `config/`, `migrations/`, `src/Migrations/`, `templates/`, `translations/`, `config/doctrine/`, `assets/`, `webpack.config.js`, and `importmap.php`.
- **Livewire** — `resources/views/livewire/`, `resources/views/components/`, `resources/views/pages/`, plus JS/TS under `resources/js/`.
- **Inertia** — server-side-rendered pages under `resources/js/Pages` and the Vite module graph for `Components`, `Layouts`, and runtime entry files.
- **Browser** — CSS, public build files, static public assets, and `public/hot` re-run browser tests only.

You don't have to configure anything to opt in to these — Pest detects each framework via Composer and merges the relevant rules. To extend or override them, see [Custom Watch Patterns](#custom-watch-patterns).

## Modes

Pest supports a few flags alongside `--tia`:

| Flag | Behaviour |
|---|---|
| `--tia` | Replay if a baseline graph exists, otherwise record. |
| `--no-tia` | Disable TIA for this run, even if `pest()->tia()->always()` is configured. |
| `--tia --fresh` | Discard any existing graph and re-record from scratch. Use this after large refactors or when the graph feels stale. |
| `--tia --refetch` | Force a CI baseline fetch even within the 24-hour cooldown after a previous failed fetch. |
| `--tia --filtered` | Narrow PHPUnit to only the affected test files rather than loading all tests and replaying cached results for unaffected ones. |
| `--tia --locally` | Equivalent to `pest()->tia()->always()->locally()` — run TIA automatically on local machines but skip on CI. |
| `--tia --baselined` | Opt in to fetching the shared baseline from CI when no local graph exists or the local graph drifts. |
| `--baseline` | Print the absolute path of this project's TIA storage directory and exit. Designed for CI uploads — see [Sharing The Baseline From CI](#sharing-the-baseline-from-ci). |

### Environment Variables

Each enabling flag has an environment variable equivalent, useful for CI matrices, container entry points, and shared developer configs:

| Variable | Equivalent flag |
|---|---|
| `PEST_TIA=1` | `--tia` |
| `PEST_TIA_FILTERED=1` | `--filtered` |
| `PEST_TIA_LOCALLY=1` | `--locally` |
| `PEST_TIA_BASELINED=1` | `--baselined` |

## Sharing The Baseline From CI

Recording the baseline locally takes minutes on large suites. Instead, you can have CI record it once per merge to `main`, and every developer downloads the result.

Baseline fetching is **opt-in**. Enable it either with `--tia --baselined` on the command line, the `PEST_TIA_BASELINED=1` environment variable, or — preferred for teams — by calling `pest()->tia()->baselined()` in `tests/Pest.php`. Once enabled, when Pest detects no local graph (or the local graph is out of date) it uses GitHub's CLI to download the latest successful run of a `tia-baseline.yml` workflow's `pest-tia-baseline` artifact. Pest validates the fetched graph against your project state — if it matches, it's adopted. Otherwise it's discarded and a local rebuild proceeds.

Here's a starter workflow you can drop into `.github/workflows/tia-baseline.yml`:

```yaml
name: TIA Baseline
on:
  push: { branches: [main] }
  schedule: [{ cron: '0 3 * * *' }]
  workflow_dispatch:
jobs:
  baseline:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.4', coverage: xdebug }
      - run: composer install --no-interaction --prefer-dist

      - name: Run tests
        run: ./vendor/bin/pest --parallel --tia --coverage

      - name: Resolve TIA baseline path
        id: baseline
        run: echo "path=$(vendor/bin/pest --baseline)" >> "$GITHUB_OUTPUT"

      - name: Upload TIA baseline
        uses: actions/upload-artifact@v4
        with:
          name: pest-tia-baseline
          path: ${{ steps.baseline.outputs.path }}
          include-hidden-files: true
          retention-days: 30
```

`vendor/bin/pest --baseline` prints the absolute path to this project's TIA storage directory (typically `~/.pest/tia/<project-key>/`), which is exactly what `actions/upload-artifact` needs to bundle the recorded graph and coverage cache. `include-hidden-files: true` is required because the baseline lives under a dot-prefixed directory.

After CI runs, every developer with `baselined()` enabled who runs `pest --tia` for the first time on the repo will download this baseline and start replaying immediately, paying no record cost.

## Storage

Pest stores its state at `~/.pest/tia/<project-key>/`, where the project key is derived from your normalised git remote URL — so `git@github.com:foo/bar.git` and `https://github.com/foo/bar` produce the same key. A non-git project falls back to a hash of the project's absolute path.

Sharing state per remote URL means multiple worktrees of the same repository share one cache, while unrelated projects on the same machine stay isolated.

## Configuration

You can configure TIA behaviour in `tests/Pest.php` via `pest()->tia()`:

```php
pest()->tia()
    ->always()     // run TIA on every invocation, no --tia flag needed
    ->locally()    // restrict always() to local environments only
    ->baselined()  // fetch the shared baseline from CI when no local graph exists
    ->filtered();  // narrow PHPUnit to only affected test files
```

**`always()`** activates TIA for every `pest` run without requiring the `--tia` flag. Pair it with **`locally()`** to restrict that behaviour to local machines — on CI (detected via the `--ci` flag or the `CI` environment variable) TIA is skipped automatically. An explicit `--tia` on the command line always takes effect regardless, and `--no-tia` can disable it for a single run.

**`filtered()`** enables filtered mode, equivalent to `--tia --filtered`. In this mode Pest narrows PHPUnit to only the affected test files rather than loading the full suite and replaying cached results for unaffected tests:

```php
pest()->tia()->filtered();
```

**`baselined()`** opts in to fetching the shared TIA baseline from CI when no local graph exists or the local graph drifts. See [Sharing The Baseline From CI](#sharing-the-baseline-from-ci) for the recommended workflow:

```php
pest()->tia()->baselined();
```

## Custom Watch Patterns

If your project has a directory layout that doesn't match the framework defaults, you can register custom watch patterns in `tests/Pest.php`:

```php
pest()->tia()->watch([
    'config/billing/**/*.php' => 'tests/Feature/Billing',
    'public/build/**/*'       => 'tests/Browser',
]);
```

Each glob maps to a test directory or an exact test file. Whenever a matching file changes, every test under that directory is invalidated. If a glob already exists in Pest's defaults, your target is merged with the existing targets instead of replacing them.

---

Now that you've learned how to use Test Impact Analysis to speed up your test suite, let's discuss how to integrate Pest with your continuous integration workflow: [Continuous Integration](/docs/continuous-integration)

---

## Testing Browser testing
Testing | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing

## Browser testing

Pest v4 includes first-party browser testing support powered by Playwright. This allows you to test your Livewire components in a real browser, interacting with them just like a user would.

### Installing browser testing

First, install the Pest browser plugin:

```shell
composer require pestphp/pest-plugin-browser --dev
```

Next, install Playwright via npm:

```shell
npm install playwright@latest
npx playwright install
```

For complete browser testing documentation, see the [Pest browser testing guide](https://pestphp.com/docs/browser-testing).

### Writing browser tests

Instead of using `Livewire::test()`, you can use `Livewire::visit()` to test your component in a real browser:

```php
it('can create a new post', function () {
    Livewire::visit('post.create')
        ->type('[wire\:model="title"]', 'My first post')
        ->type('[wire\:model="content"]', 'This is the content')
        ->press('Save')
        ->assertSee('Post created successfully');
});
```

Browser tests are slower than unit tests but provide end-to-end confidence that your components work as expected in a real browser environment.

For a complete list of available browser testing assertions, see the [Pest browser testing assertions](https://pestphp.com/docs/browser-testing#content-available-assertions).

> [!info] When to use browser tests
> Use browser tests for critical user flows and complex interactions. For most component testing, the standard `Livewire::test()` approach is faster and sufficient.

---

## Database Testing Introduction Resetting the Database After Each Test
Database Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md

### Resetting the Database After Each Test

Before proceeding much further, let's discuss how to reset your database after each of your tests so that data from a previous test does not interfere with subsequent tests. Laravel's included `Illuminate\Foundation\Testing\RefreshDatabase` trait will take care of this for you. Simply use the trait on your test class:

```php tab=Pest
<?php

use Illuminate\Foundation\Testing\RefreshDatabase;

pest()->use(RefreshDatabase::class);

test('basic example', function () {
    $response = $this->get('/');

    // ...
});
```

```php tab=PHPUnit
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    use RefreshDatabase;

    /**
     * A basic functional test example.
     */
    public function test_basic_example(): void
    {
        $response = $this->get('/');

        // ...
    }
}
```

The `Illuminate\Foundation\Testing\RefreshDatabase` trait does not migrate your database if your schema is up to date. Instead, it will only execute the test within a database transaction. Therefore, any records added to the database by test cases that do not use this trait may still exist in the database.

If you would like to totally reset the database, you may use the `Illuminate\Foundation\Testing\DatabaseMigrations` or `Illuminate\Foundation\Testing\DatabaseTruncation` traits instead. However, both of these options are significantly slower than the `RefreshDatabase` trait.

---

## Database Testing
Database Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md

# Database Testing

- [Introduction](#introduction)
    - [Resetting the Database After Each Test](#resetting-the-database-after-each-test)
- [Model Factories](#model-factories)
- [Running Seeders](#running-seeders)
- [Available Assertions](#available-assertions)

---

## Browser Testing assertNotSelected
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### assertNotSelected

The `assertNotSelected` method asserts that the given dropdown does not have the given value selected:

```php
$page->assertNotSelected('country', 'UK');
```

---

## Styles Browser support
Styles | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/styles

## Browser support

Scoped styles use [CSS nesting](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting), which is supported in all modern browsers (Chrome 120+, Firefox 117+, Safari 17.2+). For older browser support, consider using a CSS preprocessor or the `@assets` directive with pre-compiled stylesheets.

---

## Installation Components not updating or errors in browser console
Installation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/installation

## Components not updating or errors in browser console

**Check the following:**
- Ensure `@livewireStyles` is in the `<head>` of your layout
- Ensure `@livewireScripts` is before `</body>` in your layout
- Check your browser's developer console for JavaScript errors
- Verify you're running a supported PHP version (8.1+) and Laravel version (10+)
- Clear your application cache: `php artisan cache:clear`

If issues persist, check the [troubleshooting documentation](/docs/4.x/troubleshooting) for more detailed debugging steps.

---

## Browser Testing waitForKey
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### waitForKey

The `waitForKey` method opens the current page URL in the default web browser and waits for a key press:

```php
$page->waitForKey(); // Useful for debugging
```

## Debugging tests

Sometimes you may want to debug your browser tests. Pest provides a convenient way to do this by using the `--debug` option, which makes pest to open the browser window and pause the execution of the test when it fails. You can then inspect the page and see what went wrong.

```bash
./vendor/bin/pest --debug
```

Optionally, you can also use the `debug()` method in your test. It will limit execution to this test (like using [`only()`](filtering-tests.md#only)), pause the execution and open the browser window:

```php
$page->debug();
```

You can also take a screenshot of the current page using the `screenshot()` method. This is useful for visual debugging:

NOTE: If you don't pass the filename, it will use the test name as the filename.

```php
$page->screenshot();
$page->screenshot(fullPage: true);
$page->screenshot(filename: 'custom-name');
```

You can also take a screenshot of a specific element using the `screenshotElement()` method:

```php
$page->screenshotElement('#my-element');
```

You can also use the `tinker()` method to open a Tinker session in the context of the current page. This allows you to interact with the page using PHP code:

```php
$page->tinker();
```

After you can run your tests with the `--headed` option to open the browser window:

```bash
./vendor/bin/pest --headed
```

If you wish to run the tests in a headed mode by default, you can set it in your `Pest.php` configuration file:

```php
pest()->browser()->headed();
```

## Continuous Integration

You may refer to Pest's [Continuous Integration](https://pestphp.com/docs/continuous-integration) documentation for more information on how to run your browser tests in a CI environment.

However, if you are using GitHub Actions, you need to add the following steps to your workflow file:

```yaml
    - uses: actions/setup-node@v4
      with:
        node-version: lts/*

    - name: Install dependencies
      run: npm ci

    - name: Install Playwright Browsers
      run: npx playwright install --with-deps
```

---

Now, let's dive into architectural testing and how it can benefit your development process. By performing architectural testing, you can evaluate the overall design of your application and identify potential flaws before they become significant issues: [Arch Testing](/docs/arch-testing)

---

## Browser Testing assertSelected
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### assertSelected

The `assertSelected` method asserts that the given dropdown has the given value selected:

```php
$page->assertSelected('country', 'US');
```

---

## Browser Testing
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

---
title: Browser Testing
description:
---

# Browser Testing

Browser testing is an essential part of modern web development, allowing you to ensure that your application works correctly across different browsers and devices. Pest provides a simple and elegant way to write browser tests. Here is an example of how to write a browser test using Pest:

```php
it('may welcome the user', function () {
    $page = visit('/');

    $page->assertSee('Welcome');
});
```

This is a basic example of a browser test that checks if the homepage contains the text "Welcome". However, Pest's browser testing capabilities go beyond this simple example. You can use various methods to interact with the page, such as clicking buttons, filling out forms, and navigating between pages.

Here is an example of a more complex browser test, on Laravel, that checks if a user can sign in:

```php
it('may sign in the user', function () {
    Event::fake();

    User::factory()->create([ // assumes RefreshDatabase trait is used on Pest.php...
        'email' => 'nuno@laravel.com',
        'password' => 'password',
    ]);

    $page = visit('/')->on()->mobile()->firefox();

    $page->click('Sign In')
         ->assertUrlIs('/login')
         ->assertSee('Sign In to Your Account')
         ->fill('email', 'nuno@laravel.com')
         ->fill('password', 'password')
         ->click('Submit')
         ->assertSee('Dashboard');

    $this->assertAuthenticated();

    Event::assertDispatched(UserLoggedIn::class);
});
```

Note that, you are leveraging the full power of Laravel's testing capabilities, such as refresh database, event faking, and authentication assertions, while also actually doing browser testing.

## Getting Started

To get started with browser testing in Pest, you need to install the Pest Browser plugin. You can do this by running the following command:

```bash
composer require pestphp/pest-plugin-browser --dev

npm install playwright@latest
npx playwright install
```

Finally, add `tests/Browser/Screenshots` to your `.gitignore` file to avoid committing screenshots taken during browser tests.

### Running Browser Tests

Running browser tests is similar to running regular Pest tests:

```bash
./vendor/bin/pest
```

We recommend running tests in parallel using the `--parallel` option to speed up the execution:

```bash
./vendor/bin/pest --parallel
```

For debugging purposes, you can run the tests in a headed mode and pause the execution at the end of the failed test run:

```bash
./vendor/bin/pest --debug
```

### Navigation

The `visit()` method is used to navigate to a specific URL in your browser test. It provides various methods to interact with the page:

```php
test('example', function () {
    $page = visit('/');

    $page->assertSee('Welcome');
});
```

### Using Other Browsers

By default, the `visit()` method uses Chrome as the browser. However, if you want to use a different browser, you can specify it using the `--browser` option when running the tests:

```bash
./vendor/bin/pest --browser firefox
./vendor/bin/pest --browser safari
```

If you wish to use a different browser by default without specifying it in the command line, you can set it in your `Pest.php` configuration file:

```php
pest()->browser()->inFirefox();
pest()->browser()->inSafari();
```

### Using Other Devices

The `visit()` method uses a desktop viewport. However, you can specify a mobile viewport using the `onMobile()` method. For example:

```php
$page = visit('/')->on()->mobile();
```

If you wish to use a specific device, you can use the `on()` method and chain it with the `macbook14`, `iPhone14Pro`, etc:

```php
$page = visit('/')->on()->iPhone14Pro();
```

### Using Dark Mode

Pest enforces a light color scheme by default. However, you can specify a dark color scheme using the `inDarkMode()` method:

```php
$page = visit('/')->inDarkMode();
```

### Visiting Multiple Pages

You can visit multiple pages simultaneously by passing an array of URLs to the `visit()` method. This is useful for testing scenarios where you need to interact with multiple pages at once:

```php
$pages = visit(['/', '/about']);

$pages->assertNoSmoke()
    ->assertNoAccessibilityIssues()
    ->assertNoConsoleLogs()
    ->assertNoJavaScriptErrors();

[$homePage, $aboutPage] = $pages;

$homePage->assertSee('Welcome to our website');
$aboutPage->assertSee('About Us');
```

### Navigation

After visiting a page, you can navigate to other pages using the `navigate()` method. This method allows you to navigate to a different URL while keeping the current browser context:

```php
$page = visit('/');

$page->navigate('/about')
     ->assertSee('About Us');
```

### Locating Elements

You can locate elements in the DOM using text or CSS selectors. Pest provides a simple syntax for locating elements:

```php
// Clicks the first link with the text "Login"
$page->click('Login');

// Clicks the first element with the class "btn-primary"
$page->click('.btn-primary');

// Clicks the element with the data-test attribute "login"
$page->click('@login');

// Clicks the element with the ID "submit-button"
$page->click('#submit-button');

// etc...
```

### Configuring Timeouts

Sometimes, elements may take time to appear on the page. By default, Pest waits for `5` seconds before timing out. You can configure the default timeout for browser tests in your `Pest.php` configuration file:

```php
pest()->browser()->timeout(10000);
```

### Configuring User Agent

By default, the User Agent will default to the Browser you're running for tests such as: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/133.0.6943.16 Safari/537.36`

You may wish to override the User Agent of the browser for all of your tests, you can configure this in the `Pest.php` configuration file:

```php
pest()->browser()->userAgent('CustomUserAgent');
```

### Configuring Host

By default, the server will bind to `127.0.0.1` for all browser tests.

You may wish to override the host for subdomain applications. You can configure this in the `Pest.php` configuration file:

```php
pest()->browser()->withHost('some-subdomain.localhost');
```

### Geolocation

Sometimes, you need to define where the browser believes it is physically on the earth.  This method takes a latitude and longitude and will set the `geolocation` permission in the browser and then make the coordinates available via Javascript's getCurrentPosition API:

```php
$page = visit('/')
     ->geolocation(39.399872, -8.224454);
```

### Configuring Locale

You can set the locale for your test requests using the `withLocale` method. This is particularly useful for testing multilingual applications.

```php
$page = visit('/')->withLocale('fr-FR');

$page->assertSee('Bienvenue');
```
### Configuring Timezone

You can set the timezone for your test requests using the `withTimezone` method. This is useful for testing date and time displays in different time zones.

```php
$page = visit('/')->withTimezone('America/New_York');

$page->assertSee('EST');
```

### Configuring UserAgent

You can set the User-Agent header for your test requests using the `withUserAgent` method. This is useful for testing how your application responds to different types of clients, such as mobile browsers or bots.

```php
$page = visit('/')->withUserAgent('Googlebot');

$page->assertSee('Welcome, bot!');
```

### Configuring Host

You can set the host for your test server using the `withHost` method. This is useful for testing subdomains or where different hosts serve different content.

```php
$page = visit('/dashboard')->withHost('some-subdomain.localhost');

$page->assertSee('Welcome to Some Subdomain');
```

## Table of Contents

### Available Assertions

[assertTitle](#assert-title)
[assertTitleContains](#assert-title-contains)
[assertSee](#assert-see)
[assertDontSee](#assert-dont-see)
[assertSeeIn](#assert-see-in)
[assertDontSeeIn](#assert-dont-see-in)
[assertSeeAnythingIn](#assert-see-anything-in)
[assertSeeNothingIn](#assert-see-nothing-in)
[assertCount](#assert-count)
[assertScript](#assert-script)
[assertSourceHas](#assert-source-has)
[assertSourceMissing](#assert-source-missing)
[assertSeeLink](#assert-see-link)
[assertDontSeeLink](#assert-dont-see-link)
[assertChecked](#assert-checked)
[assertNotChecked](#assert-not-checked)
[assertIndeterminate](#assert-indeterminate)
[assertRadioSelected](#assert-radio-selected)
[assertRadioNotSelected](#assert-radio-not-selected)
[assertSelected](#assert-selected)
[assertNotSelected](#assert-not-selected)
[assertValue](#assert-value)
[assertValueIsNot](#assert-value-is-not)
[assertAttribute](#assert-attribute)
[assertAttributeMissing](#assert-attribute-missing)
[assertAttributeContains](#assert-attribute-contains)
[assertAttributeDoesntContain](#assert-attribute-doesnt-contain)
[assertAriaAttribute](#assert-aria-attribute)
[assertDataAttribute](#assert-data-attribute)
[assertVisible](#assert-visible)
[assertPresent](#assert-present)
[assertNotPresent](#assert-not-present)
[assertMissing](#assert-missing)
[assertEnabled](#assert-enabled)
[assertDisabled](#assert-disabled)
[assertButtonEnabled](#assert-button-enabled)
[assertButtonDisabled](#assert-button-disabled)
[assertUrlIs](#assert-url-is)
[assertSchemeIs](#assert-scheme-is)
[assertSchemeIsNot](#assert-scheme-is-not)
[assertHostIs](#assert-host-is)
[assertHostIsNot](#assert-host-is-not)
[assertPortIs](#assert-port-is)
[assertPortIsNot](#assert-port-is-not)
[assertPathBeginsWith](#assert-path-begins-with)
[assertPathEndsWith](#assert-path-ends-with)
[assertPathContains](#assert-path-contains)
[assertPathIs](#assert-path-is)
[assertPathIsNot](#assert-path-is-not)
[assertQueryStringHas](#assert-query-string-has)
[assertQueryStringMissing](#assert-query-string-missing)
[assertFragmentIs](#assert-fragment-is)
[assertFragmentBeginsWith](#assert-fragment-begins-with)
[assertFragmentIsNot](#assert-fragment-is-not)
[assertNoSmoke](#assert-no-smoke)
[assertNoConsoleLogs](#assert-no-console-logs)
[assertNoJavaScriptErrors](#assert-no-javascript-errors)
[assertNoAccessibilityIssues](#assert-no-accessibility-issues)
[assertScreenshotMatches](#assert-screenshot-matches)

### Element Interactions

[click](#click)
[text](#text)
[attribute](#attribute)
[keys](#keys)
[withKeyDown](#withKeyDown)
[type](#type)
[typeSlowly](#type-slowly)
[select](#select)
[append](#append)
[clear](#clear)
[radio](#radio)
[check](#check)
[uncheck](#uncheck)
[attach](#attach)
[press](#press)
[pressAndWaitFor](#press-and-wait-for)
[drag](#drag)
[hover](#hover)
[submit](#submit)
[value](#value)
[withinIframe](#within-iframe)
[resize](#resize)
[script](#script)
[content](#content)
[url](#url)
[wait](#wait)
[waitForKey](#wait-for-key)

### Debugging tests

[debug](#debug)
[screenshot](#screenshot)
[screenshotElement](#screenshot-element)
[tinker](#tinker)
[headed](#headed)

## Element Assertions

---

## Attribute Url Browser history
Attribute Url | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-url

## Browser history

By default, Livewire uses `history.replaceState()` to modify the URL without adding browser history entries. To add history entries (making the back button restore previous query values), use `history`:

```php
#[Url(history: true)] // [tl! highlight]
public $search = '';
```

Now clicking the browser's back button will restore previous search values instead of navigating to the previous page.

---

## Browser Testing withinIframe
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### withinIframe

The `withinIframe` method allows you to interact with elements inside an iframe:

```php
use Pest\Browser\Api\PendingAwaitablePage;

$page->withinIframe('.iframe-container', function (PendingAwaitablePage $page) {
    $page->type('frame-input', 'Hello iframe')
        ->click('frame-button');
});
```

---

## Attribute On Listening to browser events
Attribute On | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-on

## Listening to browser events

You can also listen for browser events dispatched from JavaScript:

```php
#[On('user-logged-in')]
public function handleUserLogin()
{
    // Handle login...
}
```

```javascript
// From JavaScript
window.dispatchEvent(new CustomEvent('user-logged-in'));
```

---

## Browser Testing content
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### content

The `content` method gets the page's content:

```php
$html = $page->content();
```

---

## Browser Testing assertDontSeeIn
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### assertDontSeeIn

The `assertDontSeeIn` method asserts that the given text is not present within the selector:

```php
$page->assertDontSeeIn('.error-container', 'Error occurred');
```

---

## Pest V4 Is Here Now With Browser Testing
Pest V4 Is Here Now With Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/pest-v4-is-here-now-with-browser-testing.md

---
title: Pest v4 Is Here — Now with Browser Testing
description: Today, we're thrilled to announce Pest v4 — our biggest release yet, featuring powerful new browser testing with parallel support and full Laravel integration.
---

> To get started with Pest v4's new features including browser testing, please refer to the upgrade guide: [Upgrade Guide →](/docs/upgrade-guide).

- [Browser Testing](#content-pest-v4-is-here-now-with-browser-testing)
- [Smoke Testing](#content-smoke-testing)
- [Visual Regression Testing](#content-visual-regression-testing)
- [Test Sharding](#content-test-sharding)
- [Type Coverage Is Much Faster](#content-type-coverage-is-much-faster)
- [Profanity Checking](#content-profanity-checking)
- [On Top of PHPUnit 12](#content-on-top-of-phpunit-12)

# Pest v4 Is Here — Now with Browser Testing

Today, we're thrilled to announce the release of **Pest v4**, bringing the biggest testing upgrade yet: powerful **[Browser Testing](/docs/browser-testing)**. Pest's new browser testing features let you run elegant, maintainable browser tests — with first-class support for Laravel's testing API and the ability to run tests in parallel. For the first time, this is browser testing that feels as good as writing unit tests.

Here is the creator of Pest, [Nuno Maduro](https://twitter.com/enunomaduro), demoing the new browser testing features in Pest v4 at Laracon US:

[![Pest v4](/assets/pest4_play.png)](https://youtu.be/f5gAgwwwwOI?si=LtPpySZe3tf8qMjz&t=52)

Here is an example of Browser Testing using [Laravel](https://laravel.com):

```php
it('may reset the password', function () {
    // access any laravel testing helpers...
    Notification::fake();

    // access to the database — using the RefreshDatabase trait (even sqlite in memory...)
    $this->actingAs(User::factory()->create());

    $page = visit('/sign-in') // visit on a real browser...
        ->on()->mobile() // or ->desktop(), ->tablet(), etc...
        ->inDarkMode(); // or ->inLightMode()

    $page->assertSee('Sign In')
         ->click('Forgot Password?')
         ->type('email', 'nuno@laravel.com')
         ->press('Send Reset Link')
         ->assertSee('We have emailed your password reset link!')
         ->assertNoJavascriptErrors(); // or ->assertNoConsoleLogs()

    Notification::assertSent(ResetPassword::class);
});
```

With Pest v4's browser testing, you can:
- Seamlessly use **Laravel features** like `Event::fake()`, `assertAuthenticated()`, and model factories
- Use `RefreshDatabase`, even with SQLite in-memory databases, to ensure a clean state for each test
- Test on **multiple browsers** (Chrome, Firefox, Safari)
- Test on **different devices** and viewports (like iPhone 14 Pro, tablets, or custom breakpoints)
- Switch **color schemes** (light/dark mode)
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.)
- Run **parallel browser tests** for dramatically faster suites
- Take **screenshots** or pause tests for debugging
- …all with the elegance of Pest syntax
- **Playwright-based** — modern, fast, and reliable

To get started with browser testing in Pest, you need to install the Pest Browser Plugin:

```bash
composer require pestphp/pest-plugin-browser --dev

npm install playwright@latest
npx playwright install
```

After, you may use the `visit()` function anywhere. Finally, running this test is as simple as executing `./vendor/bin/pest` in your terminal. Pest will handle the rest, launching a browser, navigating to the page, and performing the actions you specified.

## Smoke Testing

Smoke testing your application in real browsers has never been easier. With Pest v4, you can literally visit all your application pages, and ensure they don't throw any JavaScript errors, and they don't log any console errors.

```php
$routes = ['/', '/about', '/contact'];

visit($routes)->assertNoSmoke();

// assertNoSmoke() is a shorthand for:
// - assertNoJavascriptErrors()
// - assertNoConsoleLogs()
```

## Visual Regression Testing

Want to ensure your pages look exactly as expected over time? Pest v4 introduces visual regression testing with the `assertScreenshotMatches()` assertion. This allows you to take screenshots of your pages and compare them against baseline images, ensuring that your UI remains consistent across changes.

```php
$pages = visit(['/', '/about', '/contact']);

$pages->assertScreenshotMatches();
```

This is just a glimpse of what Browser Testing in Pest v4 can do. Find out more about the new features below, and check out the [Browser Testing documentation](/docs/browser-testing) for a complete guide on how to get started.

## Test Sharding

Pest v4 introduces **Test Sharding**, allowing you to split your test suite into smaller, manageable chunks. This is particularly useful for large applications (or when running browser tests) where running all tests at once can be time-consuming.

This feature is particularly useful on CI platforms, where on things like GitHub actions you can no longer scale vertical, but rather horizontally. This means you can run your tests in parallel across multiple machines, significantly speeding up your test suite execution.

To get started with Test Sharding, you can use the `--shard` option when running Pest:

```bash
# GitHub Workflow One
./vendor/bin/pest --shard=1/4

# GitHub Workflow Two
./vendor/bin/pest --shard=2/4

# GitHub Workflow Three
./vendor/bin/pest --shard=3/4

# GitHub Workflow Four
./vendor/bin/pest --shard=4/4
```

You may combine this with the `--parallel` option to run your tests in parallel, and this way trully maximize your test suite execution speed:

```bash
./vendor/bin/pest --shard=1/4 --parallel
```

Finally, to easily set up sharding your CI configuration, you just have to ensure each job in your CI configuration runs a different shard of your test suite. For example, in GitHub Actions, you can use the `matrix` strategy to define multiple jobs that run different shards:

```yaml
strategy:
  matrix:
    shard: [1, 2, 3, 4]

name: Tests (Shard ${{ matrix.shard }}/4)

steps:
  - name: Run tests
    run: pest --parallel --shard ${{ matrix.shard }}/4
```

## Type Coverage Is Much Faster

Remember the days when you had to wait for your type coverage to run? Not anymore! Pest v4 introduces a new type coverage engine that is significantly faster than previous versions.

Type coverage is 2x faster on the first run and instant on subsequent runs. This means you can quickly check your type coverage without waiting for long periods, making your development workflow much more efficient.

In addition, Type Coverage now supports **Sharding**. This means you can run type coverage with the `--shard` option, just like you do with your tests.

## Profanity Checking

Pest v4 introduces a new feature that allows you to check for profanity in your test code. This is particularly useful for maintaining a clean and professional codebase, especially in collaborative environments.

You can enable profanity checking by adding the `--profanity` option when running Pest:

```bash
./vendor/bin/pest --profanity
```

To start using Pest's Profanity plugin, you need to require the plugin via Composer.

```bash
composer require pestphp/pest-plugin-profanity --dev
```

After requiring the plugin, you may utilize the `--profanity` option to generate a report of your profanity.

```bash
./vendor/bin/pest --profanity
```

If any of your files contain profanity, they will be highlighted in red and displayed using their respective line
numbers and the profane word(s) that have been found.

As an example, `pr31(f*ck)` means that the word "fuck" was found on line 31.

To learn more about the Profanity plugin and how to configure it, check out the [Profanity documentation](/docs/profanity).

### Skip Locally or On CI

Pest v4 introduces the ability to conditionally skip tests based on the environment. You can use `skipLocally()` to skip tests when running locally, or `skipOnCi` to skip tests when running on a CI server.

```php
it('does not run locally', function () {
    // This test will be skipped when running locally
})->skipLocally();

it('does not run on CI', function () {
    // This test will be skipped when running on a CI server
})->skipOnCi();
```

### Miscellaneous Improvements

- You may now use `skipLocally()` or `skipOnCi` to conditionally skip tests based on the environment.
- The `not->toHaveSuspiciousCharacters()` arch expectation has been added to help you identify potential suspicious characters in your code. This arch expectation is now enabled by default on the `php` arch preset. This expectation requires the `intl` PHP extension.
- The expectation `toBeSlug` has been added to help you validate that a string is a valid slug.

### On Top of PHPUnit 12

Pest v4 is built on top of PHPUnit 12, which means you get all the latest features and improvements from PHPUnit. As such, be sure to check out the [PHPUnit 12 release announcement](https://phpunit.de/announcements/phpunit-12.html).

### Thanks To You, Pest v4 Is Here!

There's never been a better time to dive into testing and start using Pest. If you're ready to get started with Pest v4 right away, check out our [installation guide](/docs/installation) for step-by-step instructions. And if you're currently using an earlier version of Pest, we've got you covered with detailed upgrade instructions in our [upgrade guide](/docs/upgrade-guide).

Thank you for your continued support and feedback. We can't wait to see what you build with Pest v4!

---

Thank you for reading about Pest v4's new features! Want to get started with Pest? You can find the installation guide in the next section of the documentation: [Installation →](/docs/installation)

_[Truncated]_