54 - Replace Dusk with Pest 4 browser testing

This commit is contained in:
myrmidex 2026-08-19 02:09:42 +02:00
parent cca3f29823
commit dcfa73b7db
39 changed files with 3406 additions and 3096 deletions

View file

@ -1,24 +0,0 @@
APP_NAME=DishPlanner
APP_ENV=testing
APP_KEY=base64:KSKZNT+cJuaBRBv4Y2HQqav6hzREKoLkNIKN8yszU1Q=
APP_DEBUG=true
APP_URL=http://dishplanner_app:8000
LOG_CHANNEL=single
# Test database
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=dishplanner_test
DB_USERNAME=dishplanner
DB_PASSWORD=dishplanner
BROADCAST_DRIVER=log
CACHE_DRIVER=array
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MAIL_MAILER=array

View file

@ -65,4 +65,16 @@ jobs:
run: vendor/bin/phpstan analyse --memory-limit=1G
- name: Tests
run: php -d memory_limit=512M vendor/bin/phpunit
run: vendor/bin/pest
- name: Install frontend dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Build frontend assets
run: npm run build
- name: Browser tests
run: vendor/bin/pest tests/Browser

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
/.phpunit.cache
/tests/Browser/Screenshots
/coverage
/node_modules
/public/build

View file

@ -14,6 +14,7 @@ RUN apk add --no-cache \
# Install PHP extensions including xdebug for development
RUN install-php-extensions \
pdo_mysql \
sockets \
opcache \
zip \
gd \

View file

