diff --git a/.github/actions/setup-laravel/action.yml b/.github/actions/setup-laravel/action.yml new file mode 100644 index 00000000..fb27885c --- /dev/null +++ b/.github/actions/setup-laravel/action.yml @@ -0,0 +1,65 @@ +name: 'Setup Laravel test environment' +description: 'Set up PHP, Composer, optional Node, the app key, database, and Passport keys for a test job.' + +inputs: + node: + description: 'Also set up Node.js and install npm dependencies.' + default: 'false' + db-port: + description: 'Host port mapped to the Postgres service.' + required: true + redis-port: + description: 'Host port mapped to the Redis service.' + required: true + +runs: + using: 'composite' + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis + coverage: none + + - name: Setup Node.js + if: inputs.node == 'true' + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Cache Composer dependencies + uses: actions/cache@v6 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Prepare environment + shell: bash + run: cp .env.ci .env + + - name: Install Composer dependencies + shell: bash + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Install npm dependencies + if: inputs.node == 'true' + shell: bash + run: npm ci + + - name: Generate application key + shell: bash + run: php artisan key:generate + + - name: Run migrations + shell: bash + env: + DB_PORT: ${{ inputs.db-port }} + REDIS_PORT: ${{ inputs.redis-port }} + run: php artisan migrate --force + + - name: Generate Passport keys + shell: bash + run: php artisan passport:keys --force diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 09d1c471..c55a1127 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: quality: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml index 9558ca02..16a22ec6 100644 --- a/.github/workflows/release-docker.yml +++ b/.github/workflows/release-docker.yml @@ -36,7 +36,7 @@ jobs: platform: ${{ matrix.platform }} - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Docker metadata (labels) id: meta @@ -76,7 +76,7 @@ jobs: touch "${{ runner.temp }}/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: digests-${{ env.PLATFORM_PAIR }} path: ${{ runner.temp }}/digests/* @@ -92,7 +92,7 @@ jobs: packages: write steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: ${{ runner.temp }}/digests pattern: digests-* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e733fb0f..497efe6d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,18 +6,19 @@ on: pull_request: branches: [main, develop] +env: + DB_CONNECTION: pgsql + DB_DATABASE: trypost_test + DB_USERNAME: postgres + DB_PASSWORD: password + BROADCAST_CONNECTION: "null" + CACHE_STORE: array + QUEUE_CONNECTION: sync + SESSION_DRIVER: array + jobs: - tests: + backend: runs-on: ubuntu-latest - env: - DB_CONNECTION: pgsql - DB_DATABASE: trypost_test - DB_USERNAME: postgres - DB_PASSWORD: password - BROADCAST_CONNECTION: "null" - CACHE_STORE: array - QUEUE_CONNECTION: sync - SESSION_DRIVER: array services: postgres: @@ -46,54 +47,89 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - name: Setup test environment + uses: ./.github/actions/setup-laravel with: - php-version: '8.4' - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis - coverage: none + db-port: ${{ job.services.postgres.ports['5432'] }} + redis-port: ${{ job.services.redis.ports['6379'] }} - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' + - name: Run backend tests + env: + DB_PORT: ${{ job.services.postgres.ports['5432'] }} + REDIS_PORT: ${{ job.services.redis.ports['6379'] }} + run: php artisan test --compact --parallel - - name: Cache Composer dependencies - uses: actions/cache@v4 - with: - path: vendor - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- + e2e: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + shard: [1, 2] - - name: Prepare environment - run: cp .env.ci .env + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: trypost_test + ports: + - 5432/tcp + options: >- + --health-cmd="pg_isready" + --health-interval=10s + --health-timeout=5s + --health-retries=3 - - name: Install Composer dependencies - run: composer install --no-interaction --prefer-dist --optimize-autoloader + redis: + image: redis:7 + ports: + - 6379/tcp + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 - - name: Install npm dependencies - run: npm ci + steps: + - name: Checkout code + uses: actions/checkout@v7 - - name: Generate application key - run: php artisan key:generate + - name: Setup test environment + uses: ./.github/actions/setup-laravel + with: + node: 'true' + db-port: ${{ job.services.postgres.ports['5432'] }} + redis-port: ${{ job.services.redis.ports['6379'] }} - name: Build assets run: npm run build - - name: Run migrations - run: php artisan migrate --force - env: - DB_PORT: ${{ job.services.postgres.ports['5432'] }} - REDIS_PORT: ${{ job.services.redis.ports['6379'] }} - - - name: Generate Passport keys - run: php artisan passport:keys --force + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium - - name: Run tests - run: php artisan test --compact --parallel + - name: Run browser tests env: DB_PORT: ${{ job.services.postgres.ports['5432'] }} REDIS_PORT: ${{ job.services.redis.ports['6379'] }} + run: php artisan test tests/Browser --compact --shard=${{ matrix.shard }}/2 + + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-artifacts-${{ matrix.shard }} + path: tests/Browser/Screenshots + if-no-files-found: ignore + retention-days: 7 + + e2e-gate: + if: always() + needs: e2e + runs-on: ubuntu-latest + steps: + - name: Require all e2e shards to pass + run: '[ "${{ needs.e2e.result }}" = "success" ] || exit 1' diff --git a/.gitignore b/.gitignore index 508a4046..e61ad860 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /.phpunit.cache /bootstrap/ssr /node_modules +/tests/Browser/Screenshots /public/build /lang/php_*.json /public/hot diff --git a/composer.json b/composer.json index 5768bda8..8d3a7946 100644 --- a/composer.json +++ b/composer.json @@ -74,6 +74,7 @@ "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", "pestphp/pest": "^4.4", + "pestphp/pest-plugin-browser": "^4.3", "pestphp/pest-plugin-laravel": "^4.1" }, "autoload": { @@ -120,6 +121,10 @@ "@test:lint", "@php artisan test" ], + "test:all": [ + "@test", + "@php artisan test tests/Browser" + ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" diff --git a/composer.lock b/composer.lock index e556996b..5c17b705 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f2f0d0ee195cb8181f595e304a6cfce0", + "content-hash": "25153dfd602f4770eee4612fef9e2857", "packages": [ { "name": "aws/aws-crt-php", @@ -10905,6 +10905,1236 @@ } ], "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/2f3ebed5a4f663968a0590dbb7654a8b32cb63cb", + "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-06-21T13:59:44+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" + }, + { + "name": "amphp/hpack", + "version": "v3.2.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/hpack.git", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "http2jp/hpack-test-case": "^1", + "nikic/php-fuzzer": "^0.0.11", + "phpunit/phpunit": "^7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "HTTP/2 HPack implementation.", + "homepage": "https://github.com/amphp/hpack", + "keywords": [ + "headers", + "hpack", + "http-2" + ], + "support": { + "issues": "https://github.com/amphp/hpack/issues", + "source": "https://github.com/amphp/hpack/tree/v3.2.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-03T19:28:59+00:00" + }, + { + "name": "amphp/http", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/http.git", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "shasum": "" + }, + "require": { + "amphp/hpack": "^3", + "amphp/parser": "^1.1", + "league/uri-components": "^2.4.2 | ^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "league/uri": "^6.8 | ^7.1", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.26.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/constants.php" + ], + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Basic HTTP primitives which can be shared by servers and clients.", + "support": { + "issues": "https://github.com/amphp/http/issues", + "source": "https://github.com/amphp/http/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-11-23T14:57:26+00:00" + }, + { + "name": "amphp/http-client", + "version": "v5.3.6", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-client.git", + "reference": "ca155026acafa74a612d776a97202d53077fee86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-client/zipball/ca155026acafa74a612d776a97202d53077fee86", + "reference": "ca155026acafa74a612d776a97202d53077fee86", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "league/uri": "^7", + "league/uri-components": "^7", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "revolt/event-loop": "^1" + }, + "conflict": { + "amphp/file": "<3 | >=5" + }, + "require-dev": { + "amphp/file": "^3 | ^4", + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "ext-json": "*", + "kelunik/link-header-rfc5988": "^1", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "suggest": { + "amphp/file": "Required for file request bodies and HTTP archive logging", + "ext-json": "Required for logging HTTP archives", + "ext-zlib": "Allows using compression for response bodies." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\Http\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", + "homepage": "https://amphp.org/http-client", + "keywords": [ + "async", + "client", + "concurrent", + "http", + "non-blocking", + "rest" + ], + "support": { + "issues": "https://github.com/amphp/http-client/issues", + "source": "https://github.com/amphp/http-client/tree/v5.3.6" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-15T23:29:38+00:00" + }, + { + "name": "amphp/http-server", + "version": "v3.4.6", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/8a971bf92cf8cf2bc511f37a75b39126d5305315", + "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2.1", + "amphp/sync": "^2.2", + "league/uri": "^7.1", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "psr/log": "^1 | ^2 | ^3", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-client": "^5", + "amphp/log": "^2", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "league/uri-components": "^7.1", + "monolog/monolog": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "suggest": { + "ext-zlib": "Allows GZip compression of response bodies" + }, + "type": "library", + "autoload": { + "files": [ + "src/Driver/functions.php", + "src/Middleware/functions.php", + "src/functions.php" + ], + "psr-4": { + "Amp\\Http\\Server\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "A non-blocking HTTP application server for PHP based on Amp.", + "homepage": "https://github.com/amphp/http-server", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "server" + ], + "support": { + "issues": "https://github.com/amphp/http-server/issues", + "source": "https://github.com/amphp/http-server/tree/v3.4.6" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-06-27T10:31:48+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.5", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "92f121dde31cd1d89d5d0f9eba64ac40271b236e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/92f121dde31cd1d89d5d0f9eba64ac40271b236e", + "reference": "92f121dde31cd1d89d5d0f9eba64ac40271b236e", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.5" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-06-27T14:17:20+00:00" + }, + { + "name": "amphp/process", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "583959df17d00304ad7b0b32285373f985935643" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643", + "reference": "583959df17d00304ad7b0b32285373f985935643", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-31T15:11:55+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^7", + "league/uri-interfaces": "^7", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-19T15:09:56+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "amphp/websocket", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket.git", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "suggest": { + "ext-zlib": "Required for compression" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + } + ], + "description": "Shared code for websocket servers and clients.", + "homepage": "https://github.com/amphp/websocket", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket/issues", + "source": "https://github.com/amphp/websocket/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-10-28T21:28:45+00:00" + }, + { + "name": "amphp/websocket-client", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket-client.git", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2.1", + "amphp/http": "^2.1", + "amphp/http-client": "^5", + "amphp/socket": "^2.2", + "amphp/websocket": "^2", + "league/uri": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1|^2", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/websocket-server": "^3|^4", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.26.1", + "psr/log": "^1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Async WebSocket client for PHP based on Amp.", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket-client/issues", + "source": "https://github.com/amphp/websocket-client/tree/v2.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-24T17:25:34+00:00" + }, { "name": "brianium/paratest", "version": "v7.20.0", @@ -11140,6 +12370,50 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, { "name": "doctrine/deprecations", "version": "1.1.6", @@ -11494,6 +12768,64 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, { "name": "laravel/pail", "version": "v1.2.7", @@ -11774,6 +13106,90 @@ }, "time": "2026-04-06T12:52:26+00:00" }, + { + "name": "league/uri-components", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-components.git", + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/848ff9db2f0be06229d6034b7c2e33d41b4fd675", + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675", + "shasum": "" + }, + "require": { + "league/uri": "^7.8.1", + "php": "^8.1" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-mbstring": "to use the sorting algorithm of URLSearchParams", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI components manipulation library", + "homepage": "http://uri.thephpleague.com", + "keywords": [ + "authority", + "components", + "fragment", + "host", + "middleware", + "modifier", + "path", + "port", + "query", + "rfc3986", + "scheme", + "uri", + "url", + "userinfo" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-components/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", @@ -12272,6 +13688,89 @@ ], "time": "2026-04-10T17:20:19+00:00" }, + { + "name": "pestphp/pest-plugin-browser", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-browser.git", + "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/b6e76d3e4a2f81da9f050ec54be2a29b402287c4", + "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.1.1", + "amphp/http-server": "^3.4.4", + "amphp/websocket-client": "^2.0.2", + "ext-sockets": "*", + "pestphp/pest": "^4.4.5", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "symfony/process": "^7.4.8|^8.0.5" + }, + "require-dev": { + "ext-pcntl": "*", + "ext-posix": "*", + "livewire/livewire": "^3.7.15", + "nunomaduro/collision": "^8.9.3", + "orchestra/testbench": "^10.11.0", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-laravel": "^4.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Browser\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Browser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Pest plugin to test browser interactions", + "keywords": [ + "browser", + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-08T21:04:12+00:00" + }, { "name": "pestphp/pest-plugin-laravel", "version": "v4.1.0", @@ -13207,6 +14706,78 @@ ], "time": "2026-06-04T06:14:42+00:00" }, + { + "name": "revolt/event-loop", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" + }, + "time": "2026-05-16T17:55:38+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.1", diff --git a/lang/ar/common.php b/lang/ar/common.php index 59fd7803..e99f2356 100644 --- a/lang/ar/common.php +++ b/lang/ar/common.php @@ -21,6 +21,12 @@ 'uploading' => 'جارٍ الرفع...', 'remove' => 'إزالة الصورة', 'hint' => 'موصى به: صورة مربعة، بحد أقصى 2 ميغابايت.', + 'crop_title' => 'قص الصورة', + 'crop_description' => 'حدّد المنطقة التي تريد الاحتفاظ بها.', + 'crop_hint' => 'اسحب للتحريك، أو زاوية لتغيير الحجم.', + 'crop_save' => 'حفظ', + 'crop_cancel' => 'إلغاء', + 'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.', ], 'timezone' => [ diff --git a/lang/de/common.php b/lang/de/common.php index ce786c16..4e6d4b99 100644 --- a/lang/de/common.php +++ b/lang/de/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Wird hochgeladen...', 'remove' => 'Foto entfernen', 'hint' => 'Empfohlen: quadratisches Bild, max. 2 MB.', + 'crop_title' => 'Bild zuschneiden', + 'crop_description' => 'Wählen Sie den Bereich aus, den Sie behalten möchten.', + 'crop_hint' => 'Zum Verschieben ziehen oder an einer Ecke die Größe ändern.', + 'crop_save' => 'Speichern', + 'crop_cancel' => 'Abbrechen', + 'crop_error' => 'Dieses Bild konnte nicht geladen werden. Versuchen Sie eine andere Datei.', ], 'timezone' => [ diff --git a/lang/el/common.php b/lang/el/common.php index 0bb7db23..c18cb1a1 100644 --- a/lang/el/common.php +++ b/lang/el/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Μεταφόρτωση...', 'remove' => 'Αφαίρεση φωτογραφίας', 'hint' => 'Συνιστάται: τετράγωνη εικόνα, έως 2 MB.', + 'crop_title' => 'Περικοπή εικόνας', + 'crop_description' => 'Επιλέξτε την περιοχή που θέλετε να κρατήσετε.', + 'crop_hint' => 'Σύρετε για μετακίνηση ή μια γωνία για αλλαγή μεγέθους.', + 'crop_save' => 'Αποθήκευση', + 'crop_cancel' => 'Άκυρο', + 'crop_error' => 'Δεν ήταν δυνατή η φόρτωση αυτής της εικόνας. Δοκιμάστε άλλο αρχείο.', ], 'timezone' => [ diff --git a/lang/en/common.php b/lang/en/common.php index a41a2d55..6bda1664 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Uploading...', 'remove' => 'Remove photo', 'hint' => 'Recommended: square image, max 2 MB.', + 'crop_title' => 'Crop image', + 'crop_description' => 'Select the area you want to keep.', + 'crop_hint' => 'Drag to move, or a corner to resize.', + 'crop_save' => 'Save', + 'crop_cancel' => 'Cancel', + 'crop_error' => 'Couldn\'t load this image. Try another file.', ], 'timezone' => [ diff --git a/lang/es/common.php b/lang/es/common.php index fe84cf9b..2a532649 100644 --- a/lang/es/common.php +++ b/lang/es/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Subiendo...', 'remove' => 'Eliminar foto', 'hint' => 'Recomendado: imagen cuadrada, máximo 2 MB.', + 'crop_title' => 'Recortar imagen', + 'crop_description' => 'Selecciona el área que quieres conservar.', + 'crop_hint' => 'Arrastra para mover o una esquina para redimensionar.', + 'crop_save' => 'Guardar', + 'crop_cancel' => 'Cancelar', + 'crop_error' => 'No se pudo cargar esta imagen. Prueba con otro archivo.', ], 'timezone' => [ diff --git a/lang/fr/common.php b/lang/fr/common.php index 206a805d..2531f3ea 100644 --- a/lang/fr/common.php +++ b/lang/fr/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Import en cours...', 'remove' => 'Supprimer la photo', 'hint' => 'Recommandé : image carrée, 2 Mo maximum.', + 'crop_title' => 'Recadrer l\'image', + 'crop_description' => 'Sélectionnez la zone à conserver.', + 'crop_hint' => 'Faites glisser pour déplacer, ou un coin pour redimensionner.', + 'crop_save' => 'Enregistrer', + 'crop_cancel' => 'Annuler', + 'crop_error' => 'Impossible de charger cette image. Essayez un autre fichier.', ], 'timezone' => [ diff --git a/lang/it/common.php b/lang/it/common.php index 44785808..1d7cf87a 100644 --- a/lang/it/common.php +++ b/lang/it/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Caricamento in corso...', 'remove' => 'Rimuovi foto', 'hint' => 'Consigliato: immagine quadrata, max 2 MB.', + 'crop_title' => 'Ritaglia immagine', + 'crop_description' => 'Seleziona l\'area da mantenere.', + 'crop_hint' => 'Trascina per spostare o un angolo per ridimensionare.', + 'crop_save' => 'Salva', + 'crop_cancel' => 'Annulla', + 'crop_error' => 'Impossibile caricare questa immagine. Prova un altro file.', ], 'timezone' => [ diff --git a/lang/ja/common.php b/lang/ja/common.php index 96e89257..539f41fe 100644 --- a/lang/ja/common.php +++ b/lang/ja/common.php @@ -21,6 +21,12 @@ 'uploading' => 'アップロード中...', 'remove' => '写真を削除', 'hint' => '推奨: 正方形の画像、最大 2 MB。', + 'crop_title' => '画像を切り抜く', + 'crop_description' => '残したい範囲を選択してください。', + 'crop_hint' => 'ドラッグで移動、角でサイズ変更できます。', + 'crop_save' => '保存', + 'crop_cancel' => 'キャンセル', + 'crop_error' => 'この画像を読み込めませんでした。別のファイルをお試しください。', ], 'timezone' => [ diff --git a/lang/ko/common.php b/lang/ko/common.php index 5bb2d175..ef309ea0 100644 --- a/lang/ko/common.php +++ b/lang/ko/common.php @@ -21,6 +21,12 @@ 'uploading' => '업로드 중...', 'remove' => '사진 제거', 'hint' => '권장: 정사각형 이미지, 최대 2MB.', + 'crop_title' => '이미지 자르기', + 'crop_description' => '남길 영역을 선택하세요.', + 'crop_hint' => '드래그하여 이동하거나 모서리로 크기를 조절하세요.', + 'crop_save' => '저장', + 'crop_cancel' => '취소', + 'crop_error' => '이 이미지를 불러올 수 없습니다. 다른 파일을 사용해 보세요.', ], 'timezone' => [ diff --git a/lang/nl/common.php b/lang/nl/common.php index 7fb1ead5..6046682e 100644 --- a/lang/nl/common.php +++ b/lang/nl/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Uploaden...', 'remove' => 'Foto verwijderen', 'hint' => 'Aanbevolen: vierkante afbeelding, max. 2 MB.', + 'crop_title' => 'Afbeelding bijsnijden', + 'crop_description' => 'Selecteer het gebied dat je wilt behouden.', + 'crop_hint' => 'Sleep om te verplaatsen of een hoek om te vergroten of verkleinen.', + 'crop_save' => 'Opslaan', + 'crop_cancel' => 'Annuleren', + 'crop_error' => 'Kan deze afbeelding niet laden. Probeer een ander bestand.', ], 'timezone' => [ diff --git a/lang/pl/common.php b/lang/pl/common.php index ed7a3fe1..d4b0e2db 100644 --- a/lang/pl/common.php +++ b/lang/pl/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Przesyłanie...', 'remove' => 'Usuń zdjęcie', 'hint' => 'Zalecane: kwadratowe zdjęcie, maks. 2 MB.', + 'crop_title' => 'Przytnij obraz', + 'crop_description' => 'Zaznacz obszar, który chcesz zachować.', + 'crop_hint' => 'Przeciągnij, aby przesunąć, lub róg, aby zmienić rozmiar.', + 'crop_save' => 'Zapisz', + 'crop_cancel' => 'Anuluj', + 'crop_error' => 'Nie można załadować tego obrazu. Spróbuj innego pliku.', ], 'timezone' => [ diff --git a/lang/pt-BR/common.php b/lang/pt-BR/common.php index a64d8a34..5c41d709 100644 --- a/lang/pt-BR/common.php +++ b/lang/pt-BR/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Enviando...', 'remove' => 'Remover foto', 'hint' => 'Recomendado: imagem quadrada, máximo 2 MB.', + 'crop_title' => 'Cortar imagem', + 'crop_description' => 'Selecione a área que deseja manter.', + 'crop_hint' => 'Arraste para mover ou um canto para redimensionar.', + 'crop_save' => 'Salvar', + 'crop_cancel' => 'Cancelar', + 'crop_error' => 'Não foi possível carregar esta imagem. Tente outro arquivo.', ], 'timezone' => [ diff --git a/lang/ru/common.php b/lang/ru/common.php index 6b7e39fd..aecc5a2c 100644 --- a/lang/ru/common.php +++ b/lang/ru/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Загрузка...', 'remove' => 'Удалить фото', 'hint' => 'Рекомендуется: квадратное изображение, до 2 МБ.', + 'crop_title' => 'Обрезать изображение', + 'crop_description' => 'Выберите область, которую нужно оставить.', + 'crop_hint' => 'Перетащите, чтобы переместить, или угол — чтобы изменить размер.', + 'crop_save' => 'Сохранить', + 'crop_cancel' => 'Отмена', + 'crop_error' => 'Не удалось загрузить это изображение. Попробуйте другой файл.', ], 'timezone' => [ diff --git a/lang/tr/common.php b/lang/tr/common.php index 294ef152..f73011a5 100644 --- a/lang/tr/common.php +++ b/lang/tr/common.php @@ -21,6 +21,12 @@ 'uploading' => 'Yükleniyor...', 'remove' => 'Fotoğrafı kaldır', 'hint' => 'Önerilen: kare görsel, en fazla 2 MB.', + 'crop_title' => 'Görseli kırp', + 'crop_description' => 'Saklamak istediğiniz alanı seçin.', + 'crop_hint' => 'Taşımak için sürükleyin veya boyutlandırmak için bir köşeyi sürükleyin.', + 'crop_save' => 'Kaydet', + 'crop_cancel' => 'İptal', + 'crop_error' => 'Bu görsel yüklenemedi. Başka bir dosya deneyin.', ], 'timezone' => [ diff --git a/lang/zh/common.php b/lang/zh/common.php index b16eb072..1eeb850b 100644 --- a/lang/zh/common.php +++ b/lang/zh/common.php @@ -21,6 +21,12 @@ 'uploading' => '上传中…', 'remove' => '移除照片', 'hint' => '推荐:正方形图片,最大 2 MB。', + 'crop_title' => '裁剪图片', + 'crop_description' => '选择要保留的区域。', + 'crop_hint' => '拖动以移动,拖动边角可调整大小。', + 'crop_save' => '保存', + 'crop_cancel' => '取消', + 'crop_error' => '无法加载此图片。请尝试其他文件。', ], 'timezone' => [ diff --git a/package-lock.json b/package-lock.json index 3941c5b3..51f067e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-vue": "^9.32.0", "laravel-echo": "^2.3.0", + "playwright": "^1.61.1", "prettier": "^3.4.2", "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-tailwindcss": "^0.6.11", @@ -1448,9 +1449,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1467,9 +1465,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1486,9 +1481,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1505,9 +1497,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1524,9 +1513,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1543,9 +1529,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1855,9 +1838,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1875,9 +1855,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1894,9 +1871,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1914,9 +1888,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7204,9 +7175,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7227,9 +7195,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7250,9 +7215,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7273,9 +7235,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7866,6 +7825,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 9f650655..5920e3a0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-vue": "^9.32.0", "laravel-echo": "^2.3.0", + "playwright": "^1.61.1", "prettier": "^3.4.2", "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/resources/js/components/ImageCropperDialog.vue b/resources/js/components/ImageCropperDialog.vue new file mode 100644 index 00000000..90b2fb2f --- /dev/null +++ b/resources/js/components/ImageCropperDialog.vue @@ -0,0 +1,368 @@ + + + diff --git a/resources/js/components/PhotoUpload.vue b/resources/js/components/PhotoUpload.vue index 3be75a6f..65c99b7e 100644 --- a/resources/js/components/PhotoUpload.vue +++ b/resources/js/components/PhotoUpload.vue @@ -2,8 +2,9 @@ import { router } from '@inertiajs/vue3'; import { IconTrash } from '@tabler/icons-vue'; import { trans } from 'laravel-vue-i18n'; -import { ref } from 'vue'; +import { ref, watch } from 'vue'; +import ImageCropperDialog from '@/components/ImageCropperDialog.vue'; import { Avatar } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { @@ -34,6 +35,17 @@ const props = withDefaults(defineProps(), { const fileInput = ref(null); const uploading = ref(false); +const cropOpen = ref(false); +const cropSrc = ref(null); +const cropFileName = ref('image.png'); +const cropMime = ref('image/png'); + +watch(cropOpen, (isOpen) => { + if (!isOpen) { + cropSrc.value = null; + } +}); + const sizeClasses = { sm: 'size-16', md: 'size-20', @@ -63,6 +75,22 @@ const handleFileChange = (event: Event) => { return; } + cropFileName.value = file.name || 'image.png'; + cropMime.value = file.type || 'image/png'; + + const reader = new FileReader(); + reader.onload = () => { + cropSrc.value = reader.result as string; + cropOpen.value = true; + }; + reader.readAsDataURL(file); + + if (fileInput.value) { + fileInput.value.value = ''; + } +}; + +const uploadCropped = (file: File) => { uploading.value = true; router.post( @@ -72,9 +100,6 @@ const handleFileChange = (event: Event) => { forceFormData: true, onFinish: () => { uploading.value = false; - if (fileInput.value) { - fileInput.value.value = ''; - } }, }, ); @@ -140,5 +165,13 @@ const handleDelete = () => { {{ $t('common.photo_upload.hint') }}

+ + diff --git a/resources/js/lib/imageCrop.ts b/resources/js/lib/imageCrop.ts new file mode 100644 index 00000000..41709459 --- /dev/null +++ b/resources/js/lib/imageCrop.ts @@ -0,0 +1,76 @@ +export type SourceRect = { + sx: number; + sy: number; + sw: number; + sh: number; +}; + +export type Corner = 'nw' | 'ne' | 'sw' | 'se'; + +const DEFAULT_SELECTION_RATIO = 0.8; + +const ENCODABLE_MIMES = ['image/jpeg', 'image/png', 'image/webp']; +const EXTENSIONS: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }; + +export const resolveOutputMime = (mimeType: string): string => + ENCODABLE_MIMES.includes(mimeType) ? mimeType : 'image/png'; + +export const resolveOutputFileName = (fileName: string, mime: string): string => + `${fileName.replace(/\.[^./]*$/, '') || 'image'}.${EXTENSIONS[mime] ?? 'png'}`; + +export const containScale = (naturalWidth: number, naturalHeight: number, viewport: number): number => { + if (naturalWidth <= 0 || naturalHeight <= 0) { + return 1; + } + + return Math.min(viewport / naturalWidth, viewport / naturalHeight); +}; + +export const defaultSelection = (naturalWidth: number, naturalHeight: number): SourceRect => { + const size = Math.min(naturalWidth, naturalHeight) * DEFAULT_SELECTION_RATIO; + + return { + sx: (naturalWidth - size) / 2, + sy: (naturalHeight - size) / 2, + sw: size, + sh: size, + }; +}; + +export const clampSelection = ( + selection: SourceRect, + naturalWidth: number, + naturalHeight: number, + minSize: number, +): SourceRect => { + const maxSize = Math.min(naturalWidth, naturalHeight); + const size = Math.min(Math.max(selection.sw, minSize), maxSize); + const sx = Math.min(Math.max(selection.sx, 0), naturalWidth - size); + const sy = Math.min(Math.max(selection.sy, 0), naturalHeight - size); + + return { sx, sy, sw: size, sh: size }; +}; + +export const resizeSelection = ( + selection: SourceRect, + corner: Corner, + px: number, + py: number, + naturalWidth: number, + naturalHeight: number, + minSize: number, +): SourceRect => { + const right = selection.sx + selection.sw; + const bottom = selection.sy + selection.sh; + + const anchorX = corner === 'nw' || corner === 'sw' ? right : selection.sx; + const anchorY = corner === 'nw' || corner === 'ne' ? bottom : selection.sy; + const horizontal = corner === 'ne' || corner === 'se' ? 1 : -1; + const vertical = corner === 'sw' || corner === 'se' ? 1 : -1; + + const size = Math.max(horizontal * (px - anchorX), vertical * (py - anchorY), minSize); + const sx = horizontal === 1 ? anchorX : anchorX - size; + const sy = vertical === 1 ? anchorY : anchorY - size; + + return clampSelection({ sx, sy, sw: size, sh: size }, naturalWidth, naturalHeight, minSize); +}; diff --git a/tests/Browser/ImageCropperTest.php b/tests/Browser/ImageCropperTest.php new file mode 100644 index 00000000..3de0b1e7 --- /dev/null +++ b/tests/Browser/ImageCropperTest.php @@ -0,0 +1,131 @@ +script(<< { + const findInput = () => document.querySelector('input[type="file"]'); + for (let attempt = 0; attempt < 50 && !findInput(); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const input = findInput(); + const bytes = Uint8Array.from(atob('{$base64}'), (character) => character.charCodeAt(0)); + const file = new File([bytes], 'logo.png', { type: 'image/png' }); + const data = new DataTransfer(); + data.items.add(file); + input.files = data.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + })(); + JS); +} + +/** + * Capture the next multipart upload the page sends, keeping the uploaded File so + * the test can decode it. The Pest browser server does not parse multipart + * bodies (its file handling is an open TODO), so we assert the crop dispatches + * a valid image rather than that it persists — persistence is covered by + * ProfileUpdateTest. + */ +function recordUpload(mixed $page): void +{ + $page->script(<<<'JS' + (() => { + window.__uploadRequest = null; + const open = XMLHttpRequest.prototype.open; + const send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url) { + this.__method = method; + this.__url = url; + return open.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function (body) { + if (body instanceof FormData) { + const photo = body.get('photo'); + window.__uploadFile = photo; + window.__uploadRequest = { + method: this.__method, + url: this.__url, + keys: [...body.keys()], + size: photo instanceof File ? photo.size : 0, + type: photo instanceof File ? photo.type : null, + }; + } + return send.apply(this, arguments); + }; + })(); + JS); +} + +test('cropping a selected photo dispatches a valid 512x512 avatar upload', function () { + $this->actingAs(User::factory()->create()); + + $page = visit(route('app.profile.edit')); + + selectPhoto($page); + recordUpload($page); + + $page->click('@crop-save') + ->assertNoJavaScriptErrors(); + + $request = json_decode((string) $page->script(<<<'JS' + (async () => { + for (let attempt = 0; attempt < 80 && !window.__uploadRequest; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!window.__uploadRequest) { + return 'null'; + } + const bitmap = await createImageBitmap(window.__uploadFile); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const sample = (x, y) => Array.from(context.getImageData(x, y, 1, 1).data); + return JSON.stringify({ + ...window.__uploadRequest, + width: bitmap.width, + height: bitmap.height, + pixels: { + topLeft: sample(128, 128), + topRight: sample(384, 128), + bottomLeft: sample(128, 384), + bottomRight: sample(384, 384), + }, + }); + })(); + JS), true); + + expect($request)->not->toBeNull() + ->and($request['method'])->toBe('POST') + ->and($request['url'])->toContain(route('app.profile.upload-photo', absolute: false)) + ->and($request['keys'])->toContain('photo') + ->and($request['size'])->toBeGreaterThan(0) + ->and($request['type'])->toBe('image/png') + ->and($request['width'])->toBe(512) + ->and($request['height'])->toBe(512); + + $isColour = function (array $pixel, array $rgb): bool { + return abs($pixel[0] - $rgb[0]) <= 24 + && abs($pixel[1] - $rgb[1]) <= 24 + && abs($pixel[2] - $rgb[2]) <= 24 + && $pixel[3] >= 250; + }; + + expect($isColour($request['pixels']['topLeft'], [255, 0, 0]))->toBeTrue() + ->and($isColour($request['pixels']['topRight'], [0, 255, 0]))->toBeTrue() + ->and($isColour($request['pixels']['bottomLeft'], [0, 0, 255]))->toBeTrue() + ->and($isColour($request['pixels']['bottomRight'], [255, 255, 0]))->toBeTrue(); +}); diff --git a/tests/BrowserTestCase.php b/tests/BrowserTestCase.php new file mode 100644 index 00000000..479adf35 --- /dev/null +++ b/tests/BrowserTestCase.php @@ -0,0 +1,14 @@ +use(RefreshDatabase::class) ->in('Feature', 'Unit'); +pest()->extend(BrowserTestCase::class) + ->use(RefreshDatabase::class) + ->in('Browser'); + /* |-------------------------------------------------------------------------- | Expectations diff --git a/tests/TestCase.php b/tests/TestCase.php index 3312b582..388d375c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -17,10 +17,18 @@ abstract class TestCase extends BaseTestCase */ protected $seed = true; + /** + * Whether to fake the Vite manifest. Browser tests drive a real browser and + * need the built assets, so they opt out via BrowserTestCase. + */ + protected bool $fakesVite = true; + protected function setUp(): void { parent::setUp(); - $this->withoutVite(); + if ($this->fakesVite) { + $this->withoutVite(); + } } } diff --git a/tests/fixtures/crop-quadrants.png b/tests/fixtures/crop-quadrants.png new file mode 100644 index 00000000..6ead7a23 Binary files /dev/null and b/tests/fixtures/crop-quadrants.png differ