@ -18,14 +18,15 @@
"require-dev": {
"fakerphp/faker": "^1.23",
"larastan/larastan": "^3.10",
"laravel/dusk": "^8.3",
"laravel/pail": "^1.1",
"laravel/pint": "^1.13",
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.1",
"phpstan/phpstan-mockery": "^2.0",
"phpunit/phpunit": "^11.0.1"
"pestphp/pest": "^4.7",
"pestphp/pest-plugin-browser": "^4.3",
"pestphp/pest-plugin-laravel": "^4.0",
"phpstan/phpstan-mockery": "^2.0"
},
"autoload": {
"psr-4": {
@ -61,7 +62,8 @@
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
],
"test": [
"@php artisan test"
"@php artisan test",
"@php vendor/bin/pest tests/Browser"
],
"test:coverage": [
"Composer\\Config::disableProcessTimeout",

3446
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,13 +2,13 @@
namespace Database\Factories;
use App\Models\User;
use App\Models\Planner;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
* @extends Factory<Planner>
*/
class PlannerFactory extends Factory
{

View file

@ -1,10 +1,13 @@
# CI image: PHP + Composer only, no runtime server or frontend toolchain.
# Tests run against SQLite in memory (see .env.testing), so no database client
# or cache extension is needed.
# CI image: PHP + Composer + Node/npm. Unit/feature tests run against SQLite in
# memory (see .env.testing), so no database client or cache extension is needed.
# Browser tests need the `sockets` extension (pest-plugin-browser boots Laravel
# in-process) and Node/npm to drive Playwright; the Chromium binary itself is
# installed per run so it always matches the playwright version from the lockfile.
#
# Published as dishplanner-ci:php8.3-<composer.lock hash>. The CI workflow
# builds and tags this image from the current lockfile, so a dependency change
# automatically yields a fresh, uniquely-tagged image (no manual revision bump).
# builds and tags this image from the current lockfile, so a PHP dependency
# change automatically yields a fresh, uniquely-tagged image (no manual revision
# bump).
#
# Debian-based rather than Alpine to avoid the DNS resolution timeouts against
# codeload.github.com that the Alpine base hit during composer install.
@ -15,6 +18,7 @@ COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /u
RUN install-php-extensions \
pdo_sqlite \
sockets \
mbstring \
dom \
xml \
@ -22,10 +26,11 @@ RUN install-php-extensions \
pcntl \
zip
# git is needed by the checkout action; nodejs runs the Forgejo JavaScript
# actions (checkout, cache); unzip lets Composer extract dist archives.
# git is needed by the checkout action; nodejs+npm run the Forgejo JavaScript
# actions (checkout, cache) and Playwright; unzip lets Composer extract dist
# archives.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip nodejs \
&& apt-get install -y --no-install-recommends git unzip nodejs npm \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

View file

@ -85,21 +85,6 @@ services:
networks:
- dishplanner
# Selenium for E2E testing with Dusk
selenium:
image: selenium/standalone-chrome:latest
container_name: dishplanner_selenium
restart: unless-stopped
ports:
- "4444:4444" # Selenium server
- "7900:7900" # VNC server for debugging
volumes:
- /dev/shm:/dev/shm
networks:
- dishplanner
environment:
- SE_VNC_PASSWORD=secret
# Optional: Redis for caching/sessions
# redis:
# image: redis:alpine

50
package-lock.json generated
View file

@ -1,5 +1,5 @@
{
"name": "app",
"name": "dishplanner",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -13,6 +13,7 @@
"axios": "^1.7.4",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^1.3.0",
"playwright": "^1.62.1",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"vite": "^6.4.1"
@ -2220,6 +2221,53 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View file

@ -11,6 +11,7 @@
"axios": "^1.7.4",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^1.3.0",
"playwright": "^1.62.1",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"vite": "^6.4.1"

File diff suppressed because it is too large Load diff

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
beStrictAboutTestsThatDoNotTestAnything="false"
colors="true"
processIsolation="false"
stopOnError="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
backupStaticProperties="false">
<testsuites>
<testsuite name="Browser Test Suite">
<directory suffix="Test.php">./tests/Browser</directory>
</testsuite>
</testsuites>
</phpunit>

View file

@ -92,6 +92,12 @@ pkgs.mkShell {
podman-compose -f $COMPOSE_FILE exec -T app env $(grep -vE '^\s*(#|$)' .env.testing) php -d memory_limit=512M vendor/bin/phpunit "$@"
}
pest-browser() {
# Run Pest browser tests (Playwright) on the host. Pass --headed for a
# visible browser or --debug to pause on failure. Requires `npm run build`.
vendor/bin/pest tests/Browser "$@"
}
dev-fix-permissions() {
echo "🔧 Fixing file permissions..."
echo "This will require sudo to fix Docker-created files"
@ -169,6 +175,7 @@ pkgs.mkShell {
echo " dev-shell - Enter app container"
echo " dev-artisan - Run artisan commands"
echo " dev-test [path] - Run PHPUnit suite (CI invocation)"
echo " pest-browser [--headed] - Run Pest browser tests (Playwright)"
echo " dev-fix-permissions - Fix Docker-created file permissions"
echo ""
echo "Production commands:"

View file

@ -1,91 +1,39 @@
<?php
namespace Tests\Browser\Auth;
use App\Models\Planner;
use Illuminate\Support\Facades\Hash;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
class LoginTest extends DuskTestCase
{
protected static $testPlanner = null;
protected static $testEmail = null;
protected static $testPassword = 'password';
protected function ensureTestPlannerExists(): void
{
if (self::$testPlanner === null) {
// Generate unique email for this test run
self::$testEmail = fake()->unique()->safeEmail();
self::$testPlanner = Planner::factory()->create([
'email' => self::$testEmail,
'password' => Hash::make(self::$testPassword),
it('logs in successfully', function () {
$planner = Planner::factory()->create([
'password' => Hash::make('password'),
]);
}
}
public function test_successful_login(): void
{
$this->ensureTestPlannerExists();
$this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]')
->type('input[id="password"]', self::$testPassword)
->press('Login')
->waitForLocation('/dashboard', self::TIMEOUT_MEDIUM)
visit('/login')
->type('input[id="email"]', $planner->email)
->type('input[id="password"]', 'password')
->press('Sign In')
->assertPathIs('/dashboard');
});
}
});
public function test_login_with_wrong_credentials(): void
{
$this->ensureTestPlannerExists();
it('rejects wrong credentials', function () {
$planner = Planner::factory()->create([
'password' => Hash::make('password'),
]);
$this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]')
->type('input[id="password"]', 'wrongpassword')
->press('Login')
->pause(self::PAUSE_MEDIUM)
visit('/login')
->type('input[id="email"]', $planner->email)
->type('input[id="password"]', 'wrong-password')
->press('Sign In')
->assertPathIs('/login')
->assertSee('These credentials do not match our records');
});
}
});
public function test_login_form_required_fields(): void
{
$this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT);
// Check that both fields have the required attribute
$browser->assertAttribute('input[id="email"]', 'required', 'true');
$browser->assertAttribute('input[id="password"]', 'required', 'true');
// Verify email field is type email
$browser->assertAttribute('input[id="email"]', 'type', 'email');
// Verify password field is type password
$browser->assertAttribute('input[id="password"]', 'type', 'password');
// Test that we stay on login page if we try to submit with empty fields
$browser->press('Login')
->pause(self::PAUSE_SHORT)
it('requires the email and password fields', function () {
visit('/login')
->assertScript("document.querySelector('input[id=\"email\"]').required")
->assertScript("document.querySelector('input[id=\"password\"]').required")
->assertAttribute('input[id="email"]', 'type', 'email')
->assertAttribute('input[id="password"]', 'type', 'password')
->press('Sign In')
->assertPathIs('/login');
});
}
}
});

View file

@ -1,105 +0,0 @@
<?php
namespace Tests\Browser\Components;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;
class DishModal extends BaseComponent
{
protected string $mode; // 'create' or 'edit'
public function __construct(string $mode = 'create')
{
$this->mode = $mode;
}
/**
* Get the root selector for the component.
*/
public function selector(): string
{
// Livewire modals typically have a specific structure
return '[role="dialog"], .fixed.inset-0';
}
/**
* Assert that the browser page contains the component.
*/
public function assert(Browser $browser): void
{
$browser->assertVisible($this->selector());
if ($this->mode === 'create') {
$browser->assertSee('Add New Dish');
} else {
$browser->assertSee('Edit Dish');
}
}
/**
* Get the element shortcuts for the component.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@name-input' => 'input[wire\\:model="name"]',
'@description-input' => 'textarea[wire\\:model="description"]',
'@users-section' => 'div:contains("Assign to Users")',
'@submit-button' => $this->mode === 'create' ? 'button:contains("Create Dish")' : 'button:contains("Update Dish")',
'@cancel-button' => 'button:contains("Cancel")',
'@validation-error' => '.text-red-500',
];
}
/**
* Fill the dish form.
*/
public function fillForm(Browser $browser, string $name, ?string $description = null): void
{
$browser->waitFor('@name-input')
->clear('@name-input')
->type('@name-input', $name);
if ($description !== null && $browser->element('@description-input')) {
$browser->clear('@description-input')
->type('@description-input', $description);
}
}
/**
* Select users to assign the dish to.
*/
public function selectUsers(Browser $browser, array $userIds): void
{
foreach ($userIds as $userId) {
$browser->check("input[type='checkbox'][value='{$userId}']");
}
}
/**
* Submit the form.
*/
public function submit(Browser $browser): void
{
$browser->press($this->mode === 'create' ? 'Create Dish' : 'Update Dish');
}
/**
* Cancel the modal.
*/
public function cancel(Browser $browser): void
{
$browser->press('Cancel');
}
/**
* Assert validation error is shown.
*/
public function assertValidationError(Browser $browser, string $message = 'required'): void
{
$browser->assertSee($message);
}
}

View file

@ -1,89 +0,0 @@
<?php
namespace Tests\Browser\Components;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;
class LoginForm extends BaseComponent
{
/**
* Get the root selector for the component.
*/
public function selector(): string
{
return 'form[method="POST"][action*="login"]';
}
/**
* Assert that the browser page contains the component.
*/
public function assert(Browser $browser): void
{
$browser->assertVisible($this->selector())
->assertVisible('@email')
->assertVisible('@password')
->assertVisible('@submit');
}
/**
* Get the element shortcuts for the component.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@email' => 'input[id="email"]',
'@password' => 'input[id="password"]',
'@submit' => 'button[type="submit"]',
'@remember' => 'input[name="remember"]',
'@error' => '.text-red-500',
];
}
/**
* Fill in the login form.
*/
public function fillForm(Browser $browser, string $email, string $password): void
{
$browser->type('@email', $email)
->type('@password', $password);
}
/**
* Submit the login form.
*/
public function submit(Browser $browser): void
{
$browser->press('@submit');
}
/**
* Login with the given credentials.
*/
public function loginWith(Browser $browser, string $email, string $password): void
{
$this->fillForm($browser, $email, $password);
$this->submit($browser);
}
/**
* Assert that the form fields are required.
*/
public function assertFieldsRequired(Browser $browser): void
{
$browser->assertAttribute('@email', 'required', 'true')
->assertAttribute('@password', 'required', 'true')
->assertAttribute('@email', 'type', 'email')
->assertAttribute('@password', 'type', 'password');
}
/**
* Assert that the form has validation errors.
*/
public function assertHasErrors(Browser $browser): void
{
$browser->assertPresent('@error');
}
}

View file

@ -1,50 +1,8 @@
<?php
namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\Browser\Components\DishModal;
use Tests\Browser\LoginHelpers;
use Tests\Browser\Pages\DishesPage;
use Tests\DuskTestCase;
class CreateDishFormValidationTest extends DuskTestCase
{
use LoginHelpers;
protected static $createDishFormValidationTestPlanner = null;
protected static $createDishFormValidationTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createDishFormValidationTestPlanner;
self::$testEmail = self::$createDishFormValidationTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createDishFormValidationTestPlanner = self::$testPlanner;
self::$createDishFormValidationTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_create_dish_form_validation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
$browser->on(new DishesPage)
->openCreateModal()
->within(new DishModal('create'), function ($browser) {
$browser->fillForm('', null)
->submit()
->pause(2000)
->assertValidationError('required');
});
});
}
}
it('validates the dish name field', function () {
loginAndGoToDishes()
->click('button[wire\:click="create"]')
->press('Create Dish')
->assertSee('required');
});

View file

@ -1,53 +1,12 @@
<?php
namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\Browser\Components\DishModal;
use Tests\Browser\LoginHelpers;
use Tests\Browser\Pages\DishesPage;
use Tests\DuskTestCase;
class CreateDishSuccessTest extends DuskTestCase
{
use LoginHelpers;
protected static $createDishSuccessTestPlanner = null;
protected static $createDishSuccessTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createDishSuccessTestPlanner;
self::$testEmail = self::$createDishSuccessTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createDishSuccessTestPlanner = self::$testPlanner;
self::$createDishSuccessTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_create_dish_successfully(): void
{
$this->browse(function (Browser $browser) {
it('creates a dish successfully', function () {
$dishName = 'Test Dish '.uniqid();
$this->loginAndGoToDishes($browser);
$browser->on(new DishesPage)
->openCreateModal()
->within(new DishModal('create'), function ($browser) use ($dishName) {
$browser->fillForm($dishName)
->submit();
})
->pause(3000)
->assertDishVisible($dishName)
loginAndGoToDishes()
->click('button[wire\:click="create"]')
->type('input[wire\:model="name"]', $dishName)
->press('Create Dish')
->assertSee($dishName)
->assertSee('Dish created successfully');
});
}
}
});

View file

@ -1,47 +1,8 @@
<?php
namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\Browser\LoginHelpers;
use Tests\Browser\Pages\DishesPage;
use Tests\DuskTestCase;
class CreateDishTest extends DuskTestCase
{
use LoginHelpers;
protected static $createDishTestPlanner = null;
protected static $createDishTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createDishTestPlanner;
self::$testEmail = self::$createDishTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createDishTestPlanner = self::$testPlanner;
self::$createDishTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_access_dishes_page(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
$browser->on(new DishesPage)
it('can access the dishes page', function () {
loginAndGoToDishes()
->assertPathIs('/dishes')
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
});
}
// TODO: Moved to separate single-method test files to avoid static planner issues
// See: OpenCreateDishModalTest, CreateDishFormValidationTest, CancelDishCreationTest, CreateDishSuccessTest
}
});

View file

@ -1,77 +1,13 @@
<?php
namespace Tests\Browser\Dishes;
use App\Models\Dish;
use App\Models\Planner;
use Laravel\Dusk\Browser;
use Tests\Browser\LoginHelpers;
use Tests\DuskTestCase;
it('exposes the delete feature', function () {
$planner = createPlanner();
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
class DeleteDishTest extends DuskTestCase
{
use LoginHelpers;
protected static $deleteDishTestPlanner = null;
protected static $deleteDishTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$deleteDishTestPlanner;
self::$testEmail = self::$deleteDishTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$deleteDishTestPlanner = self::$testPlanner;
self::$deleteDishTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_access_delete_feature(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
loginAs($planner, '/dishes')
->assertPathIs('/dishes')
->assertSee('MANAGE DISHES');
// Verify that delete functionality is available by looking for the text in the page source
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('Delete', $pageSource);
});
}
// TODO: Fix static planner issue causing login failures in suite runs
// These tests pass in isolation but fail when run in full suite
/*
public function testDeleteModalComponents(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
});
}
public function testDeletionSafetyFeatures(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
// Check that Livewire component includes all CRUD features
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('MANAGE DISHES', $pageSource);
$this->assertStringContainsString('Add Dish', $pageSource);
// Either we have dishes with Delete button OR "No dishes found" message
if (str_contains($pageSource, 'No dishes found')) {
$this->assertStringContainsString('No dishes found', $pageSource);
} else {
$this->assertStringContainsString('Delete', $pageSource);
}
});
}
*/
}
->assertSee('Delete');
});

View file

@ -1,50 +1,13 @@
<?php
namespace Tests\Browser\Dishes;
use App\Models\Dish;
use Laravel\Dusk\Browser;
use Tests\Browser\LoginHelpers;
use Tests\DuskTestCase;
it('shows the deletion safety features', function () {
$planner = createPlanner();
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
class DishDeletionSafetyTest extends DuskTestCase
{
use LoginHelpers;
protected static $dishDeletionSafetyTestPlanner = null;
protected static $dishDeletionSafetyTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$dishDeletionSafetyTestPlanner;
self::$testEmail = self::$dishDeletionSafetyTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$dishDeletionSafetyTestPlanner = self::$testPlanner;
self::$dishDeletionSafetyTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_deletion_safety_features(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
// Check that Livewire component includes all CRUD features
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('MANAGE DISHES', $pageSource);
$this->assertStringContainsString('Add Dish', $pageSource);
// Either we have dishes with Delete button OR "No dishes found" message
if (str_contains($pageSource, 'No dishes found')) {
$this->assertStringContainsString('No dishes found', $pageSource);
} else {
$this->assertStringContainsString('Delete', $pageSource);
}
});
}
}
loginAs($planner, '/dishes')
->assertSee('MANAGE DISHES')
->assertSee('Add Dish')
->assertSee('Delete');
});

View file

@ -1,74 +1,28 @@
<?php
namespace Tests\Browser\Dishes;
use App\Models\Dish;
use App\Models\Planner;
use Laravel\Dusk\Browser;
use Tests\Browser\LoginHelpers;
use Tests\DuskTestCase;
it('exposes the edit feature', function () {
$planner = createPlanner();
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
class EditDishTest extends DuskTestCase
{
use LoginHelpers;
protected static $editDishTestPlanner = null;
protected static $editDishTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$editDishTestPlanner;
self::$testEmail = self::$editDishTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$editDishTestPlanner = self::$testPlanner;
self::$editDishTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_access_edit_feature(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
loginAs($planner, '/dishes')
->assertPathIs('/dishes')
->assertSee('MANAGE DISHES');
->assertSee('MANAGE DISHES')
->assertSee('Edit');
});
// Verify that edit functionality is available by looking for the text in the page source
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('Edit', $pageSource);
});
}
public function test_edit_modal_components(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
it('shows the edit modal components', function () {
loginAndGoToDishes()
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
});
}
});
public function test_dishes_page_structure(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
it('renders the dishes page structure', function () {
$planner = createPlanner();
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
// Check that the dishes CRUD structure is present
$pageSource = $browser->driver->getPageSource();
// Either we have dishes with Edit/Delete buttons OR "No dishes found" message
if (str_contains($pageSource, 'No dishes found')) {
$this->assertStringContainsString('No dishes found', $pageSource);
} else {
$this->assertStringContainsString('Edit', $pageSource);
$this->assertStringContainsString('Delete', $pageSource);
}
});
}
}
loginAs($planner, '/dishes')
->assertSee('Edit')
->assertSee('Delete');
});

View file

@ -1,66 +0,0 @@
<?php
namespace Tests\Browser;
use App\Models\Planner;
use Illuminate\Support\Facades\Hash;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
trait LoginHelpers
{
protected static $testPlanner = null;
protected static $testEmail = null;
protected static $testPassword = 'password';
protected function ensureTestPlannerExists(): void
{
// Always create a fresh planner for each test class to avoid session conflicts
if (self::$testPlanner === null || ! self::$testPlanner->exists) {
// Generate unique email for this test run
self::$testEmail = fake()->unique()->safeEmail();
self::$testPlanner = Planner::factory()->create([
'email' => self::$testEmail,
'password' => Hash::make(self::$testPassword),
]);
}
}
protected function loginAndNavigate(Browser $browser, string $page = '/dashboard'): Browser
{
$this->ensureTestPlannerExists();
// Clear browser session and cookies to start fresh
$browser->driver->manage()->deleteAllCookies();
return $browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', DuskTestCase::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]')
->type('input[id="password"]', self::$testPassword)
->press('Sign In')
->waitForLocation('/dashboard', DuskTestCase::TIMEOUT_MEDIUM) // Wait for successful login redirect
->pause(DuskTestCase::PAUSE_SHORT) // Brief pause for any initialization
->visit('http://dishplanner_app:8000'.$page)
->pause(DuskTestCase::PAUSE_MEDIUM); // Let Livewire components initialize
}
protected function loginAndGoToDishes(Browser $browser): Browser
{
return $this->loginAndNavigate($browser, '/dishes');
}
protected function loginAndGoToUsers(Browser $browser): Browser
{
return $this->loginAndNavigate($browser, '/users');
}
protected function loginAndGoToSchedule(Browser $browser): Browser
{
return $this->loginAndNavigate($browser, '/schedule');
}
}

View file

@ -1,86 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
class DishesPage extends Page
{
/**
* Get the URL for the page.
*/
public function url(): string
{
return '/dishes';
}
/**
* Assert that the browser is on the page.
*/
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('MANAGE DISHES');
}
/**
* Get the element shortcuts for the page.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@add-button' => 'button[wire\\:click="create"]',
'@dishes-list' => '[wire\\:id]', // Livewire component
'@search' => 'input[type="search"]',
'@no-dishes' => '*[text*="No dishes found"]',
];
}
/**
* Open the create dish modal.
*/
public function openCreateModal(Browser $browser): void
{
$browser->waitFor('@add-button')
->click('@add-button')
->pause(1000);
}
/**
* Click edit button for a dish.
*/
public function clickEditForDish(Browser $browser, string $dishName): void
{
$browser->within("tr:contains('{$dishName}')", function ($row) {
$row->click('button.bg-accent-blue');
});
}
/**
* Click delete button for a dish.
*/
public function clickDeleteForDish(Browser $browser, string $dishName): void
{
$browser->within("tr:contains('{$dishName}')", function ($row) {
$row->click('button.bg-red-500');
});
}
/**
* Assert a dish is visible in the list.
*/
public function assertDishVisible(Browser $browser, string $dishName): void
{
$browser->assertSee($dishName);
}
/**
* Assert no dishes message is shown.
*/
public function assertNoDishes(Browser $browser): void
{
$browser->assertSee('No dishes found');
}
}

View file

@ -1,47 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
use Tests\Browser\Components\LoginForm;
class LoginPage extends Page
{
/**
* Get the URL for the page.
*/
public function url(): string
{
return '/login';
}
/**
* Assert that the browser is on the page.
*/
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('Login')
->assertPresent((new LoginForm)->selector());
}
/**
* Get the element shortcuts for the page.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@register-link' => 'a[href*="register"]',
];
}
/**
* Navigate to the registration page.
*/
public function goToRegistration(Browser $browser): void
{
$browser->click('@register-link');
}
}

View file

@ -1,21 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page as BasePage;
abstract class Page extends BasePage
{
/**
* Get the global element shortcuts for the site.
*
* @return array<string, string>
*/
public static function siteElements(): array
{
return [
'@nav' => 'nav',
'@alert' => '[role="alert"]',
];
}
}

View file

@ -1,110 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
class SchedulePage extends Page
{
public function url(): string
{
return '/schedule';
}
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('SCHEDULE');
}
public function elements(): array
{
return [
'@generate-button' => 'button[wire\\:click="generate"]',
'@clear-month-button' => 'button[wire\\:click="clearMonth"]',
'@previous-month' => 'button[wire\\:click="previousMonth"]',
'@next-month' => 'button[wire\\:click="nextMonth"]',
'@month-select' => 'select[wire\\:model="selectedMonth"]',
'@year-select' => 'select[wire\\:model="selectedYear"]',
'@clear-existing-checkbox' => 'input[wire\\:model="clearExisting"]',
'@calendar-grid' => '.grid.grid-cols-7',
];
}
public function clickGenerate(Browser $browser): void
{
$browser->waitFor('@generate-button')
->click('@generate-button')
->pause(2000); // Wait for generation
}
public function clickClearMonth(Browser $browser): void
{
$browser->waitFor('@clear-month-button')
->click('@clear-month-button')
->pause(1000);
}
public function goToPreviousMonth(Browser $browser): void
{
$browser->waitFor('@previous-month')
->click('@previous-month')
->pause(500);
}
public function goToNextMonth(Browser $browser): void
{
$browser->waitFor('@next-month')
->click('@next-month')
->pause(500);
}
public function selectMonth(Browser $browser, int $month): void
{
$browser->waitFor('@month-select')
->select('@month-select', $month)
->pause(500);
}
public function selectYear(Browser $browser, int $year): void
{
$browser->waitFor('@year-select')
->select('@year-select', $year)
->pause(500);
}
public function toggleClearExisting(Browser $browser): void
{
$browser->waitFor('@clear-existing-checkbox')
->click('@clear-existing-checkbox');
}
public function selectUser(Browser $browser, string $userName): void
{
$browser->check("input[type='checkbox'][value]", $userName);
}
public function assertSuccessMessage(Browser $browser, ?string $message = null): void
{
if ($message) {
$browser->assertSee($message);
} else {
$browser->assertPresent('.border-success');
}
}
public function assertDishScheduled(Browser $browser, string $dishName): void
{
$browser->assertSee($dishName);
}
public function assertNoDishesScheduled(Browser $browser): void
{
$browser->assertSee('No dishes scheduled');
}
public function assertMonthDisplayed(Browser $browser, string $monthYear): void
{
$browser->assertSee($monthYear);
}
}

View file

@ -1,93 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
class UsersPage extends Page
{
/**
* Get the URL for the page.
*/
public function url(): string
{
return '/users';
}
/**
* Assert that the browser is on the page.
*/
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('MANAGE USERS');
}
/**
* Get the element shortcuts for the page.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@add-button' => 'button[wire\\:click="create"]',
'@users-list' => '[wire\\:id]', // Livewire component
'@no-users' => '*[text*="No users found"]',
];
}
/**
* Open the create user modal.
*/
public function openCreateModal(Browser $browser): void
{
$browser->waitFor('@add-button')
->click('@add-button')
->pause(1000);
}
/**
* Click delete button for a user.
*/
public function clickDeleteForUser(Browser $browser, string $userName): void
{
$browser->within("tr:contains('{$userName}')", function ($row) {
$row->click('button.bg-danger');
});
}
/**
* Click the first available delete button.
*/
public function clickFirstDeleteButton(Browser $browser): void
{
$browser->waitFor('button.bg-danger', 5)
->click('button.bg-danger')
->pause(1000);
}
/**
* Assert a user is visible in the list.
*/
public function assertUserVisible(Browser $browser, string $userName): void
{
$browser->assertSee($userName);
}
/**
* Assert a user is not visible in the list.
*/
public function assertUserNotVisible(Browser $browser, string $userName): void
{
$browser->assertDontSee($userName);
}
/**
* Assert success message is shown.
*/
public function assertSuccessMessage(Browser $browser, string $message): void
{
$browser->assertSee($message);
}
}

View file

@ -1,38 +1,14 @@
<?php
namespace Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
class RedirectTest extends DuskTestCase
{
use DatabaseTransactions;
/**
* Test that unauthenticated users are redirected to login
*/
public function test_unauthenticated_redirects_to_login()
{
$this->browse(function (Browser $browser) {
$browser->visit('http://dishplanner_app:8000/dashboard')
it('redirects unauthenticated users to login', function () {
visit('/dashboard')
->assertPathIs('/login')
->assertSee('Login');
});
}
->assertSee('Sign In');
});
/**
* Test that login page loads correctly
*/
public function test_login_page_loads()
{
$this->browse(function (Browser $browser) {
$browser->visit('http://dishplanner_app:8000/login')
it('loads the login page', function () {
visit('/login')
->assertPathIs('/login')
->assertSee('Login')
->assertSee('Email')
->assertSee('Password');
});
}
}
});

View file

@ -1,128 +1,66 @@
<?php
namespace Tests\Browser\Schedule;
use App\Models\Dish;
use App\Models\Planner;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Laravel\Dusk\Browser;
use Tests\Browser\Pages\SchedulePage;
use Tests\DuskTestCase;
class GenerateScheduleTest extends DuskTestCase
/**
* @return array{0: Planner, 1: User}
*/
function makeScheduleFixture(): array
{
protected static $planner = null;
protected static $email = null;
protected static $password = 'password';
protected static $user = null;
protected static $dish = null;
protected function setUp(): void
{
parent::setUp();
// Create test data if not exists
if (self::$planner === null) {
self::$email = fake()->unique()->safeEmail();
self::$planner = Planner::factory()->create([
'email' => self::$email,
'password' => Hash::make(self::$password),
$planner = Planner::factory()->create([
'email' => fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
]);
// Create a user for this planner
self::$user = User::factory()->create([
'planner_id' => self::$planner->id,
$user = User::factory()->create([
'planner_id' => $planner->id,
'name' => 'Test User',
]);
// Create a dish and assign to user
self::$dish = Dish::factory()->create([
'planner_id' => self::$planner->id,
$dish = Dish::factory()->create([
'planner_id' => $planner->id,
'name' => 'Test Dish',
]);
// Attach user to dish (creates UserDish)
self::$dish->users()->attach(self::$user);
}
}
$dish->users()->attach($user);
protected function loginAsPlanner(Browser $browser): Browser
{
$browser->driver->manage()->deleteAllCookies();
return $browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', DuskTestCase::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$email)
->clear('input[id="password"]')
->type('input[id="password"]', self::$password)
->press('Login')
->waitForLocation('/dashboard', DuskTestCase::TIMEOUT_MEDIUM)
->pause(DuskTestCase::PAUSE_SHORT)
->visit('http://dishplanner_app:8000/schedule')
->pause(DuskTestCase::PAUSE_MEDIUM);
}
public function test_can_generate_schedule_with_user_and_dish(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
->assertSee('Test User') // User should be in selection
->clickGenerate()
->pause(2000)
// Verify schedule was generated by checking dish appears on calendar
->assertSee('Test Dish');
});
}
public function test_generated_schedule_shows_dish_on_calendar(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
->clickGenerate()
->pause(2000)
// The dish should appear somewhere on the calendar
->assertSee('Test Dish');
});
}
public function test_can_clear_month_schedule(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
// First generate a schedule
->clickGenerate()
->pause(2000)
->assertSee('Test Dish') // Verify generated
// Then clear it
->clickClearMonth()
->pause(1000)
// After clearing, should see "No dishes scheduled" on calendar days
->assertSee('No dishes scheduled');
});
}
public function test_user_selection_affects_generation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
// Verify the user checkbox is present
->assertSee('Test User')
// User should be selected by default
->assertChecked("input[value='".self::$user->id."']");
});
}
return [$planner, $user];
}
it('can generate a schedule with a user and dish', function () {
[$planner] = makeScheduleFixture();
loginAs($planner, '/schedule')
->assertSee('Test User')
->click('button[wire\:click="generate"]')
->assertSee('Test Dish');
});
it('shows the generated dish on the calendar', function () {
[$planner] = makeScheduleFixture();
loginAs($planner, '/schedule')
->click('button[wire\:click="generate"]')
->assertSee('Test Dish');
});
it('can clear the month schedule', function () {
[$planner] = makeScheduleFixture();
loginAs($planner, '/schedule')
->click('button[wire\:click="generate"]')
->assertSee('Test Dish')
->click('button[wire\:click="clearMonth"]')
->assertSee('No dishes scheduled');
});
it('selects all users by default', function () {
[$planner, $user] = makeScheduleFixture();
loginAs($planner, '/schedule')
->assertSee('Test User')
->assertChecked('input[value="'.$user->id.'"]');
});

View file

@ -1,101 +1,43 @@
<?php
namespace Tests\Browser\Schedule;
use Laravel\Dusk\Browser;
use Tests\Browser\LoginHelpers;
use Tests\Browser\Pages\SchedulePage;
use Tests\DuskTestCase;
class SchedulePageTest extends DuskTestCase
{
use LoginHelpers;
protected static $schedulePageTestPlanner = null;
protected static $schedulePageTestEmail = null;
protected function setUp(): void
{
parent::setUp();
self::$testPlanner = self::$schedulePageTestPlanner;
self::$testEmail = self::$schedulePageTestEmail;
}
protected function tearDown(): void
{
self::$schedulePageTestPlanner = self::$testPlanner;
self::$schedulePageTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_access_schedule_page(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
it('can access the schedule page', function () {
loginAndGoToSchedule()
->assertSee('SCHEDULE')
->assertSee('Generate Schedule');
});
}
});
public function test_schedule_page_has_month_navigation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
->assertPresent('@previous-month')
->assertPresent('@next-month')
it('shows the month navigation', function () {
loginAndGoToSchedule()
->assertPresent('button[wire\:click="previousMonth"]')
->assertPresent('button[wire\:click="nextMonth"]')
->assertSee(now()->format('F Y'));
});
}
public function test_can_navigate_to_next_month(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
});
it('can navigate to the next month', function () {
$nextMonth = now()->addMonth();
$browser->on(new SchedulePage)
->goToNextMonth()
loginAndGoToSchedule()
->click('button[wire\:click="nextMonth"]')
->assertSee($nextMonth->format('F Y'));
});
}
});
public function test_can_navigate_to_previous_month(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
it('can navigate to the previous month', function () {
$previousMonth = now()->subMonth();
$prevMonth = now()->subMonth();
loginAndGoToSchedule()
->click('button[wire\:click="previousMonth"]')
->assertSee($previousMonth->format('F Y'));
});
$browser->on(new SchedulePage)
->goToPreviousMonth()
->assertSee($prevMonth->format('F Y'));
});
}
public function test_schedule_generator_shows_user_selection(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
it('shows the user selection', function () {
loginAndGoToSchedule()
->assertSee('Select Users')
->assertPresent('@generate-button')
->assertPresent('@clear-month-button');
});
}
->assertPresent('button[wire\:click="generate"]')
->assertPresent('button[wire\:click="clearMonth"]');
});
public function test_calendar_displays_days_of_week(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
it('shows the days of the week', function () {
loginAndGoToSchedule()
->assertSee('Mon')
->assertSee('Tue')
->assertSee('Wed')
@ -103,6 +45,4 @@ public function test_calendar_displays_days_of_week(): void
->assertSee('Fri')
->assertSee('Sat')
->assertSee('Sun');
});
}
}
});

View file

@ -1,104 +1,43 @@
<?php
namespace Tests\Browser\Users;
use Laravel\Dusk\Browser;
use Tests\Browser\LoginHelpers;
use Tests\Browser\Pages\UsersPage;
use Tests\DuskTestCase;
class CreateUserTest extends DuskTestCase
{
use LoginHelpers;
protected static $createUserTestPlanner = null;
protected static $createUserTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createUserTestPlanner;
self::$testEmail = self::$createUserTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createUserTestPlanner = self::$testPlanner;
self::$createUserTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_access_users_page(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
it('can access the users page', function () {
loginAndGoToUsers()
->assertSee('MANAGE USERS')
->assertSee('Add User');
});
}
});
public function test_can_open_create_user_modal(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
it('can open the create user modal', function () {
loginAndGoToUsers()
->click('button[wire\:click="create"]')
->assertSee('Add New User')
->assertSee('Name')
->assertSee('Cancel')
->assertSee('Create User');
});
}
});
public function test_create_user_form_validation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
it('validates the user name field', function () {
loginAndGoToUsers()
->click('button[wire\:click="create"]')
->press('Create User')
->pause(self::PAUSE_MEDIUM)
->assertSee('The name field is required');
});
}
});
public function test_can_create_user(): void
{
$this->browse(function (Browser $browser) {
it('can create a user', function () {
$userName = 'TestCreate_'.uniqid();
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
->type('input[wire\\:model="name"]', $userName)
loginAndGoToUsers()
->click('button[wire\:click="create"]')
->type('input[wire\:model="name"]', $userName)
->press('Create User')
->pause(self::PAUSE_MEDIUM)
->assertSee('User created successfully')
->assertSee($userName);
});
}
});
public function test_can_cancel_user_creation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
->type('input[wire\\:model="name"]', 'Test Cancel User')
it('can cancel user creation', function () {
loginAndGoToUsers()
->click('button[wire\:click="create"]')
->type('input[wire\:model="name"]', 'Test Cancel User')
->press('Cancel')
->pause(self::PAUSE_SHORT)
// Modal should be closed, we should be back on users page
->assertSee('MANAGE USERS')
->assertDontSee('Add New User');
});
}
}
});

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

12
tests/BrowserTestCase.php Normal file
View file

@ -0,0 +1,12 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class BrowserTestCase extends BaseTestCase
{
// Intentionally does NOT call withoutVite(): browser tests drive the real
// frontend (Livewire + Alpine) through the in-process server, so Vite must
// resolve to the built assets rather than be stubbed out.
}

View file

@ -1,55 +0,0 @@
<?php
namespace Tests;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Laravel\Dusk\TestCase as BaseTestCase;
use PHPUnit\Framework\Attributes\BeforeClass;
abstract class DuskTestCase extends BaseTestCase
{
// Timeout constants for consistent timing across all Dusk tests
public const TIMEOUT_SHORT = 2; // 2 seconds max for most operations
public const TIMEOUT_MEDIUM = 3; // 3 seconds for slower operations
public const PAUSE_SHORT = 500; // 0.5 seconds for quick pauses
public const PAUSE_MEDIUM = 1000; // 1 second for medium pauses
/**
* Prepare for Dusk test execution.
*/
#[BeforeClass]
public static function prepare(): void
{
// Don't start ChromeDriver - we're using Selenium
}
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments([
'--window-size=1920,1080',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--headless=new',
'--disable-extensions',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
]);
return RemoteWebDriver::create(
'http://selenium:4444/wd/hub', // Connect to Selenium container
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY, $options
)
);
}
}

63
tests/Pest.php Normal file
View file

@ -0,0 +1,63 @@
<?php
use App\Models\Planner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\BrowserTestCase;
use Tests\TestCase;
uses(TestCase::class)->in('Feature', 'Unit');
uses(BrowserTestCase::class, RefreshDatabase::class)->in('Browser');
// Livewire round-trips can be slower than the default 5s auto-wait budget.
pest()->browser()->timeout(10000);
/**
* Create a planner with a known password for browser logins.
*/
function createPlanner(?string $email = null): Planner
{
return Planner::factory()->create([
'email' => $email ?? fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
]);
}
/**
* Log in as the given planner and navigate to the target page.
*
* @return \Pest\Browser\Api\Webpage
*/
function loginAs(Planner $planner, string $page = '/dashboard')
{
return visit('/login')
->type('input[id="email"]', $planner->email)
->type('input[id="password"]', 'password')
->press('Sign In')
->assertPathIs('/dashboard')
->navigate($page);
}
/**
* @return \Pest\Browser\Api\Webpage
*/
function loginAndGoToDishes()
{
return loginAs(createPlanner(), '/dishes');
}
/**
* @return \Pest\Browser\Api\Webpage
*/
function loginAndGoToUsers()
{
return loginAs(createPlanner(), '/users');
}
/**
* @return \Pest\Browser\Api\Webpage
*/
function loginAndGoToSchedule()
{
return loginAs(createPlanner(), '/schedule');
}