diff --git a/.github/actions/setup-php/action.yml b/.github/actions/setup-php/action.yml deleted file mode 100644 index 9d385b3a1..000000000 --- a/.github/actions/setup-php/action.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Setup PHP Extensions -description: Install PHP extensions required by the test suite - -runs: - using: composite - steps: - - name: Install PHP extensions - shell: bash - run: | - apt-get update -qq - apt-get install -y -qq libgmp-dev libicu-dev libpq-dev libpng-dev libjpeg-dev libfreetype6-dev libsqlite3-dev > /dev/null - - # Core extensions (installed via docker-php-ext-install) - core_extensions="pcntl intl gmp pdo_pgsql pdo_mysql pdo_sqlite sqlite3 bcmath gd fileinfo" - to_install="" - for ext in $core_extensions; do - if ! php -m 2>/dev/null | grep -qi "^${ext}$"; then - to_install="$to_install $ext" - fi - done - - if [ -n "$to_install" ]; then - if echo "$to_install" | grep -q "gd"; then - docker-php-ext-configure gd --with-freetype --with-jpeg > /dev/null - fi - docker-php-ext-install -j$(nproc) $to_install > /dev/null - fi - - # PECL extensions - if ! php -m 2>/dev/null | grep -qi "^redis$"; then - pecl install redis > /dev/null - docker-php-ext-enable redis > /dev/null - fi - - # Disable Swoole shortnames to avoid shadowing foundation helpers - echo "swoole.use_shortname=Off" > /usr/local/etc/php/conf.d/99-swoole-shortname.ini diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile new file mode 100644 index 000000000..ce23a8c71 --- /dev/null +++ b/.github/docker/ci/Dockerfile @@ -0,0 +1,86 @@ +ARG PHP_VERSION=8.4 +ARG SWOOLE_VERSION=6.2.2 + +FROM phpswoole/swoole:${SWOOLE_VERSION}-php${PHP_VERSION} + +ARG PHP_VERSION +ARG SWOOLE_VERSION +ARG PIE_VERSION=1.4.9 +ARG PIE_CHECKSUM=19a31ddd4bfd08b9eb5eaad2e5f63e76e7919cae7683852da41c80da704ad6c0 + +LABEL org.opencontainers.image.source="https://github.com/hypervel/components" +LABEL org.opencontainers.image.description="PHP ${PHP_VERSION} and Swoole ${SWOOLE_VERSION} test image for Hypervel components" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN set -eux; \ + apt-get update; \ + apt-get install --yes --no-install-recommends \ + curl \ + git \ + libavif-dev \ + libfreetype6-dev \ + libgmp-dev \ + libicu-dev \ + libjpeg-dev \ + libtool \ + libmagickwand-dev \ + libpng-dev \ + libpq-dev \ + libsqlite3-dev \ + libwebp-dev \ + procps \ + unzip; \ + rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + bundled_extensions=(pcntl intl gmp pdo_pgsql pdo_mysql pdo_sqlite sqlite3 bcmath fileinfo); \ + bundled_extensions_to_install=(); \ + for extension in "${bundled_extensions[@]}"; do \ + if ! php -r "exit(extension_loaded('${extension}') ? 0 : 1);"; then \ + bundled_extensions_to_install+=("${extension}"); \ + fi; \ + done; \ + if ! php -r 'exit(extension_loaded("gd") && function_exists("imagewebp") && function_exists("imageavif") ? 0 : 1);'; then \ + bundled_extensions_to_install+=(gd); \ + docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp --with-avif; \ + fi; \ + if (( ${#bundled_extensions_to_install[@]} > 0 )); then \ + docker-php-ext-install -j"$(nproc)" "${bundled_extensions_to_install[@]}"; \ + fi + +RUN set -eux; \ + pie_packages=(); \ + if ! php -r 'exit(extension_loaded("redis") && version_compare((string) phpversion("redis"), "6.3.0", ">=") ? 0 : 1);'; then \ + pie_packages+=(phpredis/phpredis); \ + fi; \ + if ! php -r 'exit(extension_loaded("imagick") ? 0 : 1);'; then \ + pie_packages+=(imagick/imagick); \ + fi; \ + if (( ${#pie_packages[@]} > 0 )); then \ + curl \ + --connect-timeout 15 \ + --fail \ + --location \ + --retry 5 \ + --retry-all-errors \ + --retry-max-time 180 \ + --show-error \ + "https://github.com/php/pie/releases/download/${PIE_VERSION}/pie.phar" \ + --output /tmp/pie.phar; \ + printf '%s %s\n' "${PIE_CHECKSUM}" /tmp/pie.phar | sha256sum --check; \ + for pie_package in "${pie_packages[@]}"; do \ + php /tmp/pie.phar install --no-interaction "${pie_package}"; \ + done; \ + rm /tmp/pie.phar; \ + fi + +RUN printf '%s\n' 'swoole.use_shortname=Off' > /usr/local/etc/php/conf.d/99-swoole-shortname.ini + +RUN set -eux; \ + test "$(php -r 'echo PHP_MAJOR_VERSION, ".", PHP_MINOR_VERSION;')" = "${PHP_VERSION}"; \ + test "$(php -r 'echo phpversion("swoole");')" = "${SWOOLE_VERSION}"; \ + php -r '$extensions = ["bcmath", "ctype", "curl", "dom", "fileinfo", "filter", "gd", "gmp", "imagick", "intl", "mbstring", "openssl", "pcntl", "pdo", "pdo_mysql", "pdo_pgsql", "pdo_sqlite", "posix", "redis", "session", "sockets", "sqlite3", "swoole", "tokenizer", "zlib"]; foreach ($extensions as $extension) { if (! extension_loaded($extension)) { fwrite(STDERR, "Missing required extension: {$extension}\n"); exit(1); } } foreach (["imagewebp", "imageavif"] as $function) { if (! function_exists($function)) { fwrite(STDERR, "Missing required GD function: {$function}()\n"); exit(1); } } if (version_compare((string) phpversion("redis"), "6.3.0", "<")) { fwrite(STDERR, "Redis extension 6.3.0 or newer is required for full integration coverage.\n"); exit(1); } if ((bool) ini_get("swoole.use_shortname")) { fwrite(STDERR, "Swoole short names must be disabled.\n"); exit(1); }'; \ + composer --version --no-ansi; \ + git --version; \ + ps --version diff --git a/.github/workflows/ci-images.yml b/.github/workflows/ci-images.yml new file mode 100644 index 000000000..6684a13bf --- /dev/null +++ b/.github/workflows/ci-images.yml @@ -0,0 +1,79 @@ +name: Build CI Images + +on: + push: + branches: + - 0.4 + paths: + - .github/docker/ci/Dockerfile + - .github/workflows/ci-images.yml + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: ci-images-${{ github.repository }} + cancel-in-progress: false + +jobs: + build: + if: github.repository == 'hypervel/components' + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + fail-fast: false + max-parallel: 1 + matrix: + php: ["8.4", "8.5"] + + name: PHP ${{ matrix.php }} (Swoole 6.2.2) + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build image + env: + IMAGE: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 + run: | + docker build \ + --pull \ + --build-arg PHP_VERSION=${{ matrix.php }} \ + --tag "$IMAGE" \ + --file .github/docker/ci/Dockerfile \ + .github/docker/ci + + - name: Publish image + env: + IMAGE: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 + run: docker push "$IMAGE" + + cleanup: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 5 + + name: Remove old images + + steps: + - name: Remove old untagged images + uses: actions/delete-package-versions@v5 + with: + package-name: components-ci + package-type: container + min-versions-to-keep: 2 + delete-only-untagged-versions: "true" diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index 97c1f9f60..7206828da 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -2,6 +2,8 @@ name: databases on: push: + branches: + - 0.4 pull_request: env: @@ -27,12 +29,14 @@ jobs: --health-retries 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: MySQL 8.0 + name: MySQL 8.0 (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -43,15 +47,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o @@ -84,12 +85,14 @@ jobs: --health-retries 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: MySQL 9.0 + name: MySQL 9.0 (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -100,15 +103,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o @@ -141,12 +141,14 @@ jobs: --health-retries 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: MariaDB 10 + name: MariaDB 10 (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -157,15 +159,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o @@ -198,12 +197,14 @@ jobs: --health-retries 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: MariaDB 11 + name: MariaDB 11 (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -214,15 +215,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o @@ -255,12 +253,14 @@ jobs: --health-retries 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: PostgreSQL 17 + name: PostgreSQL 17 (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -271,15 +271,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o @@ -312,12 +309,14 @@ jobs: --health-retries 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: PostgreSQL 18 + name: PostgreSQL 18 (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -328,15 +327,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o @@ -355,12 +351,14 @@ jobs: timeout-minutes: 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: SQLite + name: SQLite (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -371,15 +369,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o diff --git a/.github/workflows/doctum.yml b/.github/workflows/doctum.yml index d8c533cb1..c23e06104 100644 --- a/.github/workflows/doctum.yml +++ b/.github/workflows/doctum.yml @@ -10,17 +10,12 @@ jobs: runs-on: ubuntu-latest container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2 permissions: contents: read steps: - - name: Install Git - run: | - apt-get update -qq - apt-get install -y -qq git > /dev/null - - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -32,9 +27,6 @@ jobs: - name: Verify checkout run: git rev-parse --is-inside-work-tree - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: @@ -58,7 +50,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 - name: Install pnpm run: | diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml index c23662c60..2d05bb2cf 100644 --- a/.github/workflows/engine.yml +++ b/.github/workflows/engine.yml @@ -2,6 +2,8 @@ name: engine on: push: + branches: + - 0.4 pull_request: env: @@ -13,12 +15,14 @@ jobs: timeout-minutes: 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: Engine Integration Tests + name: Engine Integration Tests (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -29,15 +33,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o diff --git a/.github/workflows/grpc.yml b/.github/workflows/grpc.yml index 2365d3397..e73227742 100644 --- a/.github/workflows/grpc.yml +++ b/.github/workflows/grpc.yml @@ -2,6 +2,8 @@ name: grpc on: push: + branches: + - 0.4 pull_request: env: @@ -13,12 +15,14 @@ jobs: timeout-minutes: 10 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: gRPC Integration Tests + name: gRPC Integration Tests (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -29,9 +33,6 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Setup Go uses: actions/setup-go@v6 with: @@ -42,8 +43,8 @@ jobs: uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index a42d937b6..3a988c288 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -2,6 +2,8 @@ name: redis on: push: + branches: + - 0.4 pull_request: env: @@ -23,14 +25,16 @@ jobs: --health-timeout 5s --health-retries 5 - # Swoole 6.2+ required for phpredis 6.3.0+ (HSETEX support for "any" tag mode) + # Any-mode coverage requires phpredis 6.3.0+ for HSETEX support. container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: Redis 8 + name: Redis 8 (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -41,22 +45,16 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o - - name: Install procps for Horizon's SystemProcessCounter (ps aux) - run: apt-get update -qq && apt-get install -y -qq --no-install-recommends procps > /dev/null - - name: Execute Redis integration tests env: REDIS_HOST: redis @@ -89,14 +87,16 @@ jobs: --health-timeout 5s --health-retries 5 - # Swoole 6.2+ required for phpredis 6.3.0+ (HSETEX support for "any" tag mode) + # Any-mode coverage requires phpredis 6.3.0+ for HSETEX support. container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: Valkey 9 + name: Valkey 9 (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -107,22 +107,16 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o - - name: Install procps for Horizon's SystemProcessCounter (ps aux) - run: apt-get update -qq && apt-get install -y -qq --no-install-recommends procps > /dev/null - - name: Execute Redis integration tests env: REDIS_HOST: valkey diff --git a/.github/workflows/reverb.yml b/.github/workflows/reverb.yml index 9f83c270e..a50c39da1 100644 --- a/.github/workflows/reverb.yml +++ b/.github/workflows/reverb.yml @@ -2,6 +2,8 @@ name: reverb on: push: + branches: + - 0.4 pull_request: env: @@ -24,12 +26,14 @@ jobs: --health-retries 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: Reverb Integration Tests + name: Reverb Integration Tests (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -40,15 +44,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o diff --git a/.github/workflows/scout.yml b/.github/workflows/scout.yml index 033d78b1e..9b90620f6 100644 --- a/.github/workflows/scout.yml +++ b/.github/workflows/scout.yml @@ -2,6 +2,8 @@ name: scout on: push: + branches: + - 0.4 pull_request: env: @@ -27,12 +29,14 @@ jobs: --health-retries 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: Meilisearch + name: Meilisearch (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -43,15 +47,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o @@ -78,12 +79,14 @@ jobs: - 8108:8108 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: Typesense + name: Typesense (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -94,15 +97,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o @@ -121,12 +121,14 @@ jobs: timeout-minutes: 5 container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 strategy: - fail-fast: true + fail-fast: false + matrix: + php: ["8.4", "8.5"] - name: Algolia + name: Algolia (PHP ${{ matrix.php }}) steps: - name: Checkout code @@ -137,15 +139,12 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: path: /root/.composer/cache - key: composer-8.4-${{ hashFiles('composer.lock') }} - restore-keys: composer-8.4- + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index ca0949a56..d88a073fe 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -2,6 +2,8 @@ name: static analysis on: push: + branches: + - 0.4 pull_request: jobs: @@ -9,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: - fail-fast: true + fail-fast: false matrix: include: - name: Source Code @@ -20,7 +22,7 @@ jobs: name: ${{ matrix.name }} container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2 steps: - name: Checkout code @@ -31,9 +33,6 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: diff --git a/.github/workflows/sync-passkeys-aaguids.yml b/.github/workflows/sync-passkeys-aaguids.yml index 241942af5..8442b3671 100644 --- a/.github/workflows/sync-passkeys-aaguids.yml +++ b/.github/workflows/sync-passkeys-aaguids.yml @@ -14,14 +14,9 @@ jobs: runs-on: ubuntu-latest container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2 steps: - - name: Install Git - run: | - apt-get update -qq - apt-get install -y -qq git > /dev/null - - name: Checkout default branch uses: actions/checkout@v6 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9c0fc40ff..1b98162f8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,6 +2,8 @@ name: tests on: push: + branches: + - 0.4 pull_request: env: @@ -13,16 +15,14 @@ jobs: if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]')" strategy: - fail-fast: true + fail-fast: false matrix: - include: - - php: "8.4" - swoole: "6.2.0" + php: ["8.4", "8.5"] - name: PHP ${{ matrix.php }} (swoole-${{ matrix.swoole }}) + name: PHP ${{ matrix.php }} (Swoole 6.2.2) container: - image: phpswoole/swoole:${{ matrix.swoole }}-php${{ matrix.php }} + image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 steps: - name: Checkout code @@ -33,9 +33,6 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: @@ -71,7 +68,7 @@ jobs: name: Wayfinder container: - image: phpswoole/swoole:6.2.0-php8.4 + image: ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2 steps: - name: Checkout code @@ -82,9 +79,6 @@ jobs: - name: Log trigger context uses: ./.github/actions/log-trigger-context - - name: Setup PHP extensions - uses: ./.github/actions/setup-php - - name: Cache Composer dependencies uses: actions/cache@v5 with: @@ -98,7 +92,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 - name: Install pnpm run: | diff --git a/composer.json b/composer.json index 556fc5a04..dca986729 100644 --- a/composer.json +++ b/composer.json @@ -52,6 +52,7 @@ "Hypervel\\Hashing\\": "src/hashing/src/", "Hypervel\\Horizon\\": "src/horizon/src/", "Hypervel\\Http\\": "src/http/src/", + "Hypervel\\Image\\": "src/image/src/", "Hypervel\\Inertia\\": "src/inertia/src/", "Hypervel\\JsonSchema\\": "src/json-schema/src/", "Hypervel\\Jwt\\": "src/jwt/src/", @@ -147,7 +148,7 @@ "ext-redis": "^6.1", "ext-session": "*", "ext-sockets": "*", - "ext-swoole": "^6.2", + "ext-swoole": "^6.2.2", "ext-tokenizer": "*", "ext-zlib": "*", "composer-runtime-api": "^2.2", @@ -247,6 +248,7 @@ "hypervel/hashing": "self.version", "hypervel/horizon": "self.version", "hypervel/http": "self.version", + "hypervel/image": "self.version", "hypervel/inertia": "self.version", "hypervel/json-schema": "self.version", "hypervel/jwt": "self.version", @@ -296,6 +298,7 @@ "composer/semver": "^3.4", "fakerphp/faker": "^1.24", "friendsofphp/php-cs-fixer": "^3.57.2", + "intervention/image": "^4.2", "league/flysystem-google-cloud-storage": "^3.0", "meilisearch/meilisearch-php": "^1.16", "mockery/mockery": "1.6.x-dev", @@ -314,6 +317,9 @@ "symfony/yaml": "^8.1", "typesense/typesense-php": "^5.2" }, + "conflict": { + "intervention/image": "<4.0 || >=5.0" + }, "config": { "sort-packages": true, "allow-plugins": { @@ -339,6 +345,7 @@ "Hypervel\\Hashing\\HashingServiceProvider", "Hypervel\\Horizon\\HorizonServiceProvider", "Hypervel\\Http\\HttpServiceProvider", + "Hypervel\\Image\\ImageServiceProvider", "Hypervel\\Inertia\\InertiaServiceProvider", "Hypervel\\Jwt\\JwtServiceProvider", "Hypervel\\Log\\Context\\ContextServiceProvider", diff --git a/docs/notes/image-package.md b/docs/notes/image-package.md deleted file mode 100644 index a506fafd9..000000000 --- a/docs/notes/image-package.md +++ /dev/null @@ -1,46 +0,0 @@ -# Image Package - -## Status - -This is a preliminary handoff note, not a completed package audit or implementation plan. The Image package should be handled as a dedicated future work unit and is not part of the Filesystem audit. - -## Upstream History - -Laravel added first-party image processing in framework PR [#59276](https://github.com/laravel/framework/pull/59276) (`3dd4830499`). Follow-up framework PRs [#60713](https://github.com/laravel/framework/pull/60713) (`01abe68fa1`), [#60748](https://github.com/laravel/framework/pull/60748) (`950830b2a9`), and [#60824](https://github.com/laravel/framework/pull/60824) (`bafb8889e2`) extended and corrected the package. Laravel documentation PR #11264 added its documentation. - -Use those pull requests to discover the complete historical change surface, but use Laravel's current default branch as the source for a future port. - -## Known Surface - -The feature is a coherent package rather than a Filesystem-only method. Its Laravel surface includes: - -- the Image package, manager, image value/pipeline, output options, drivers, transformations, provider, metadata, and tests; -- Image driver and transformation contracts; -- image configuration and the Image facade; -- Filesystem and Storage integration through `FilesystemAdapter::image()`; -- HTTP Request integration through `Request::image()`; -- application provider and container-alias wiring; -- package dependencies, documentation, and facade metadata. - -Do not port only `FilesystemAdapter::image()`. That would expose an orphan method whose return type and behavior depend on the missing package. - -## Known Hypervel Lifecycle Concern - -Laravel registers its mutable `ImageManager` as scoped. A direct port needs special review under Hypervel's container semantics: boot-time calls that resolve and configure a scoped manager can mutate the non-coroutine fallback instance, while request coroutines receive different scoped instances without those registrations. Rebuilding managers and image drivers for every request may also add unnecessary cost. - -A future audit must settle the simplest correct ownership model for: - -- manager driver caching; -- custom driver registration through `extend()`; -- custom transformation registration through `transformUsing()`; -- boot-time configuration visibility in request coroutines; -- driver and underlying Intervention Image manager concurrency safety; -- reset behavior for mutable worker-lifetime state and tests. - -No manager lifetime or adaptation has been approved yet. Do not assume that Laravel's scoped provider or a worker-singleton replacement is correct without tracing these boundaries. - -## Future Work - -Treat Image as a dedicated Laravel-package port. Follow the incremental upstream workflow, inspect every file changed by the originating and follow-up pull requests, then port from the current Laravel default branch. Audit the complete package and all Filesystem, HTTP, Foundation, Support, configuration, contract, facade, test, and documentation integrations together before implementation. - -The dedicated review should reject both an orphan Filesystem method and speculative lifecycle machinery. Any Hypervel adaptation should be limited to demonstrated coroutine-safety, worker-lifetime, performance, typing, or framework-integration requirements. diff --git a/src/docs/octane.md b/docs/octane.md similarity index 100% rename from src/docs/octane.md rename to docs/octane.md diff --git a/docs/plans/2026-08-12-1342-image-package-port.md b/docs/plans/2026-08-12-1342-image-package-port.md new file mode 100644 index 000000000..ca1ba93d7 --- /dev/null +++ b/docs/plans/2026-08-12-1342-image-package-port.md @@ -0,0 +1,602 @@ +# Image Package Port + +## Outcome + +Port Laravel 13.x's Image component as the first-party `hypervel/image` package, including its contracts, facade, request/filesystem/validation integrations, configuration, documentation, GD and Imagick drivers, tests, split metadata, and framework package integration. + +Keep Laravel's public API and ordering except for Hypervel's approved type, container, provider, coroutine, worker-lifetime, correctness, and performance adaptations. The finished API must also support a completely independent driver such as vips; only the bundled GD and Imagick drivers use Intervention Image. + +This is a new Laravel package port. Implement it serially, one file at a time. Copy every upstream file with `cp`, read the complete copy, then edit it. Read the relevant source and docs again before each checklist item. Run every changed test file immediately before moving to the next one. + +## Status + +The implementation and local targeted verification are complete. Before the consumer workflows run, the canonical `hypervel/components` repository must publish both public CI image tags; the expanded PHP 8.4/8.5 workflows must then pass. + +## References and verified facts + +Primary references: + +- `examples/laravel/framework/src/Illuminate/Image` +- `examples/laravel/framework/src/Illuminate/Contracts/Image` +- `examples/laravel/framework/src/Illuminate/Support/Facades/Image.php` +- `examples/laravel/framework/config/images.php` +- `examples/laravel/framework/tests/Image` +- `examples/laravel/framework/tests/Integration/Image` +- `examples/laravel/docs/images.md` +- `examples/laravel/framework/src/Illuminate/Validation/Concerns/ValidatesAttributes.php` and `examples/laravel/docs/validation.md` for the AVIF/HEIC/HEIF validation surface added by Image follow-up commit `ae89eb5025`. +- Laravel's originating framework PR: `laravel/framework#59276`; use current 13.x source, not its historical diff. +- Intervention Image's official `4.2.1` tag. `ImageManager` retains one driver, `AbstractDriver` retains only `Config`, GD/Imagick drivers add no fields, and every decode creates a new image/core graph. Cached bundled drivers therefore retain no per-image native resources. + +Hypervel references: + +- `src/cache/{composer.json,LICENSE.md,README.md}` for a first-party Laravel package skeleton. +- `src/support/src/Manager.php` for worker-cached drivers and boot-only `extend()`. +- `src/coroutine/src/Locker.php` for single-flight lazy resolution. +- `src/foundation/src/Application.php` for canonical service aliases. +- `src/scout/src/ScoutServiceProvider.php` for optional config merging/publishing and discovery. +- `src/testing/src/PHPUnit/AfterEachTestSubscriber.php` for optional first-party static cleanup. +- `src/contracts/src/Filesystem/Factory.php` and `src/filesystem/src/FilesystemManager.php` for `UnitEnum|string|null` disks. +- `src/filesystem/src/ScopedFilesystemProxy.php` and `src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php` for tenant-prefix capture and lease-safe lazy filesystem reads. +- `src/contracts/src/Support/Responsable.php` for the native Hypervel response signature. + +Verified upstream defects to fix in the port: + +| Defect | Final correction | +|---|---| +| A lazy closure runs on every untransformed `toBytes()` call; streams are consumed once; variants independently re-read one source. | Share one internal, single-flight `ImageSource` across an image family. | +| Materialization replaces the source and clears the pipeline, so inspection can make later transformations decode/re-encode an intermediate result despite the documented “encoded once at the end” contract. | Retain the original source and full recipe; cache final bytes per image instance. | +| `dominantColor()` runs a copied pipeline and discards the result, so `toBytes()` repeats it. | Inspect `toBytes()` and retain its processed-byte cache. | +| The Intervention requirement check runs after manager construction, so a missing package throws a raw class-not-found `Error`. | Check before `createManager()` and remove `ImageManager`'s hidden optional-method hook. | +| Intervention v3 is installable even though the adapter calls v4-only APIs. | Suggest Intervention, conflict with `<4.0 \|\| >=5.0`, and test against current 4.x. | +| HEIC fallback and processing catch `Throwable`, masking custom-driver `TypeError`/PHP `Error` as image input failures. | Catch decoder/processing `Exception`; let programming errors surface. `ImageSource` remains `Throwable`-wide only to publish the identical object, never to translate it. | +| Stream/base64 lazy resolvers use `?: throw`, so they accidentally reject the non-empty byte string `'0'` along with the deliberately invalid false/empty results. | Reject only `false` and `''`; preserve empty-input errors while allowing every non-empty byte string to reach normal image validation. | +| A hash already materialized on a parent is copied to a variant. | Reset all output/derived caches in `Image::__clone()`. | +| The hash-cache test never calls the clone; PNG integration coverage calls `toWebp()`; invalid-stream cleanup is not exception-safe. | Correct the tests while porting them. | +| Docs claim `(string) $image` returns bytes, but `toString()` returns a data URI; custom-driver example omits two required methods. | Correct and complete the Hypervel docs. | +| The Image HEIC/AVIF follow-up expanded Laravel's `image` validation rule to AVIF, HEIC, and HEIF without adding a focused validation regression, while current Laravel and Hypervel validation docs still list only the older formats. | Port the validator change, add explicit coverage for all three formats, and correct both Hypervel validation-documentation locations. | +| CI's GD build lacks WebP/AVIF and installs no Imagick, leaving the headline paths failing or permanently skipped. PECL also rewrites Imagick's shipped arginfo headers before their stub sources, so `make install` needlessly regenerates them and depends on a second network download of PHP-Parser that can fail after the extension has compiled. Installing the same toolchain and extensions in every matrix job also duplicates slow work and makes one transient release-host failure fan out across the suite. | Build focused PHP 8.4 and 8.5 CI images once a week from the Swoole 6.2.2 base. Build GD with both codecs and use checksum-verified PIE 1.4.9 for the latest compatible stable `phpredis/phpredis` and `imagick/imagick` releases. Validate every required extension and codec before publishing, then run all PHP jobs from those images. | +| The global container slot accepts any `ContainerContract` but `getInstance(): static` cannot return one; the inherited slot also cannot guarantee the called subclass. Laravel's own global-container consumers call concrete-only `makeWith()`, so unrelated contract implementations are not functional framework containers. | Type the shared slot, getter, and setter as `self`, retaining `Container`, `Application`, subclasses, and concrete mocks while removing the PHPStan assignment suppression. | + +Do not add finfo, encoder, transformation-handler, or format metadata caches. A 20,000-call real-JPEG measurement found fresh `finfo` construction adds about 0.5 microseconds / 2.6% to MIME detection, which is noise beside image decode/encode. Do not optimize the linear custom-handler scan either; it is bounded by the short transformation pipeline and negligible beside processing. + +### Complete framework-facing surface + +| Surface | Port decision | +|---|---| +| `Request::image(string $key): ?Image` | Port the uploaded-file conversion and preserve the original `UploadedFile` on the image. | +| `FilesystemAdapter::image(string $path): Image` / `Storage::disk(...)->image(...)` | Port the lazy filesystem source and adapt it across Hypervel's scoped and pooled wrappers. | +| Returning an `Image` from a route/controller | Preserve `Image implements Responsable` and Hypervel's native typed `toResponse(Request): Response`; the router already handles it. Laravel has no `Response::image()` API, so do not invent one. | +| Validation `image` rule and `File::image()` | Accept AVIF, HEIC, and HEIF in addition to the existing formats and update both documentation surfaces. Otherwise the Image package can encode formats that the framework's own `image` rule rejects. The validation path uses guessed extensions and remains independent of `hypervel/image`, Intervention, codecs, and fixtures; `File::image()` already delegates to the same rule. | +| `UploadedFile::fake()->image()` / `FileFactory::image()` | Existing test-file generation is unrelated to the new component; retain its current API and `ext-gd` suggestion. Do not add real AVIF/HEIC generation: that would make a currently portable core test helper depend on optional codec capabilities and fail where its JPEG fallback succeeds. | + +The preliminary `docs/notes/image-package.md` handoff and its `docs/todo.md` Image entry are fully superseded by this audited plan and have been removed. Do not leave a second stale specification or completed TODO behind. + +Redis cache tag requirements remain mode-specific. The default `all` mode uses Laravel-style tag namespaces and the general PhpRedis 6.1 requirement. The `any` mode uses hash-field expiration and requires Redis 8.0 or later, Valkey 9.0 or later, and PhpRedis 6.3.0 or later. Keep the root components and `hypervel/redis` baseline at `^6.1`; state the conditional `^6.3` requirement in the cache package and framework aggregate suggestions, document it on the public tag-mode setter, and run CI with PhpRedis 6.3 or later so both modes receive coverage. The Redis doctor must check PhpRedis 6.1 for `all` mode and 6.3 for `any` mode, matching its existing mode-specific server and command checks. Its remediation and the live cache/session docs use PIE's documented `pie install phpredis/phpredis` command for both installation and upgrades; archived release docs remain unchanged. Any-mode batch writes must use one multi-field `HSETEX` per tag instead of `HSET` followed by `HEXPIRE`; all fields in the batch share one positive TTL, so the native command preserves atomic field creation/expiration while reducing commands. Do not replace the native hash-field commands with compatibility machinery or describe the Any-mode requirement as a general Redis connector requirement. + +## Final lifecycle and API design + +### State ownership + +| State | Owner / lifetime | +|---|---| +| Image manager, custom creators, transformation handlers, resolved drivers, Intervention managers | Worker-lifetime singleton. Register only during worker boot. | +| Original source bytes | One `ImageSource` shared by clones for the image-family lifetime. The lazy resolver and captured resources are released after its terminal result. | +| Pipeline, driver override, processed bytes, MIME/dimension/color/hash caches | Per `Image` object. Every public manipulation clones and invalidates the derived caches. | +| Decoded GD/Imagick/vips handles | Local to one driver call and released before it returns. Never retain on a cached driver. | +| Macros | Worker-lifetime static state, cleared centrally between tests. | + +Bind the canonical service as a worker singleton, not Laravel's scoped binding: + +```php +$this->app->singleton( + 'image', + fn (Container $container): ImageManager => new ImageManager($container), +); +``` + +Add `'image' => [ImageManager::class]` to `Application::registerCoreContainerAliases()` so string, facade, and concrete resolution share the one manager. Do not add `ImageServiceProvider` to `DefaultProviders`; it is an optional discovered package and needs no early bootstrap. Omit `DeferrableProvider` and `provides()` because Hypervel does not defer provider work per request. + +`Manager::extend()`, `ImageManager::transformUsing()`, and driver `transformUsing()` mutate worker-lifetime registries. The latter two need this warning beneath their title: + +```php +Boot-only. The handler persists on a cached driver for the worker lifetime +and affects every subsequent image processed by that driver. +``` + +Custom drivers may use ext-vips, a PHP vips library, a CLI, or a remote service without Intervention. They must implement all four `Driver` methods and remain stateless/coroutine-safe: retain only immutable configuration or a concurrency-safe client, never image contents, pipelines, native image handles, or request state. They must treat the supplied `ImagePipeline` as read-only; mutating or retaining it would break image immutability and race across callers. + +Custom `Transformation` objects must be immutable. Pipeline clones share transformation instances; deep-cloning arbitrary user objects would be defensive machinery with unclear semantics. State this invariant on the contract and in the docs; keep the built-in transformations `readonly`. + +### Multi-tenancy boundary + +Do not add an image-level tenant resolver, key prefix, or row-partitioning API. The package owns no database rows, shared cache keys, tenant-addressed catalog, or persistent namespace: source bytes, recipes, rendered bytes, and derived metadata belong to one `Image` family, while the only shared manager/driver state is tenant-neutral boot configuration. + +Tenant isolation for reads and writes belongs at the filesystem/path boundary. `Image::store*()` already delegates to the selected disk, so configured scoped disks and Hypervel's dynamic `ScopedFilesystemProxy` remain authoritative. Complete the new filesystem convenience method across every Hypervel wrapper rather than bypassing that boundary: + +- `FilesystemAdapter::image()` creates the normal lazy image source and reports a missing path clearly. +- `ScopedFilesystemProxy::image()` resolves and captures its validated prefix and inner disk once when the image is created, then lazily reads only that scoped path. This prevents an image created for one tenant from switching prefixes or disks if materialized after coroutine context changes; `ScopedCloudFilesystemProxy` inherits the mapping. Empty prefixes fail immediately at `image()` creation unless the caller deliberately constructed the proxy with `allowRootPassthrough: true`; that explicit opt-out preserves existing behavior and permits an unscoped read. +- `InteractsWithPooledFilesystem::image()` creates a lazy source around the pool proxy's own `get()`. It must not borrow an adapter merely to return an `Image`, because the borrow would be released before lazy materialization. Both client-pooled and whole-driver pooled filesystems receive the method through the trait. + +The two wrapper shapes are deliberately different: + +```php +// ScopedFilesystemProxy: capture the validated tenant boundary now; read later. +$prefix = $this->prefix(); + +// Capture the disk with the prefix so tenant-varying resolvers cannot switch +// tenants between image creation and lazy materialization. +$disk = $this->resolveDisk(); +$scopedPath = $this->applyPrefix($prefix, $path); + +return new Image( + fn () => $disk->get($scopedPath) + ?? throw new ImageException("Unable to read image from path [{$path}]."), +); + +// InteractsWithPooledFilesystem: borrow only when the lazy read occurs. +return new Image( + fn () => $this->get($path) + ?? throw new ImageException("Unable to read image from path [{$path}]."), +); +``` + +The scoped method deliberately does not use the otherwise-standard `call()` forwarding helper: late forwarding would re-resolve a tenant-varying disk during materialization, while eager forwarding would defeat lazy reads. Nothing from `call()`'s safety boundary is lost because `get()` is declared on the `Filesystem` contract, so its method-existence guard and unsupported-method remapping cannot apply. The scoped exception must report only the caller-supplied `$path`, never `$scopedPath`; tenant prefixes are privileged boundary state. Adapter, scoped, and pooled variants use the identical `Unable to read image from path [...]` message. + +Custom creators, drivers, and transformation handlers are worker-global. They must not read tenant context while a cached driver is being constructed or capture tenant-specific credentials/configuration in boot callbacks. A backend that varies by tenant may retain a resolver or concurrency-safe credential/client provider and resolve it inside each `process()`, `dimensions()`, or `dominantColor()` call; it must never retain the resolved tenant value after that call. Per-image tenant choices may also travel in an immutable custom transformation. No additional `...Using` method is needed because `extend()` and `transformUsing()` already provide the backend extension points, while filesystem scoping owns persistent name isolation. + +### Lazy source and retained recipe + +`ImageSource` is an `@internal` implementation class in `Hypervel\Image`. It is absent from contracts, facade metadata, examples, and user docs. It accepts an eager string or `Closure`, then publishes exactly one string or original exception to every clone/coroutine: + +```php +/** + * Share one lazy source across every image derived from it. + * + * @internal + */ +class ImageSource +{ + protected const string LOCK_KEY_PREFIX = '__image.source.'; + + protected ?string $contents = null; + protected ?Closure $resolver = null; + protected ?Throwable $exception = null; + + /** + * Create a new image source instance. + */ + public function __construct(Closure|string $contents) + { + if (is_string($contents)) { + $this->contents = $contents; + } else { + $this->resolver = $contents; + } + } + + /** + * Resolve the image source contents. + */ + public function contents(): string + { + if ($this->contents !== null) { + return $this->contents; + } + + if ($this->exception !== null) { + throw $this->exception; + } + + // This object stays alive while its lock is held, so PHP cannot reuse its object ID for another source. + $key = self::LOCK_KEY_PREFIX.spl_object_id($this); + + if (Locker::lock($key)) { + try { + /** @var Closure $resolver */ + $resolver = $this->resolver; + $contents = $resolver(); + + if (! is_string($contents)) { + throw new ImageException(sprintf( + 'Image source resolver must return a string, %s returned.', + get_debug_type($contents), + )); + } + + $this->contents = $contents; + } catch (Throwable $exception) { + $this->exception = $exception; + } finally { + $this->resolver = null; + Locker::unlock($key); + } + } + + return $this->contents ?? throw ($this->exception + ?? new ImageException('Image source resolution was interrupted.')); + } +} +``` + +The source holder catches `Throwable` only to publish and rethrow the identical terminal object to every waiter; unlike processing/fallback handling, this is not error translation and therefore must also publish `Error` and `TypeError`. `Locker::unlock()` runs in `finally`, removes its static key, and wakes all waiters, so source families do not grow worker state. The final interruption exception is the impossible-state fallback and type narrowing for a lock released without publication, which is reachable only through abnormal forced lock cleanup such as test-time `Locker::flushState()` racing a waiter. A missing storage object gets a path-specific `ImageException` at the adapter/manager boundary; the generic non-string check covers public constructor closures and future sources. + +`Image` retains the recipe and caches its rendered result: + +```php +protected ImageSource $source; +protected ImagePipeline $pipeline; +protected ?string $processedContents = null; +protected ?string $mimeType = null; +/** @var array{0: int, 1: int}|null */ +protected ?array $dimensions = null; +protected ?string $dominantColor = null; +protected ?string $hashName = null; + +public function toBytes(): string +{ + if (! $this->pipeline->hasChanges()) { + return $this->source->contents(); + } + + return $this->processedContents ??= $this->process(); +} + +/** + * Process the image recipe. + */ +protected function process(): string +{ + try { + return $this->resolveDriver()->process($this->source->contents(), $this->pipeline); + } catch (ImageException $exception) { + throw $exception; + } catch (Exception $exception) { + throw new ImageException("Failed to process image: {$exception->getMessage()}", 0, $exception); + } +} +``` + +Do not clear the pipeline after processing and do not keep Laravel's redundant `processed` flag. Materializing the same recipe twice calls the driver once. Appending a transform after materialization clones the original source plus complete pipeline and renders once at the new endpoint. Switching drivers after materialization correctly re-renders the recipe rather than returning bytes from the old driver. + +The retained source raises bounded per-request peak memory, because a materialized variant owns its output while the family retains one shared original. That is required for correct immutable/one-final-encode behavior. The docs must prominently direct large workloads to queued jobs. + +Do not add a second lock around one `Image` object's processing. Concurrently materializing the exact same object may duplicate deterministic CPU work but does not corrupt bytes; callers should derive separate variants before concurrent work. The source fetch remains single-flight because duplicate network/disk reads and non-repeatable streams are correctness failures. + +Use instance caches rather than coroutine-scoped `once()` for MIME type, dimensions, and dominant color. Public methods are immutable and `__clone()` resets every derived value: + +```php +/** + * Clone the pipeline and reset all derived state. + */ +public function __clone(): void +{ + $this->pipeline = clone $this->pipeline; + $this->processedContents = null; + $this->mimeType = null; + $this->dimensions = null; + $this->dominantColor = null; + $this->hashName = null; +} +``` + +Delete `newClone()`. Use `clone $this` directly in `using()` and `withClone()` so PHP's clone hook is the single owner of pipeline cloning and cache invalidation. + +`dominantColor()` delegates to `toBytes()`. HEIC dimensions catch `Exception` for decoder failure and fall back to `getimagesizefromstring`; `TypeError` and `Error` escape. `toBytes()` likewise wraps ordinary driver exceptions only. Add `Image::flushState()` delegating to `flushMacros()` at the class cleanup position required by `AGENTS.md`. + +`Image` ends with upstream's serialization/string-representation methods, but those are not magic dispatch/lifecycle placement anchors named by `AGENTS.md`. Place `flushState()` at the actual end of the class, after `__toString()`, with only the standard `Flush all static state.` title docblock. + +### Dependency contract + +`hypervel/image` directly requires PHP 8.4, `ext-fileinfo`, and the Hypervel conditionable, container, contracts, coroutine, filesystem, foundation, HTTP, macroable, and support packages. Foundation owns the `config_path()` helper used by the package provider. The package does not require Intervention, GD, or Imagick because a custom driver needs none of them. + +Package metadata: + +```json +"suggest": { + "ext-gd": "Required to use the GD image driver.", + "ext-imagick": "Required to use the Imagick image driver.", + "intervention/image": "Required to use the GD and Imagick image drivers (^4.0)." +}, +"conflict": { + "intervention/image": "<4.0 || >=5.0" +} +``` + +Before implementation, check Packagist again. Add the current compatible test dependency with: + +```shell +composer require --dev 'intervention/image:^4.2' --no-interaction +``` + +The root components manifest also needs the conflict because it replaces the split package. Its untracked lock is local only; run `composer update --no-interaction` after all manifest edits and never commit the lock. + +## Implemented design + +### 1. Dependency and CI foundation + +- [x] `composer.json`, every split-package manifest that declares `ext-swoole`, and the installation/deployment requirement lists — require Swoole 6.2.2 as the framework minimum; use PIE's `pie install swoole/swoole` command in the installation guide. +- [x] `.github/docker/ci/Dockerfile` — build a focused reusable test image from `phpswoole/swoole:6.2.2-php${PHP_VERSION}` for PHP 8.4 and 8.5. Install the shared native libraries plus `git` and `procps`; build missing bundled extensions, always configure GD with FreeType, JPEG, WebP, and AVIF; disable Swoole short names; download PIE 1.4.9 with bounded retries and verify its independently confirmed SHA-256 digest; install unversioned `phpredis/phpredis` and `imagick/imagick` one package per invocation so PIE selects the latest compatible stable releases and owns extension enabling. Keep Composer from the base image and do not add Node or pnpm. Link the OCI source directly to the canonical `hypervel/components` repository. End the build with executable checks for the expected PHP minor, Swoole 6.2.2, every required extension, phpredis 6.3.0 or newer for full any-tag-mode integration coverage, `imagewebp()`, `imageavif()`, and Composer. An invalid image must fail before publication. +- [x] `.github/workflows/ci-images.yml` — on a weekly non-peak-hour schedule, manual dispatch, and relevant `0.4` changes, build with `--pull` and publish the two moving tags `ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2` and `php8.5-swoole6.2.2`. Guard the build by the canonical `hypervel/components` repository identity so forks and temporary mirrors remain pull-only consumers even when their schedule or manual trigger runs. Run the build matrix sequentially so only one PIE download/build reaches external services at a time. Grant only `contents: read` and `packages: write`, serialize overlapping workflow runs, and delete old untagged container versions after both builds while retaining the newest two. Tagged current images remain intact when a build fails. +- [x] `.github/actions/setup-php/action.yml` and consumers — delete the obsolete per-job installer after every consumer uses the public canonical CI image. Runtime suites (`tests`, databases, Redis/Valkey, engine, gRPC, Reverb, and Scout) use a direct `php: ["8.4", "8.5"]` matrix with `fail-fast: false`, PHP-labelled check names, matching image tags, and PHP-specific Composer cache keys. Static analysis, Doctum, Wayfinder, and Passkeys synchronization run once on the PHP 8.4 image. Keep the static-analysis `include` matrix because it maps names to configs. Move Doctum and Wayfinder from Node 22 to Node 24, with pnpm still supplied by the repository's pinned Corepack version rather than baked into the PHP image. Do not add container credentials or package-read permissions: the image is deliberately public, and existing workflow permissions—including Passkeys' content and pull-request write access—remain authoritative. Test workflows run on pull requests and on pushes to `0.4`, avoiding duplicate push and pull-request suites for feature branches. +- [ ] Canonical image activation — publish both tags from `hypervel/components` before switching consumers, link the organization-owned package to that repository, and make it public once in GitHub's package settings. GitHub exposes no workflow or REST setting for changing container-package visibility. Public access lets other repositories and local developers pull the exact CI environment without credentials. If the temporary package's OCI source label prevents unlinking it in the package settings, delete that temporary package and let the canonical guarded builder recreate it. +- [x] `src/cache/composer.json`, `src/cache/src/RedisStore.php`, Redis doctor, Any-mode batch writes, cache/session docs, and the framework aggregate manifest — keep the general PhpRedis floor at `^6.1`, state that the optional `any` tagging mode requires `^6.3`, place the Redis 8.0 / Valkey 9.0 / PhpRedis 6.3 requirements on the public mode-selection API, make the doctor select its PhpRedis minimum from the configured mode and recommend PIE for installation or upgrades, and batch each tag's fields and TTL into one `HSETEX`. The existing `hypervel/redis` suggestion remains the authoritative general connector requirement. + +- [x] `composer.json` — use Composer to add Intervention 4.2 dev dependency, then add `"Hypervel\\Image\\": "src/image/src/"` autoload, `hypervel/image` replace entry, `ImageServiceProvider` discovery, and the Intervention conflict in existing alphabetical sections. Do not add Image to default providers. +- [x] `src/jwt/composer.json` — declare the existing provider's direct `hypervel/foundation` dependency because it also calls Foundation's `config_path()` helper; do not rely on `hypervel/support` to supply that package transitively. + +After the package skeleton exists, run: + +```shell +composer update --no-interaction +./vendor/bin/phpunit --no-progress tests/Composer/PackageManifestConsistencyTest.php +``` + +### 2. Package skeleton + +- [x] `src/image/composer.json` — copy `src/cache/composer.json`, then edit it to the dependency/discovery metadata specified above. Keep root support/authors/branch conventions and `"Hypervel\\Image\\": "src/"` autoload. +- [x] `src/image/LICENSE.md` — copy Laravel Image's license, preserve Taylor Otwell, and add Hypervel copyright as the cache package does. +- [x] `src/image/README.md` — copy the cache README as the skeleton, then reduce it to: + +```markdown +Image for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/image) + +Documentation: https://hypervel.org/docs/images + +## Differences From Laravel + +Custom image drivers and transformation handlers must be registered during worker boot. Registrations affect every subsequent request handled by that worker. + +Ported from: https://github.com/laravel/framework +``` + +- [x] `src/image/config/images.php` — copy Laravel's config, add strict types, replace Laravel prose with Hypervel, retain `IMAGE_DRIVER`, default `gd`, and the supported built-in list. + +Do not copy Laravel's package-local `.gitattributes` or read-only split-repository pull-request workflow. Hypervel's components root owns repository attributes and CI, while the split tooling owns package repository publication; no existing `src/*` package carries either file. + +### 3. Contracts, one file at a time + +- [x] `src/contracts/src/Image/Driver.php` — copy upstream; add strict types/Hypervel namespaces and fully typed methods. Keep the backend-neutral four-method surface. Document that `process()` treats its pipeline as read-only and never retains it. Add the boot-only warning to `transformUsing()` because every conforming first-party/custom driver is worker-cached by the manager. +- [x] `src/contracts/src/Image/Transformation.php` — copy upstream; add strict types/namespace and a concise contract docblock requiring immutable implementations because clones share transformation objects. + +The public driver surface remains: + +```php +interface Driver +{ + public function process(string $contents, ImagePipeline $pipeline): string; + /** @return array{0: int, 1: int} */ + public function dimensions(string $contents): array; + public function dominantColor(string $contents): string; + /** @param class-string $transformation */ + public function transformUsing(string $transformation, callable $callback): static; +} +``` + +### 4. Image package source, copied/created alphabetically + +For every copied file: `cp`, read the complete destination, then namespace/type/adapt it. Preserve upstream member order unless a specified correctness change requires replacement. + +- [x] `src/image/src/Drivers/GdDriver.php` — copy upstream; return `ImageManagerInterface` from `createManager()` and use Intervention's GD driver. +- [x] `src/image/src/Drivers/ImagickDriver.php` — same for Imagick. +- [x] `src/image/src/Drivers/InterventionDriver.php` — copy upstream; check requirements before manager creation; type the retained manager as `ImageManagerInterface`; use strict MIME membership; keep upstream's encoding-block `finally` and other decoded-image/sample cleanup; catch no programming errors; type handler lookup to `Transformation`; add the boot-only handler warning. Do not cache encoders/finfo/handler lookups or widen the encoding cleanup across the transformation loop, since local images are released naturally when the frame unwinds. +- [x] `src/image/src/Image.php` — copy upstream; keep the public constructor signature exactly `Closure|string $contents, ?UploadedFile $file = null` and build the internal `ImageSource` inside it. Replace stored closure/string contents and `processed` with the holder, retained pipeline, processed/derived instance caches, `process()`, and `__clone()` as specified. Delete `newClone()` and clone directly in `using()`/`withClone()`. Use `UnitEnum|string|null` on all four storing APIs, strict format membership, native Hypervel `Responsable`, `Exception`-only wrapping/fallback, a direct early-return dimensions cache rather than an immediately invoked closure, and an end-of-class `flushState()` for macros. `(string)` remains a data URI; `ImageSource` never appears in a public signature or another package. +- [x] `src/image/src/ImageException.php` — copy upstream; add strict types/namespace, retain `RuntimeException` inheritance. +- [x] `src/image/src/ImageManager.php` — copy upstream; use explicit false/empty checks for streams/base64; share lazy holders through `Image`; accept `UnitEnum|string|null`; keep local-path reads direct because the concrete filesystem returns a string or throws `FileNotFoundException`; turn null storage reads into a path-specific `ImageException`; reject HTTP client/server errors before reading remote response bodies and preserve the native `RequestException` with its status and response instead of wrapping it; remove `enum_value()`; keep the `createDriver()` override's image-specific `InvalidArgumentException` rewrap and `applyTransformationHandlers()`, remove only its hidden `ensureRequirementsAreMet` hook, and narrow `parent::createDriver()` with an accurate local `@var Driver` rather than a runtime branch. Add the boot-only `transformUsing()` warning and use `$this->config->string('images.default')`: the provider-merged config is the sole `gd` default. +- [x] `src/image/src/ImageOutputOptions.php` — copy upstream; type `public const int DEFAULT_QUALITY = 70`; preserve the documented format/quality shapes and `hasChanges()`. +- [x] `src/image/src/ImagePipeline.php` — copy upstream; add strict types; retain output-only cloning and `hasChanges()`. Do not deep-clone transformations. +- [x] `src/image/src/ImageServiceProvider.php` — copy upstream; remove deferred-provider machinery without a package-local divergence comment because `ServiceProvider` already records that framework-wide omission at its owning boundary; merge package config; bind canonical `image` singleton by closure; publish `images.php` under `image-config` while running in console. +- [x] `src/image/src/ImageSource.php` — create the internal source holder exactly from the lifecycle design; use the existing `Locker`, terminal result/exception publication, resolver release, non-string validation, object-ID lifetime comment, and interruption guard. +- [x] `src/image/src/Transformations/Blur.php` — copy upstream; strict types/namespace; readonly `int $amount`. +- [x] `src/image/src/Transformations/Contain.php` — readonly `int $width`, `int $height`, `?string $background`. +- [x] `src/image/src/Transformations/Cover.php` — readonly positive width/height. +- [x] `src/image/src/Transformations/Crop.php` — readonly width/height/x/y. +- [x] `src/image/src/Transformations/FlipHorizontally.php` — marker transformation. +- [x] `src/image/src/Transformations/FlipVertically.php` — marker transformation. +- [x] `src/image/src/Transformations/Grayscale.php` — marker transformation. +- [x] `src/image/src/Transformations/Orient.php` — marker transformation. +- [x] `src/image/src/Transformations/Resize.php` — readonly nullable width/height. +- [x] `src/image/src/Transformations/Rotate.php` — readonly float angle and nullable background. +- [x] `src/image/src/Transformations/Scale.php` — readonly nullable width/height. +- [x] `src/image/src/Transformations/Sharpen.php` — readonly `int $amount`. + +Representative transformation shape: + +```php +class Rotate implements Transformation +{ + public function __construct( + public readonly float $angle, + public readonly ?string $background = null, + ) { + } +} +``` + +After source is ported, grep all `src/` and `tests/` for `Illuminate\\Image`, `Illuminate\\Contracts\\Image`, and image-facing `Illuminate` facade imports; zero ported references may remain. Run targeted PHPStan for `src/image` only if needed while resolving a concrete type question; the checkpoint full analysis remains `composer fix`. + +### 5. Framework integrations, one file at a time + +- [x] `src/container/src/Container.php` — model the one inherited global slot as native `?self`; return `self` from `getInstance()` and accept/return `?self` from the tests-only `setInstance()`. Remove the impossible `null|static` property docblock and assignment suppression. Do not add a contract adapter, subclass guard, separate registry, or runtime rejection branch. +- [x] `src/filesystem/composer.json` — add a `hypervel/image` suggestion for image creation across adapters, scoped proxies, and pooled proxies. +- [x] `src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php` — add a lazy `image()` implementation that reads through the proxy's public `get()` only at materialization; never return an image closure capturing a released borrowed adapter. +- [x] `src/filesystem/src/FilesystemAdapter.php` — copy upstream method into the same relative position; return lazy `Image`; convert a null `get()` result into `ImageException` naming the path. +- [x] `src/filesystem/src/ScopedFilesystemProxy.php` — explicitly map `image()` through the fail-closed tenant boundary by capturing one validated prefix and resolved disk, then lazily reading the resulting scoped path. Add the inline WHY comment from the design above, report only the unscoped caller path on failure, and do not route through `call()`/`__call()` or expose scoped internals. Preserve the existing explicit `allowRootPassthrough` opt-out. +- [x] `src/foundation/src/Application.php` — add canonical `'image' => [ImageManager::class]` alias alphabetically. +- [x] `src/http/composer.json` — add `hypervel/image` suggestion for request upload conversion. +- [x] `src/http/src/Concerns/InteractsWithInput.php` — copy upstream `image(string $key): ?Image` after `file()`; preserve uploaded file on the image. +- [x] `src/horizon/src/Events/LongWaitDetected.php` — restore upstream's `make(..., $parameters)` call while retaining Hypervel's renamed constructor keys. `makeWith()` is only an alias, and the old divergence is no longer needed now that `make()` accepts parameters. +- [x] `src/support/src/Facades/App.php` — regenerate only this facade after correcting the inherited container methods; its generated getter/setter signatures must match the concrete shared slot. +- [x] `src/support/src/Facades/Image.php` — copy upstream facade, strip its generated method inventory to the `@see ImageManager` source, use canonical `image` accessor, then generate its docblock with the targeted facade documenter. +- [x] `src/support/src/Facades/Request.php` — regenerate only this facade after adding the request method. +- [x] `src/support/src/Facades/Storage.php` — regenerate only this facade after adding the filesystem method. +- [x] `src/testing/src/PHPUnit/AfterEachTestSubscriber.php` — call a new optional `flushImageState()` between Horizon and Inertia; use `callIfExists(Image::class, 'flushState')` and no container resolution. +- [x] `src/validation/src/Concerns/ValidatesAttributes.php` — port AVIF/HEIC/HEIF support into `validateImage()` and make the touched `allow_svg` membership check strict. This rule remains usable without the Image package or Intervention and needs no new validation-package dependency. + +Targeted facade generation must call the documenter directly, not the all-facades helper: + +```shell +php -f src/facade-documenter/facade.php -- Hypervel\\Support\\Facades\\App +php -f src/facade-documenter/facade.php -- Hypervel\\Support\\Facades\\Image +php -f src/facade-documenter/facade.php -- Hypervel\\Support\\Facades\\Request +php -f src/facade-documenter/facade.php -- Hypervel\\Support\\Facades\\Storage +``` + +At review, use the full facade docblock test to detect drift; do not run the helper script that rewrites every facade. + +### 6. Tests, ported/created serially with immediate execution + +Every test class extends `Hypervel\Tests\TestCase` or `Hypervel\Testbench\TestCase`, declares strict types, gives test/lifecycle methods `: void`, and relies on inherited coroutine execution. When a test uses Mockery, import it as `m`. Use PHPUnit extension/function attributes for unavailable codec capabilities. + +- [x] `tests/Container/ContainerTest.php` — add one focused shared-slot regression: install an `Application` through `Container::setInstance()` and assert both base and subclass getters return the identical object. Do not test PHP's native rejection of an unrelated contract. +- [x] `tests/Pool/HeartbeatConnectionTest.php` — change the helper's global mock from `ContainerContract` to concrete `Container`; `Pool` continues receiving it through the contract boundary. Run immediately. +- [x] `tests/Filesystem/ClientPooledFilesystemTest.php` — prove image creation acquires no client lease, first materialization performs one balanced borrow/read, repeated bytes reuse the source, and a missing path uses the shared caller-path error message. Run immediately. +- [x] `tests/Filesystem/FilesystemAdapterTest.php` — merge Laravel's adapter image test plus missing-path lazy failure coverage asserting the shared caller-path error message. Run this file immediately. +- [x] `tests/Filesystem/PackageMetadataTest.php` — create a focused split-manifest regression asserting the non-empty `hypervel/image` suggestion. Run immediately. +- [x] `tests/Filesystem/FilesystemPoolProxyTest.php` — prove the whole-driver proxy also defers its balanced lease/read until materialization and never leaves an adapter borrowed by the returned image. Run immediately. +- [x] `tests/Filesystem/ScopedFilesystemProxyTest.php` — prove `image()` captures one non-empty normalized tenant prefix and one resolved disk at creation, remains content-lazy, reads only the captured scoped path after context changes, and inherits through the cloud proxy. Assert an empty prefix fails at `image()` creation by default; explicit root passthrough reads the unscoped path. For a missing image, assert the shared message contains the caller path and excludes the tenant prefix. Run immediately. +- [x] `tests/Sentry/Features/StorageIntegrationTest.php` — classify `FilesystemAdapter::image()` as intentionally inherited by the Sentry decorator because its lazy closure calls the outer adapter's already-instrumented `get()` method. Do not add a redundant decorator override or a second span. Run immediately. +- [x] `tests/Http/HttpRequestTest.php` — port upstream `testImageMethod` and `testImageMethodReturnsNullForMissingKey` 1:1, preserving the uploaded-image and missing/non-file cases. Run immediately. +- [x] `tests/Http/PackageMetadataTest.php` — extend the existing package metadata owner to assert the non-empty `hypervel/image` suggestion while preserving its existing fake-image `ext-gd` assertion. Run immediately. +- [x] `tests/Image/CoroutineSafetyTest.php` — create focused public regressions: + - two cloned images racing one yielding lazy resolver call it once and receive identical bytes; + - both waiters receive the resolver's original terminal exception type/message and the resolver runs once; + - separate images interleaving through one singleton stateless custom driver retain their own contents/pipelines; + - do not lock or promise one processing call when the exact same `Image` object is concurrently materialized. + Run immediately. +- [x] `tests/Image/Drivers/GdDriverTest.php` — copy upstream, use Hypervel base/imports/types, guard WebP and AVIF tests with their GD function capabilities, and cover every transformation, format, dimensions, alpha-free dominant color, custom immutable transformation, unsupported input, quality, and raw-output path. Run immediately. +- [x] `tests/Image/Drivers/ImagickDriverTest.php` — copy upstream, keep capability checks for WebP/AVIF/HEIC delegates, cover the same driver surface and HEIC display dimensions. Run immediately; it must run in CI after the setup action change. +- [x] `tests/Image/Drivers/InterventionDriverTest.php` — create a test-local subclass whose overridden `ensureRequirementsAreMet()` throws and whose `createManager()` records if it ran; assert manager construction never runs. Also prove handler mutation is boot-scoped behavior. Use only the existing protected extension point and add no production seam solely for testing. Run immediately. +- [x] `tests/Image/ImageManagerTest.php` — copy upstream and add/correct: + - replace upstream's standalone empty-repository `gd` fallback test with a manager test that reads configured `images.default`; the provider test below owns the merged `gd` default so no duplicate manager fallback is reinstated; + - backed and unit enum disks; + - stream/base64 false, empty, and string `'0'` strict behavior; + - exception-safe stream closure; + - missing storage path message; + - lazy source resolution only once across sequential variants; + - custom backend works directly through `Driver`, with no Intervention inheritance; + - direct public-API processing installs a concrete `Container`, never an unrelated contract in the global slot; + - remote URL responses reject HTTP client/server errors before their bodies reach image decoding; + - path, storage, and URL laziness expectations apply to the application mock actually resolved by the manager; + - driver caches and transformation handler application remain worker-lifetime. + Run immediately. +- [x] `tests/Image/ImageServiceProviderTest.php` — create Testbench coverage, register `ImageServiceProvider` through `getPackageProviders()`, and verify the merged packaged config independently of ambient `IMAGE_DRIVER`, publishable config, canonical alias, and one worker-lifetime manager/driver instance across resolutions. Pin the alias test to the GD config and skip it when GD is unavailable. Add no deferred-provider note: the framework owner already records the omission, Laravel has no matching provider test, and no upstream test is skipped. Run immediately. +- [x] `tests/Image/ImageTest.php` — copy upstream and preserve its public API breadth, then correct/add: + - direct `toFormat()` for every supported spelling and HEIF normalization; + - strict format rejection and every clamp boundary; + - one resolver call for repeated raw `toBytes()`; + - a public constructor closure returning a non-string gets the source resolver's precise `ImageException`; + - one driver call for repeated materialization; + - retained original source/full pipeline after materialize-then-clone; + - `dominantColor()` then `toBytes()` causes no second process call; + - driver switching re-renders the retained recipe; + - instance metadata caches and clone invalidation; + - clone hash independence by calling both objects; + - normal storage methods preserve caller options, public storage methods force public visibility, and failed writes return `false`; assert these at the filesystem `put()` boundary because a local disk reports an ordinary write as public; + - broken-driver `TypeError` escapes processing and HEIC fallback; + - `flushState()` removes macros; + - native Responsable signature/data URI string behavior. + Replace the reflected `processed`-flag test with the retained-recipe behavioral test. Run immediately. +- [x] `tests/Integration/Image/ImageTest.php` — copy upstream into Hypervel Integration, register `ImageServiceProvider` through Testbench's `getPackageProviders()` hook, correct PNG coverage to call/assert PNG, and guard only WebP-encoding methods when GD lacks WebP rather than skipping the whole suite. Keep Laravel's Integration path because mirroring it avoids collision with the distinct unit `tests/Image/ImageTest.php`; it needs no external-service workflow, and `phpunit.xml.dist` already includes this directory. Preserve real GD end-to-end transformation, storage, request, facade, branching, idempotence, format, quality, hash, public visibility, and no-argument overload tests. Add a real materialize/append test proving the final output is encoded once from the full recipe. Run immediately. +- [x] `tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php` — add `Image` to the framework macro cleanup regression so the optional grouped path is exercised. Run immediately. +- [x] `tests/Validation/ValidationValidatorTest.php` — extend the existing `testValidateImage(): void` matrix with AVIF, HEIC, and HEIF uploaded-file extensions while preserving the SVG opt-in cases. Run immediately. + +Then run the cross-cutting metadata and facade owners: + +```shell +./vendor/bin/phpunit --no-progress tests/Composer/PackageManifestConsistencyTest.php +./vendor/bin/phpunit --no-progress tests/FacadeDocumenter/FacadeDocblocksTest.php +``` + +### 7. User documentation + +- [x] `docs/octane.md` — move the unadapted, unlisted Laravel Octane page out of the published `src/docs` package into the repository-level reference docs without changing its contents; no documentation navigation or page links target it. +- [x] `src/docs/documentation.md` — add Images between HTTP Client and JSON Schema in Digging Deeper. +- [x] `src/docs/filesystem.md` — merge Laravel's `Storage::disk(...)->image(...)` integration at the natural file-retrieval surface and link to the Images page; make the general scoped-resolver section point out that `image()` resolves and captures the disk/prefix at image creation, then explain that boundary at the image surface together with fail-closed empty prefixes and the existing explicit root-passthrough opt-out. +- [x] `src/docs/images.md` — first run `cp ../../../examples/laravel/docs/images.md src/docs/images.md` from this worktree, then read the complete copied destination before editing it in place. Do not draft a replacement document from scratch. Convert namespaces/links/prose to Hypervel and include: + - install `hypervel/image`; Intervention plus GD/Imagick only for bundled drivers; + - config publishing with the exact `php artisan vendor:publish --tag=image-config` command used by the provider; + - uploaded, storage, bytes, base64, local path, URL, and stream sources; remote client/server errors fail before decoding, while streams remain caller-owned/open until first materialization; + - immutable retained recipes, ordered transformations, one final encode, inspection/materialization caching, and variant-first concurrent use; + - WebP/JPEG/PNG/GIF/AVIF/HEIC/BMP helpers, public `toFormat()`, HEIF normalization, and quality/optimize behavior; + - bytes/base64/data URI, with `(string)` correctly documented as a data URI; + - all storage overloads and `UnitEnum` disk names; + - MIME/extension/dimensions/dominant color and direct route returns through `Responsable`; + - clearly labeled incomplete, backend-neutral custom driver skeleton covering `process`, `dimensions`, `dominantColor`, and `transformUsing` without pretending to implement a specific third-party backend; + - boot-only registration, worker-cached stateless drivers/handlers, a read-only/non-retained pipeline argument, and no per-image/request/native-handle retention; + - no image-specific tenancy API: use tenant-scoped filesystem disks/paths, and make tenant-varying custom backends resolve context inside each operation rather than cached-driver construction; + - immutable custom transformation requirement and readonly example; + - prominent queue recommendation for large images because retained source plus outputs and codec work consume request memory/CPU. +- [x] `src/docs/requests.md` — merge Laravel's uploaded-file `$request->image(...)` integration at the uploaded-files surface and link to the Images page. +- [x] `src/docs/validation.md` — correct both the `image` rule and fluent `File::image()` prose to list AVIF, HEIC, and HEIF alongside the existing formats; preserve the SVG security/opt-in guidance. + +Complete custom driver skeleton: + +```php +class VipsDriver implements Driver +{ + public function process(string $contents, ImagePipeline $pipeline): string + { + // Decode with vips, apply the ordered immutable transformations and output options, then encode once. + } + + public function dimensions(string $contents): array + { + // Return [$width, $height]. + } + + public function dominantColor(string $contents): string + { + // Return a seven-character RGB hex value such as #0080ff. + } + + public function transformUsing(string $transformation, callable $callback): static + { + // Store boot-time handlers only; never store image/request state. + return $this; + } +} +``` + +Do not describe correctness fixes or internal caches as Laravel differences. The only README difference is the boot/worker lifetime developers must code against. + +### 8. Framework meta-package integration + +The public `hypervel/framework` meta-package must install the new core image component after the split package is available. Perform this companion change in its own clean worktree and a feature branch based on `0.4`, then validate its manifest: + +- [x] `contrib/hypervel/framework/composer.json` — add `hypervel/image:^0.4`; expand the existing `ext-gd` suggestion to cover the GD image driver; add `ext-imagick` and `intervention/image:^4.0` suggestions. The split package's conflict enforces the supported Intervention major. + +Do not add `config/images.php` to the application skeleton: the discovered package merges its default and publishes an override on demand. + +## Verification and review + +After all targeted files are green: + +1. Run broad namespace searches across all `src/` and `tests/`; inspect every remaining `Illuminate\Image`, Laravel-facing image namespace, raw PHPUnit base class, untyped test method, and non-strict `in_array` hit. +2. Run `composer validate --strict composer.json` and `composer validate --strict src/image/composer.json`. +3. Re-open the installed Intervention 4.x vendor source and reconfirm manager/driver retained fields before accepting worker caching. +4. Inspect the full diff one file at a time for copied upstream bugs, dead `processed`/source-replacement logic, stale comments/docs, accidental generated facade churn, and missing package consumers. +5. Run `composer fix` once. Its full source analysis satisfies the new-package PHPStan checkpoint and it also owns formatting, parallel tests, Testbench, and dogfood; do not duplicate those full checks first. Use targeted PHPStan earlier only to answer a concrete type question, never on tests. +6. If `composer fix` fails, explain the exact root cause before source changes, use targeted checks while correcting it, then run the failed stage plus each remaining `fix` script stage. Repeat the whole checkpoint only when fixes can affect earlier stages. +7. Re-run the affected targeted test files after review corrections. Run a second `composer fix` only if review changes warrant a full checkpoint. +8. Verify `git status`, confirm no tracked `composer.lock`, generated artifacts, temp images, or unrelated changes, and leave all work unstaged/uncommitted. + +For the CI image migration, also parse every changed workflow and grep for obsolete `setup-php` and direct Swoole test-container references locally. After publication, confirm through GitHub CLI that the canonical workflow built both targets from fresh base metadata, passed the Dockerfile smoke checks, and exposed the intended expanded job names/matrices. The image build itself is the authoritative extension check; do not add a repeated verification job or step to each consumer. + +No edit is needed in PHPStan paths or split/release scripts: PHPStan analyzes all `src`, Composer consistency discovers every `src/*/composer.json`, and split/release scripts enumerate package directories. + +## Completion invariants + +- Public API remains Laravel-shaped and fully typed; arbitrary drivers do not depend on Intervention. +- One source family performs at most one lazy read, including concurrent variants and terminal failures. +- Every image recipe encodes once at its endpoint; inspection never changes later output semantics. +- Worker-cached manager/driver state contains only boot configuration and concurrency-safe dependencies. +- No request/image/native resource is retained by a worker singleton or static property. +- Tenant isolation remains at the filesystem/path boundary: a per-image scoped source may capture its tenant prefix/disk, no worker-cached image infrastructure retains that state, scoped filesystems fail closed unless root passthrough was explicitly enabled, and pooled sources never retain a released lease. +- All static macros are centrally reset between tests. +- A missing Intervention Image installation fails with an actionable Hypervel `ImageException` before manager construction. Missing GD or Imagick extensions surface Intervention's actionable `checkHealth()` exception, which `Image` wraps during processing; the bundled drivers therefore do not duplicate native extension checks. Request and filesystem `image()` integration methods deliberately match Laravel: when they need to instantiate an image and `hypervel/image` is absent, they fail with PHP's native class-not-found `Error`, and their Composer suggestions provide the install signal. +- CI executes GD WebP/AVIF and Imagick paths rather than silently skipping them, tests both supported PHP minors, and never compiles extensions independently in each consumer job. +- Package, root components, facade, consumers, docs, tests, split metadata, and framework meta-package agree on the same feature surface. +- No compatibility shim, deprecated provider machinery, obsolete processed flag, dead helper, stale comment, duplicated docs, TODO, skipped applicable test, or unjustified cache remains. diff --git a/docs/todo.md b/docs/todo.md index 6838e5791..a3740b985 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -35,10 +35,6 @@ - Complete Testing assertion coverage: port the remaining current Laravel `TestResponseTest` cases through the incremental upstream-update workflow, and add focused coverage for `TestView`'s public assertion and string surface where Laravel has no equivalent suite. - Add the repository-required `: void` return type to the remaining untyped HTTP test methods: 176 in `tests/Http/HttpClientTest.php`, 30 in `tests/Http/HttpRequestTrustedStateTest.php`, and 4 in `tests/Http/HttpRequestTrustedStateCoroutineTest.php`. Verify each file after the mechanical conversion. -## Image - -- Port the complete first-party Image component through the dedicated [Image package handoff](notes/image-package.md). The HTTP integration must add `Request::image(string $key): ?Image`, port `testImageMethod` and `testImageMethodReturnsNullForMissingKey`, and add the `hypervel/image` suggestion to `src/http/composer.json` with its package-metadata regression. - ## HTTP Server - Remove trailer-stream one-chunk lookahead once the minimum supported Swoole release includes [swoole-src#6124](https://github.com/swoole/swoole-src/pull/6124). Current releases send an empty `END_STREAM` DATA frame before trailer HEADERS when `end()` receives no body after `write()`, so `ResponseBridge` retains the final chunk for `end($chunk)` and delays delivery by one chunk. Once fixed, raise the `ext-swoole` constraint, write every chunk immediately, emit trailers, call bare `end()`, invert the deterministic bridge ordering tests, and add real gRPC incremental-delivery coverage. diff --git a/src/cache/composer.json b/src/cache/composer.json index ede45f838..02e0a9fcb 100644 --- a/src/cache/composer.json +++ b/src/cache/composer.json @@ -30,7 +30,7 @@ }, "require": { "php": "^8.4", - "ext-swoole": "^6.2", + "ext-swoole": "^6.2.2", "hypervel/collections": "^0.4", "hypervel/config": "^0.4", "hypervel/console": "^0.4", @@ -52,6 +52,7 @@ "psr/simple-cache-implementation": "3.0" }, "suggest": { + "ext-redis": "Required to use the Redis cache driver (^6.1); the any tagging mode requires ^6.3.", "hypervel/telescope": "Required for benchmark monitoring detection (^0.4)." }, "config": { diff --git a/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php index bf39bb449..f7a1164f8 100644 --- a/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php +++ b/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php @@ -7,16 +7,24 @@ use Hypervel\Cache\Redis\Console\Doctor\CheckResult; /** - * Checks that PHPRedis extension is installed with required version. - * - * Requires phpredis ≥6.3.0 for full feature support (HSETEX, etc.). + * Checks that PHPRedis is installed with the version required by the tag mode. */ final class PhpRedisCheck implements EnvironmentCheckInterface { - private const string REQUIRED_VERSION = '6.3.0'; + private const string MINIMUM_VERSION = '6.1.0'; + + private const string ANY_TAG_MINIMUM_VERSION = '6.3.0'; private ?string $installedVersion = null; + /** + * Create a new PHPRedis check instance. + */ + public function __construct( + private readonly string $taggingMode, + ) { + } + public function name(): string { return 'PHPRedis Extension'; @@ -36,10 +44,11 @@ public function run(): CheckResult $result->assert(true, "PHPRedis extension is installed (v{$this->installedVersion})"); - $versionOk = version_compare($this->installedVersion, self::REQUIRED_VERSION, '>='); + $requiredVersion = $this->requiredVersion(); + $versionOk = version_compare($this->installedVersion, $requiredVersion, '>='); $result->assert( $versionOk, - 'PHPRedis version >= ' . self::REQUIRED_VERSION + 'PHPRedis version >= ' . $requiredVersion ); return $result; @@ -48,13 +57,25 @@ public function run(): CheckResult public function getFixInstructions(): ?string { if (! extension_loaded('redis')) { - return 'Install PHPRedis: pecl install redis'; + return 'Install PHPRedis: pie install phpredis/phpredis'; } - if ($this->installedVersion !== null && version_compare($this->installedVersion, self::REQUIRED_VERSION, '<')) { - return "Upgrade PHPRedis: pecl upgrade redis (current: {$this->installedVersion}, required: " . self::REQUIRED_VERSION . '+)'; + $requiredVersion = $this->requiredVersion(); + + if ($this->installedVersion !== null && version_compare($this->installedVersion, $requiredVersion, '<')) { + return "Upgrade PHPRedis: pie install phpredis/phpredis (current: {$this->installedVersion}, required: {$requiredVersion}+)"; } return null; } + + /** + * Get the minimum required PHPRedis version. + */ + private function requiredVersion(): string + { + return $this->taggingMode === 'any' + ? self::ANY_TAG_MINIMUM_VERSION + : self::MINIMUM_VERSION; + } } diff --git a/src/cache/src/Redis/Console/DoctorCommand.php b/src/cache/src/Redis/Console/DoctorCommand.php index e01e16326..1d3340979 100644 --- a/src/cache/src/Redis/Console/DoctorCommand.php +++ b/src/cache/src/Redis/Console/DoctorCommand.php @@ -154,7 +154,7 @@ public function handle(): int protected function getEnvironmentChecks(string $storeName, RedisStore $store, string $tagMode, RedisConnection $redis): array { return [ - new PhpRedisCheck, + new PhpRedisCheck($tagMode), new RedisVersionCheck($redis, $tagMode), new HashFieldExpirationCheck($redis, $tagMode), new CacheStoreCheck($storeName, 'redis', $tagMode), diff --git a/src/cache/src/Redis/Operations/AnyTag/PutMany.php b/src/cache/src/Redis/Operations/AnyTag/PutMany.php index e3d4acc21..b7d7050d6 100644 --- a/src/cache/src/Redis/Operations/AnyTag/PutMany.php +++ b/src/cache/src/Redis/Operations/AnyTag/PutMany.php @@ -130,14 +130,9 @@ private function executeCluster(array $values, int $seconds, array $tags): bool $tag = (string) $tag; $tagHashKey = $this->context->tagHashKey($tag); - // Prepare HSET arguments: [key1 => 1, key2 => 1, ...] - $hsetArgs = array_fill_keys($keys, StoreContext::TAG_FIELD_VALUE); + $fields = array_fill_keys($keys, StoreContext::TAG_FIELD_VALUE); - // Use multi() for tag hash updates (same slot) - $multi = $connection->multi(); - $multi->hSet($tagHashKey, $hsetArgs); // @phpstan-ignore arguments.count, argument.type (phpredis supports array syntax) - $multi->hexpire($tagHashKey, $ttl, $keys); // @phpstan-ignore method.nonObject (phpredis multi() returns Redis) - $multi->exec(); + $connection->hsetex($tagHashKey, $fields, ['EX' => $ttl]); } // 5. Batch update Registry (Same slot, single command optimization) @@ -235,11 +230,9 @@ private function executeUsingPipeline(array $values, int $seconds, array $tags): $tag = (string) $tag; $tagHashKey = $this->context->tagHashKey($tag); - // Prepare HSET arguments: [key1 => 1, key2 => 1, ...] - $hsetArgs = array_fill_keys($keys, StoreContext::TAG_FIELD_VALUE); + $fields = array_fill_keys($keys, StoreContext::TAG_FIELD_VALUE); - $pipeline->hSet($tagHashKey, $hsetArgs); // @phpstan-ignore arguments.count, argument.type (phpredis supports array syntax) - $pipeline->hexpire($tagHashKey, $ttl, $keys); // @phpstan-ignore method.nonObject (phpredis pipeline() returns Redis) + $pipeline->hsetex($tagHashKey, $fields, ['EX' => $ttl]); // @phpstan-ignore method.nonObject (phpredis pipeline() returns Redis) } // Update Registry in batch diff --git a/src/cache/src/RedisStore.php b/src/cache/src/RedisStore.php index 9b637e05a..80c75f4e9 100644 --- a/src/cache/src/RedisStore.php +++ b/src/cache/src/RedisStore.php @@ -325,6 +325,10 @@ public function flushStaleTags(): ?array /** * Set the tag mode. * + * Any mode requires Redis 8.0 or later, Valkey 9.0 or later, and + * PhpRedis 6.3.0 or later. These additional requirements do not apply + * to all mode. + * * Boot-only. Mutates state on a per-worker singleton; runtime mutation * races across coroutines. */ diff --git a/src/console/composer.json b/src/console/composer.json index 70924c85d..df0a2fbaa 100644 --- a/src/console/composer.json +++ b/src/console/composer.json @@ -34,7 +34,7 @@ "ext-mbstring": "*", "ext-pcntl": "*", "ext-posix": "*", - "ext-swoole": "^6.2", + "ext-swoole": "^6.2.2", "guzzlehttp/guzzle": "^7.15.1", "nesbot/carbon": "^3.13.1", "nunomaduro/termwind": "^2.0", diff --git a/src/container/composer.json b/src/container/composer.json index 430063b8c..10e0fc7a4 100644 --- a/src/container/composer.json +++ b/src/container/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": "^8.4", - "ext-swoole": "^6.2", + "ext-swoole": "^6.2.2", "psr/container": "^2.0.1", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", diff --git a/src/container/src/Container.php b/src/container/src/Container.php index 0310de23b..c48bac3b3 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -76,10 +76,8 @@ class Container implements ArrayAccess, ContainerContract /** * The current globally available container (if any). - * - * @var null|static */ - protected static $instance; + protected static ?self $instance = null; /** * An array of the types that have been resolved. @@ -2357,7 +2355,7 @@ public function flush(): void /** * Get the globally available instance of the container. */ - public static function getInstance(): static + public static function getInstance(): self { return static::$instance ??= new static; } @@ -2368,9 +2366,9 @@ public static function getInstance(): static * Tests only. Replaces the process-wide container singleton; runtime use * races across coroutines and breaks every facade/global container lookup. */ - public static function setInstance(?ContainerContract $container = null): ?ContainerContract + public static function setInstance(?self $container = null): ?self { - return static::$instance = $container; // @phpstan-ignore assign.propertyType + return static::$instance = $container; } /** diff --git a/src/contracts/src/Image/Driver.php b/src/contracts/src/Image/Driver.php new file mode 100644 index 000000000..3007b0f98 --- /dev/null +++ b/src/contracts/src/Image/Driver.php @@ -0,0 +1,38 @@ + $transformation + */ + public function transformUsing(string $transformation, callable $callback): static; +} diff --git a/src/contracts/src/Image/Transformation.php b/src/contracts/src/Image/Transformation.php new file mode 100644 index 000000000..aba937cdb --- /dev/null +++ b/src/contracts/src/Image/Transformation.php @@ -0,0 +1,12 @@ + #### Redis -Before using a Redis cache with Hypervel, you will need to install the PhpRedis PHP extension via PECL. +Before using a Redis cache with Hypervel, install the PhpRedis PHP extension using [PIE](https://github.com/php/pie): + +```shell +pie install phpredis/phpredis +``` Redis cache tags support two modes. The default `all` mode works on standard Redis deployments. The `any` tag mode requires Redis 8.0+ or Valkey 9.0+ and PhpRedis 6.3.0+, because it uses Redis hash-field expiration commands for tag indexes. diff --git a/src/docs/deployment.md b/src/docs/deployment.md index a5ef51142..ddab4560a 100644 --- a/src/docs/deployment.md +++ b/src/docs/deployment.md @@ -47,7 +47,7 @@ The Hypervel framework has a few system requirements. Hypervel ships with its ow - POSIX PHP Extension - Redis PHP Extension >= 6.1 - Session PHP Extension -- Swoole PHP Extension >= 6.2 +- Swoole PHP Extension >= 6.2.2 - Tokenizer PHP Extension - XML PHP Extension diff --git a/src/docs/documentation.md b/src/docs/documentation.md index e2fa6edb0..ce0c6006c 100644 --- a/src/docs/documentation.md +++ b/src/docs/documentation.md @@ -50,6 +50,7 @@ - [File Storage](/docs/{{version}}/filesystem) - [Helpers](/docs/{{version}}/helpers) - [HTTP Client](/docs/{{version}}/http-client) + - [Images](/docs/{{version}}/images) - [JSON Schema](/docs/{{version}}/json-schema) - [Localization](/docs/{{version}}/localization) - [Mail](/docs/{{version}}/mail) diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index 47e30addb..ce1cba94a 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -11,6 +11,7 @@ - [Obtaining Disk Instances](#obtaining-disk-instances) - [On-Demand Disks](#on-demand-disks) - [Retrieving Files](#retrieving-files) + - [Images](#retrieving-images) - [Downloading Files](#downloading-files) - [File URLs](#file-urls) - [Temporary URLs](#temporary-urls) @@ -329,7 +330,7 @@ $files = new ScopedFilesystemProxy( $files->put('avatar.jpg', $contents); ``` -The disk and prefix resolvers are each called once per operation. Keep the disk resolver fast by reading values already available in memory. Equivalent S3 and Google Cloud Storage configurations created with `Storage::build()` reuse the same client pool. Use `ScopedCloudFilesystemProxy` when you need methods from the `Cloud` contract, such as `url()`. Every disk returned by its resolver must implement that contract, or the first operation throws a `TypeError`. +The disk and prefix resolvers are each called once per operation. For `image()`, both are resolved when the image is created and captured for its later lazy read, as described under [Images](#retrieving-images). Keep the disk resolver fast by reading values already available in memory. Equivalent S3 and Google Cloud Storage configurations created with `Storage::build()` reuse the same client pool. Use `ScopedCloudFilesystemProxy` when you need methods from the `Cloud` contract, such as `url()`. Every disk returned by its resolver must implement that contract, or the first operation throws a `TypeError`. Dynamic scoped filesystems fail closed when the resolved prefix is empty. Pass `allowRootPassthrough: true` to the constructor only when root access is intentional. Prefixes and user paths are normalized with Flysystem's path normalizer, traversal and control characters are rejected, and unknown methods are not forwarded because an unmapped call could bypass the scope. Percent-encoded segments are treated as literal file names; URL decoding belongs at the HTTP boundary. @@ -438,6 +439,19 @@ if (Storage::disk('s3')->directoryMissing('photos')) { } ``` + +### Images + +After installing `hypervel/image`, you may create an image from a file already stored on any filesystem disk. The file is read when the image is first materialized, such as when it is inspected, converted to bytes, processed, or stored: + +```php +$image = Storage::disk('public')->image('avatars/photo.jpg'); +``` + +You may then resize, crop, convert, or store the image using Hypervel's [image manipulation features](/docs/{{version}}/images). + +When `image()` is called on a dynamic `ScopedFilesystemProxy` or `ScopedCloudFilesystemProxy`, the current disk and non-empty prefix are captured when the image is created. This prevents an image from crossing tenant boundaries if it is processed after the coroutine context changes. An empty prefix fails immediately unless the proxy was deliberately constructed with `allowRootPassthrough: true`. + ### Downloading Files diff --git a/src/docs/images.md b/src/docs/images.md new file mode 100644 index 000000000..cb1971b77 --- /dev/null +++ b/src/docs/images.md @@ -0,0 +1,529 @@ +# Image Manipulation + +- [Introduction](#introduction) +- [Installation](#installation) + - [Configuration](#configuration) +- [Reading Images](#reading-images) + - [Uploaded Files](#uploaded-files) + - [Storage Files](#storage-files) + - [Other Sources](#other-sources) +- [Manipulating Images](#manipulating-images) + - [Resizing Images](#resizing-images) + - [Other Transformations](#other-transformations) +- [Encoding Images](#encoding-images) +- [Storing Images](#storing-images) +- [Inspecting Images](#inspecting-images) +- [Returning Images](#returning-images) +- [Image Drivers](#image-drivers) + - [Custom Image Drivers](#custom-image-drivers) + - [Custom Transformations](#custom-transformations) + + +## Introduction + +Hypervel provides a fluent image manipulation API that allows you to resize, crop, encode, and store images using the same expressive conventions found throughout the framework. The bundled GD and Imagick drivers are powered by [Intervention Image](https://image.intervention.io/), while custom drivers may use any image processing backend. + +The image API is useful when working with uploaded files, files stored on Hypervel [filesystem disks](/docs/{{version}}/filesystem), local files, remote URLs, streams, or raw image bytes: + +```php +use Hypervel\Support\Facades\Image; + +$path = Image::fromStorage('avatars/photo.jpg', 'public') + ->cover(400, 400) + ->toWebp() + ->quality(80) + ->storePublicly('avatars', 'public'); +``` + +> [!WARNING] +> Image manipulation can be CPU and memory-intensive. An image family retains its original source while processed variants retain their output bytes. Perform large image processing workloads on a [queued job](/docs/{{version}}/queues) instead of during the HTTP request that receives the upload. + + +## Installation + +Install Hypervel's image package via Composer: + +```shell +composer require hypervel/image +``` + +If you will use the bundled GD or Imagick driver, also install Intervention Image: + +```shell +composer require intervention/image:^4.0 +``` + +You should also ensure your PHP installation has the matching GD or Imagick extension. A custom driver does not require Intervention Image or either bundled extension. + + +### Configuration + +You may publish Hypervel's image configuration file using the `image-config` tag: + +```shell +php artisan vendor:publish --tag=image-config +``` + +The image configuration file allows you to specify your application's default image driver. You may also specify the default driver using the `IMAGE_DRIVER` environment variable. The bundled drivers are `gd` and `imagick`, and registered custom drivers may also be selected: + +```ini +IMAGE_DRIVER=imagick +``` + + +## Reading Images + +The `Image` facade provides several methods for reading images from common sources. Image contents are loaded lazily when the image is first inspected, processed, or stored. The source is resolved once and shared by every variant derived from the image, including variants processed concurrently. + + +### Uploaded Files + +You may retrieve an uploaded image from an incoming request using the `image` method. This method returns a `Hypervel\Image\Image` instance for the uploaded file, or `null` if the file is not present: + +```php +use Hypervel\Http\Request; + +Route::post('/avatar', function (Request $request) { + $request->validate(['avatar' => ['required', 'image']]); + + $path = $request->image('avatar') + ->cover(400, 400) + ->toWebp() + ->storePublicly('avatars', 'public'); + + // ... +}); +``` + +Alternatively, you may create an image instance from a `Hypervel\Http\UploadedFile` instance using the `fromUpload` method: + +```php +use Hypervel\Support\Facades\Image; + +$image = Image::fromUpload($request->file('avatar')); +``` + +When an image is created from an uploaded file, you may retrieve the underlying uploaded file using the `file` method: + +```php +$file = $image->file(); +``` + + +### Storage Files + +You may create an image instance from a file stored on one of your application's [filesystem disks](/docs/{{version}}/filesystem) using the `fromStorage` method. The first argument is the path to the file, while the second argument is the disk name: + +```php +use Hypervel\Support\Facades\Image; + +$image = Image::fromStorage('avatars/photo.jpg', disk: 'public'); +``` + +You may also create image instances directly from a filesystem disk instance using the `image` method: + +```php +use Hypervel\Support\Facades\Storage; + +$image = Storage::disk('public')->image('avatars/photo.jpg'); +``` + + +### Other Sources + +The `Image` facade also includes methods for creating image instances from raw bytes, Base64 encoded strings, local file paths, remote URLs, and open streams: + +```php +use Hypervel\Support\Facades\Image; + +$image = Image::fromBytes($contents); +$image = Image::fromBase64($base64); +$image = Image::fromPath(storage_path('app/avatars/photo.jpg')); +$image = Image::fromUrl('https://example.com/photo.jpg'); +$image = Image::fromStream($stream); +``` + +Remote URL requests are deferred until the image is first materialized. HTTP client and server error responses throw a `Hypervel\Http\Client\RequestException` from the [HTTP client](/docs/{{version}}/http-client#error-handling), and their response bodies are not passed to an image driver. + +Streams remain owned by the caller and must stay open until the image is first materialized, such as when it is inspected, converted to bytes, processed, or stored. Hypervel reads the stream once but does not close it for you. + + +## Manipulating Images + +Image instances are immutable. Each manipulation method returns a new image instance with the transformation appended to its processing pipeline, allowing methods to be chained fluently: + +```php +$image = $request->image('avatar') + ->orient() + ->cover(400, 400) + ->sharpen(10); +``` + +Transformations are processed in the order they are added and the image is encoded once at the end of the complete recipe. Inspecting or encoding an image does not replace its original source or discard its pipeline, so you may append another transformation afterward without decoding and re-encoding an intermediate result. + +Transformed output bytes and inspected metadata are cached on each image instance, while untransformed source bytes are cached across the image family. A new manipulation creates a variant with fresh derived caches while retaining the shared original source. When processing variants concurrently, create each immutable variant before dispatching it; the variants will share one source read while retaining their own recipes and output caches. + + +### Resizing Images + +The `resize` method resizes an image to the given dimensions. You may provide both a width and height, or provide only one dimension using named arguments: + +```php +$image = $image->resize(800, 600); +$image = $image->resize(width: 800); +$image = $image->resize(height: 600); +``` + +The `scale` method proportionally scales an image down so that it fits within the given dimensions. This method will never increase the size of an image: + +```php +$image = $image->scale(800, 600); +$image = $image->scale(width: 800); +$image = $image->scale(height: 600); +``` + +The `cover` method resizes and crops an image to completely cover the given dimensions: + +```php +$image = $image->cover(400, 400); +``` + +The `contain` method resizes an image to fit within the given dimensions while preserving the entire image. If necessary, empty space will be filled using the optional background color: + +```php +$image = $image->contain(400, 400); +$image = $image->contain(400, 400, '#ffffff'); +$image = $image->contain(400, 400, 'dominant'); +``` + +You may specify `dominant` as the background color to fill empty space using the image's dominant color. + +You may crop an image using the `crop` method. The first two arguments are the desired width and height, and the optional third and fourth arguments specify the crop's `x` and `y` coordinates: + +```php +$image = $image->crop(300, 200); +$image = $image->crop(300, 200, x: 50, y: 25); +``` + + +### Other Transformations + +Hypervel also provides a variety of additional image transformation methods: + +```php +$image = $image->orient(); +$image = $image->rotate(90); +$image = $image->rotate(90, '#ffffff'); +$image = $image->rotate(90, 'dominant'); +$image = $image->blur(5); +$image = $image->grayscale(); +$image = $image->sharpen(10); +$image = $image->flipVertically(); +$image = $image->flipHorizontally(); +``` + +The `orient` method rotates the image according to its EXIF orientation data. The `rotate` method rotates the image clockwise by the given angle and accepts an optional background color. The `blur` and `sharpen` methods accept values between `0` and `100`. + + +#### Conditional Transformations + +Image instances support Hypervel's `Conditionable` trait, allowing you to conditionally apply transformations using the `when` and `unless` methods: + +```php +$image = $request->image('avatar') + ->when($request->boolean('crop'), fn ($image) => $image->cover(400, 400)) + ->unless($request->boolean('preserve_format'), fn ($image) => $image->toWebp()); +``` + + +## Encoding Images + +By default, processed images are encoded using their original format. However, you may convert the image to another supported format before retrieving or storing it: + +```php +$image = $image->toWebp(); +$image = $image->toJpg(); +$image = $image->toJpeg(); +$image = $image->toPng(); +$image = $image->toGif(); +$image = $image->toAvif(); +$image = $image->toHeic(); +$image = $image->toBmp(); +``` + +The public `toFormat` method accepts `webp`, `jpg`, `jpeg`, `png`, `gif`, `avif`, `heic`, `heif`, and `bmp`. The `heif` spelling is normalized to `heic`: + +```php +$image = $image->toFormat('heif'); +``` + +You may use the `quality` method to set the output quality. The quality will be clamped between `1` and `100`: + +```php +$image = $image->toWebp()->quality(80); +``` + +The `optimize` method is a convenient shortcut for converting the image to a given format and setting its quality. By default, images are optimized as WebP images with a quality of `70`: + +```php +$image = $image->optimize(); + +$image = $image->optimize(format: 'jpg', quality: 85); +``` + +You may retrieve the processed image contents as a string of bytes, base64 encoded string, or data URI: + +```php +$bytes = $image->toBytes(); +$base64 = $image->toBase64(); +$dataUri = $image->toDataUri(); +``` + +An image instance may also be cast to a string to retrieve its data URI: + +```php +$dataUri = (string) $image; +``` + + +## Storing Images + +The `store` method stores the processed image on one of your application's filesystem disks. Like uploaded files, Hypervel will generate a unique filename and return the stored path. The second argument may be used to specify the disk: + +```php +$path = $request->image('avatar') + ->cover(400, 400) + ->store(path: 'avatars'); + +$path = $request->image('avatar') + ->cover(400, 400) + ->store(path: 'avatars', disk: 's3'); +``` + +Calling `store` without arguments stores the image at the root of the default disk. Disk names may be strings, backed enums, or unit enums. Backed enums use their value, while unit enums use their case name: + +```php +enum FilesystemDisk: string +{ + case Media = 'media'; +} + +$path = $image->store(); +$path = $image->store(path: 'avatars', disk: FilesystemDisk::Media); +``` + +You may use the `storeAs` method to specify the stored filename: + +```php +$path = $request->image('avatar') + ->cover(400, 400) + ->storeAs(path: 'avatars', name: 'avatar.jpg', disk: 'public'); + +$path = $image->storeAs('avatar.jpg'); +``` + +The `storePublicly` and `storePubliclyAs` methods store the image with `public` visibility: + +```php +$path = $request->image('avatar') + ->cover(400, 400) + ->storePublicly(path: 'avatars', disk: 'public'); + +$path = $request->image('avatar') + ->cover(400, 400) + ->storePubliclyAs(path: 'avatars', name: 'avatar.webp', disk: 'public'); + +$path = $image->storePubliclyAs('avatar.webp'); +``` + +If the image could not be stored, the storage methods return `false`. + +The image package has no separate tenant namespace or partition setting. Use tenant-scoped filesystem disks and paths to isolate stored data. Images created through a dynamic scoped filesystem capture that disk and prefix when the image is created, even though the file contents remain lazy. + + +## Inspecting Images + +You may retrieve the image's MIME type, extension, dimensions, width, height, and dominant color using the following methods: + +```php +$mimeType = $image->mimeType(); +$extension = $image->extension(); + +[$width, $height] = $image->dimensions(); +$width = $image->width(); +$height = $image->height(); + +$dominantColor = $image->dominantColor(); +``` + +These methods operate on the processed image. For example, calling `width` after `cover(400, 400)` will return `400`. MIME type, dimensions, dominant color, and transformed output bytes are cached on that image instance after their first successful resolution. + + +## Returning Images + +Image instances implement Hypervel's `Responsable` contract, so you may return an image directly from a route or controller. Hypervel returns the processed bytes with the detected image MIME type: + +```php +use Hypervel\Support\Facades\Image; + +Route::get('/avatar', function () { + return Image::fromStorage('avatars/photo.jpg', 'public') + ->cover(400, 400) + ->toWebp(); +}); +``` + + +## Image Drivers + + +### Custom Image Drivers + +Hypervel's image manager extends the base `Hypervel\Support\Manager` class. You may register custom image drivers using the `extend` method available on the image manager and `Image` facade. + +The driver contract is backend-neutral. A custom driver may use vips, another PHP library, a command-line tool, or a remote service without extending the bundled Intervention driver or installing Intervention Image. A driver must implement all four methods on the `Hypervel\Contracts\Image\Driver` interface. The following incomplete skeleton illustrates the contract; replace each placeholder body with operations provided by your chosen backend: + +```php +, callable> + */ + protected array $handlers = []; + + /** + * Process the given image contents with the specified pipeline. + */ + public function process(string $contents, ImagePipeline $pipeline): string + { + // Decode with vips, apply each transformation in order, apply the + // output format and quality, then encode once. + + return $contents; + } + + /** + * Return the image dimensions as [$width, $height]. + */ + public function dimensions(string $contents): array + { + // Decode with vips and return the real dimensions. + + return [0, 0]; + } + + /** + * Return the dominant color as a seven-character RGB hex value. + */ + public function dominantColor(string $contents): string + { + // Calculate the dominant color with vips. + + return '#000000'; + } + + /** + * Register a custom transformation handler during worker boot. + */ + public function transformUsing(string $transformation, callable $callback): static + { + $this->handlers[$transformation] = $callback; + + return $this; + } +} +``` + +> [!NOTE] +> To see how a complete driver applies transformations and output options, review Hypervel's built-in `Hypervel\Image\Drivers\InterventionDriver`. Only the bundled GD and Imagick drivers depend on Intervention Image. + +Image managers and resolved drivers are cached for the worker lifetime. Drivers must remain stateless and coroutine-safe: retain only immutable configuration or a concurrency-safe client. Do not retain image contents, request or tenant data, pipelines, native image handles, or decoded images. Treat the `ImagePipeline` passed to `process` as read-only and do not retain it after the call returns. + +Register custom drivers during worker boot, typically in a service provider's `boot` method. The registration and resolved driver affect every subsequent request handled by that worker: + +```php +use App\Images\VipsDriver; +use Hypervel\Contracts\Container\Container; +use Hypervel\Support\Facades\Image; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + Image::extend( + 'vips', + fn (Container $container): VipsDriver => $container->make(VipsDriver::class), + ); +} +``` + +After registering the driver, you may use it for a specific image using the `using` method: + +```php +$image = $request->image('avatar') + ->using('vips') + ->cover(400, 400); +``` + +You may also configure a custom driver as your application's default image driver using the `default` option in your application's `config/images.php` configuration file or the `IMAGE_DRIVER` environment variable: + +```ini +IMAGE_DRIVER=vips +``` + +The image package has no tenant-specific driver registry. If a backend varies by tenant, keep a resolver or concurrency-safe client provider on the cached driver and resolve the current tenant inside each `process`, `dimensions`, or `dominantColor` call. Never resolve tenant credentials while constructing the cached driver or retain the resolved tenant value after the operation. + + +### Custom Transformations + +Applications and packages may define custom transformations by creating an immutable class that implements the `Hypervel\Contracts\Image\Transformation` contract. Image variants share transformation objects, so transformation state must never change after construction. Custom transformations can then be added to an image pipeline using the `transform` method: + +```php +pixelate($transformation->size); +}); +``` + +Once the transformation handler has been registered, you may apply the transformation to an image: + +```php +use App\Images\Transformations\Pixelate; + +$image = $request->image('avatar') + ->transform(new Pixelate(12)) + ->store('avatars'); +``` diff --git a/src/docs/installation.md b/src/docs/installation.md index e82ac1ac4..b31fc4655 100644 --- a/src/docs/installation.md +++ b/src/docs/installation.md @@ -41,17 +41,17 @@ The Hypervel framework has a few system requirements: - PDO PHP Extension - POSIX PHP Extension - Session PHP Extension -- Swoole PHP Extension >= 6.2 +- Swoole PHP Extension >= 6.2.2 - Tokenizer PHP Extension If your application uses Redis for cache, queues, sessions, or broadcasting, you should also install the Redis PHP extension 6.1 or higher. -You may install Swoole using PECL: +You may install Swoole using [PIE](https://github.com/php/pie): ```shell -pecl install swoole +pie install swoole/swoole ``` If you develop on macOS, you may also install Swoole via Homebrew. Replace `8.4` with your installed PHP version: diff --git a/src/docs/requests.md b/src/docs/requests.md index 9d0d79576..6d6684067 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -809,6 +809,14 @@ if ($request->hasFile('photo')) { } ``` +If the uploaded file is an image that you need to manipulate before storing, you may use the `image` method to retrieve a `Hypervel\Image\Image` instance, or `null` if the file is not present: + +```php +$image = $request->image('photo'); +``` + +For more information on manipulating images, please consult the complete [image manipulation documentation](/docs/{{version}}/images). + #### Validating Successful Uploads diff --git a/src/docs/session.md b/src/docs/session.md index 4b155d213..1ac10a187 100644 --- a/src/docs/session.md +++ b/src/docs/session.md @@ -66,7 +66,13 @@ The `session:table` command is also available as an alias for `make:session-tabl #### Redis -Before using Redis sessions with Hypervel, you will need to install the [PhpRedis](https://github.com/phpredis/phpredis) PHP extension via PECL. For more information on configuring Redis, consult Hypervel's [Redis documentation](/docs/{{version}}/redis#configuration). +Before using Redis sessions with Hypervel, install the [PhpRedis](https://github.com/phpredis/phpredis) PHP extension using [PIE](https://github.com/php/pie): + +```shell +pie install phpredis/phpredis +``` + +For more information on configuring Redis, consult Hypervel's [Redis documentation](/docs/{{version}}/redis#configuration). > [!NOTE] > The `SESSION_CONNECTION` environment variable, or the `connection` option in the `session.php` configuration file, may be used to specify which Redis connection is used for session storage. diff --git a/src/docs/validation.md b/src/docs/validation.md index 719c8b3a2..4becb47dd 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -2188,7 +2188,7 @@ The field under validation must contain a valid color value in [hexadecimal](htt #### image -The file under validation must be an image (jpg, jpeg, png, bmp, gif, or webp). +The file under validation must be an image (jpg, jpeg, png, bmp, gif, webp, avif, heic, or heif). > [!WARNING] > By default, the image rule does not allow SVG files due to the possibility of XSS vulnerabilities. If you need to allow SVG files, you may provide the `allow_svg` directive to the `image` rule (`image:allow_svg`). @@ -3107,7 +3107,7 @@ File::types(['mp3', 'wav']) #### Validating Image Files -If your application accepts images uploaded by your users, you may use the `File` rule's `image` constructor method to ensure that the file under validation is an image (jpg, jpeg, png, bmp, gif, or webp). +If your application accepts images uploaded by your users, you may use the `File` rule's `image` constructor method to ensure that the file under validation is an image (jpg, jpeg, png, bmp, gif, webp, avif, heic, or heif). In addition, the `dimensions` rule may be used to limit the dimensions of the image: diff --git a/src/engine/composer.json b/src/engine/composer.json index 84593975d..4407eadaa 100644 --- a/src/engine/composer.json +++ b/src/engine/composer.json @@ -26,7 +26,7 @@ "require": { "php": "^8.4", "ext-sockets": "*", - "ext-swoole": "^6.2", + "ext-swoole": "^6.2.2", "psr/http-message": "^2.0", "hypervel/contracts": "^0.4", "hypervel/support": "^0.4" diff --git a/src/filesystem/composer.json b/src/filesystem/composer.json index 0dd7b6173..0ef0e9310 100644 --- a/src/filesystem/composer.json +++ b/src/filesystem/composer.json @@ -55,6 +55,7 @@ "suggest": { "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", + "hypervel/image": "Required to create images from stored files (^0.4).", "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", "league/flysystem-google-cloud-storage": "Required to use the Flysystem Google Cloud Storage driver (^3.25.1).", "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", diff --git a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php index a5a631464..30f6795dc 100644 --- a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php +++ b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php @@ -13,6 +13,8 @@ use Hypervel\Http\File; use Hypervel\Http\Request; use Hypervel\Http\UploadedFile; +use Hypervel\Image\Image; +use Hypervel\Image\ImageException; use Hypervel\Support\Traits\Conditionable; use Psr\Http\Message\StreamInterface; use RuntimeException; @@ -235,6 +237,17 @@ public function download(string $path, ?string $name = null, array $headers = [] return $this->response($path, $name, $headers, 'attachment'); } + /** + * Create an image instance from a file in storage. + */ + public function image(string $path): Image + { + return new Image( + fn (): string => $this->get($path) + ?? throw new ImageException("Unable to read image from path [{$path}]."), + ); + } + /** * Write the contents of a file. * diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index 9656789a5..a3e13b259 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -14,6 +14,8 @@ use Hypervel\Http\File; use Hypervel\Http\Request; use Hypervel\Http\UploadedFile; +use Hypervel\Image\Image; +use Hypervel\Image\ImageException; use Hypervel\Support\Arr; use Hypervel\Support\Json; use Hypervel\Support\Str; @@ -315,6 +317,17 @@ public function download(string $path, ?string $name = null, array $headers = [] return $this->response($path, $name, $headers, 'attachment'); } + /** + * Create an image instance from a file in storage. + */ + public function image(string $path): Image + { + return new Image( + fn (): string => $this->get($path) + ?? throw new ImageException("Unable to read image from path [{$path}]."), + ); + } + /** * Build a file response using the supplied request context. */ diff --git a/src/filesystem/src/ScopedFilesystemProxy.php b/src/filesystem/src/ScopedFilesystemProxy.php index 015c393d0..f808d983b 100644 --- a/src/filesystem/src/ScopedFilesystemProxy.php +++ b/src/filesystem/src/ScopedFilesystemProxy.php @@ -11,6 +11,8 @@ use Hypervel\Http\File; use Hypervel\Http\Request; use Hypervel\Http\UploadedFile; +use Hypervel\Image\Image; +use Hypervel\Image\ImageException; use Hypervel\Support\Traits\Conditionable; use League\Flysystem\PathNormalizer; use League\Flysystem\WhitespacePathNormalizer; @@ -301,6 +303,24 @@ public function download(string $path, ?string $name = null, array $headers = [] return $this->call(__FUNCTION__, [$this->applyPrefix($prefix, $path), $name, $headers]); } + /** + * Create an image instance from a scoped file in storage. + */ + public function image(string $path): Image + { + $prefix = $this->prefix(); + + // Capture the disk with the prefix so a context-backed resolver cannot switch + // tenants between image creation and lazy materialization. + $disk = $this->resolveDisk(); + $scopedPath = $this->applyPrefix($prefix, $path); + + return new Image( + fn (): string => $disk->get($scopedPath) + ?? throw new ImageException("Unable to read image from path [{$path}]."), + ); + } + /** * Write the contents of a scoped file. */ diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 50a83bccf..922e2be6f 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -1394,6 +1394,7 @@ protected function registerCoreContainerAliases(): void 'filesystem.disk' => [\Hypervel\Contracts\Filesystem\Filesystem::class], 'hash' => [\Hypervel\Hashing\HashManager::class], 'hash.driver' => [\Hypervel\Contracts\Hashing\Hasher::class], + 'image' => [\Hypervel\Image\ImageManager::class], 'jwt' => [ \Hypervel\Jwt\JwtManager::class, \Hypervel\Jwt\Contracts\ManagerContract::class, diff --git a/src/grpc/composer.json b/src/grpc/composer.json index 71a503c15..a61c2ac5e 100644 --- a/src/grpc/composer.json +++ b/src/grpc/composer.json @@ -31,7 +31,7 @@ "require": { "php": "^8.4", "composer-runtime-api": "^2.2", - "ext-swoole": "^6.2", + "ext-swoole": "^6.2.2", "ext-zlib": "*", "google/common-protos": "^4.14", "google/protobuf": "^5.35", diff --git a/src/horizon/src/Events/LongWaitDetected.php b/src/horizon/src/Events/LongWaitDetected.php index 2bd7c1419..5972bb4f8 100644 --- a/src/horizon/src/Events/LongWaitDetected.php +++ b/src/horizon/src/Events/LongWaitDetected.php @@ -28,7 +28,7 @@ public function __construct( */ public function toNotification(): LongWaitDetectedNotification { - return Container::getInstance()->makeWith(LongWaitDetectedNotification::class, [ + return Container::getInstance()->make(LongWaitDetectedNotification::class, [ 'longWaitConnection' => $this->connection, 'longWaitQueue' => $this->queue, 'seconds' => $this->seconds, diff --git a/src/http-server/composer.json b/src/http-server/composer.json index 752a37bd2..085d40467 100644 --- a/src/http-server/composer.json +++ b/src/http-server/composer.json @@ -31,7 +31,7 @@ }, "require": { "php": "^8.4", - "ext-swoole": "^6.2", + "ext-swoole": "^6.2.2", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/coordinator": "^0.4", diff --git a/src/http/composer.json b/src/http/composer.json index e3d3b243d..040b649ed 100644 --- a/src/http/composer.json +++ b/src/http/composer.json @@ -55,6 +55,7 @@ }, "suggest": { "ext-gd": "Required to use Hypervel\\Http\\Testing\\FileFactory::image().", + "hypervel/image": "Required for image() method on requests (^0.4).", "hypervel/validation": "Required for validate() method on requests." }, "config": { diff --git a/src/http/src/Concerns/InteractsWithInput.php b/src/http/src/Concerns/InteractsWithInput.php index 9380ad319..2939d505d 100644 --- a/src/http/src/Concerns/InteractsWithInput.php +++ b/src/http/src/Concerns/InteractsWithInput.php @@ -5,6 +5,7 @@ namespace Hypervel\Http\Concerns; use Hypervel\Http\UploadedFile; +use Hypervel\Image\Image; use Hypervel\Support\Arr; use Hypervel\Support\Fluent; use Hypervel\Support\Traits\Dumpable; @@ -208,6 +209,20 @@ public function file(?string $key = null, mixed $default = null): UploadedFile|a return data_get($this->allFiles(), $key, $default); } + /** + * Retrieve a file from the request as an image instance. + */ + public function image(string $key): ?Image + { + $file = $this->file($key); + + if (! $file instanceof UploadedFile) { + return null; + } + + return new Image(fn (): string => $file->getContent(), $file); + } + /** * Retrieve data from the instance. */ diff --git a/src/image/LICENSE.md b/src/image/LICENSE.md new file mode 100644 index 000000000..09cec3ed7 --- /dev/null +++ b/src/image/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/image/README.md b/src/image/README.md new file mode 100644 index 000000000..5b29c28bc --- /dev/null +++ b/src/image/README.md @@ -0,0 +1,12 @@ +Image for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/image) + +Documentation: https://hypervel.org/docs/images + +## Differences From Laravel + +Custom image drivers and transformation handlers must be registered during worker boot. Registrations affect every subsequent request handled by that worker. + +Ported from: https://github.com/laravel/framework diff --git a/src/image/composer.json b/src/image/composer.json new file mode 100644 index 000000000..6155d9ba0 --- /dev/null +++ b/src/image/composer.json @@ -0,0 +1,65 @@ +{ + "name": "hypervel/image", + "type": "library", + "description": "The image package for Hypervel.", + "license": "MIT", + "keywords": [ + "php", + "image", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Image\\": "src/" + } + }, + "require": { + "php": "^8.4", + "ext-fileinfo": "*", + "hypervel/conditionable": "^0.4", + "hypervel/container": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", + "hypervel/filesystem": "^0.4", + "hypervel/foundation": "^0.4", + "hypervel/http": "^0.4", + "hypervel/macroable": "^0.4", + "hypervel/support": "^0.4" + }, + "suggest": { + "ext-gd": "Required to use the GD image driver.", + "ext-imagick": "Required to use the Imagick image driver.", + "intervention/image": "Required to use the GD and Imagick image drivers (^4.0)." + }, + "conflict": { + "intervention/image": "<4.0 || >=5.0" + }, + "config": { + "sort-packages": true + }, + "extra": { + "hypervel": { + "providers": [ + "Hypervel\\Image\\ImageServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "0.4-dev" + } + } +} diff --git a/src/image/config/images.php b/src/image/config/images.php new file mode 100644 index 000000000..f09b4b1fc --- /dev/null +++ b/src/image/config/images.php @@ -0,0 +1,20 @@ + env('IMAGE_DRIVER', 'gd'), +]; diff --git a/src/image/src/Drivers/GdDriver.php b/src/image/src/Drivers/GdDriver.php new file mode 100644 index 000000000..f44a0fca6 --- /dev/null +++ b/src/image/src/Drivers/GdDriver.php @@ -0,0 +1,20 @@ +, callable> + */ + protected array $transformationHandlers = []; + + /** + * The Intervention image manager instance. + */ + protected ImageManagerInterface $manager; + + /** + * Create a new Intervention driver instance. + */ + public function __construct() + { + $this->ensureRequirementsAreMet(); + $this->manager = $this->createManager(); + } + + /** + * Create the underlying Intervention image manager. + */ + abstract protected function createManager(): ImageManagerInterface; + + /** + * Ensure Intervention Image is installed. + * + * @throws ImageException + */ + public function ensureRequirementsAreMet(): void + { + if (! class_exists(ImageManager::class)) { + throw new ImageException( + 'Intervention Image is required to use this driver. ' + . 'You may install it via: composer require intervention/image:^4.0', + ); + } + } + + /** + * Process the given image contents with the specified pipeline. + */ + public function process(string $contents, ImagePipeline $pipeline): string + { + $mimeType = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents); + + if (! in_array($mimeType, ['image/jpeg', 'image/png', 'image/bmp', 'image/gif', 'image/webp', 'image/avif', 'image/x-avif', 'image/heic', 'image/x-heic', 'image/heif'], true)) { + throw new ImageException("The image format [{$mimeType}] is not supported."); + } + + $image = $this->manager->decode($contents); + + foreach ($pipeline->transformations as $transformation) { + if ($handler = $this->transformationHandlerFor($transformation)) { + $image = $handler($image, $transformation); + + continue; + } + + $image = match (true) { + $transformation instanceof Orient => $image->orient(), + $transformation instanceof Cover => $image->cover($transformation->width, $transformation->height), + $transformation instanceof Contain => $image->contain( + $transformation->width, + $transformation->height, + $this->resolveBackground($image, $transformation->background), + ), + $transformation instanceof Crop => $image->crop($transformation->width, $transformation->height, $transformation->x, $transformation->y), + $transformation instanceof Resize => $image->resize($transformation->width, $transformation->height), + $transformation instanceof Rotate => $image->rotate( + $transformation->angle, + $this->resolveBackground($image, $transformation->background), + ), + $transformation instanceof Scale => $image->scaleDown($transformation->width, $transformation->height), + $transformation instanceof Blur => $image->blur($transformation->amount), + $transformation instanceof Grayscale => $image->grayscale(), + $transformation instanceof Sharpen => $image->sharpen($transformation->amount), + $transformation instanceof FlipVertically => $image->flip(Direction::VERTICAL), + $transformation instanceof FlipHorizontally => $image->flip(Direction::HORIZONTAL), + default => throw new ImageException('The image transformation [' . get_class($transformation) . '] is not supported.'), + }; + } + + $quality = $pipeline->output->quality ?? ImageOutputOptions::DEFAULT_QUALITY; + + try { + if ($pipeline->output->format !== null) { + return $image->encode(match ($pipeline->output->format) { + 'webp' => new WebpEncoder($quality), + 'jpg', 'jpeg' => new JpegEncoder($quality), + 'png' => new PngEncoder, + 'gif' => new GifEncoder, + 'avif' => new AvifEncoder($quality), + 'heic' => new HeicEncoder($quality), + 'bmp' => new BmpEncoder, + })->toString(); + } + + $mediaType = match ($image->origin()->mediaType()) { + 'image/x-gif' => 'image/gif', + default => null, + }; + + return $image->encode(new MediaTypeEncoder($mediaType, quality: $quality))->toString(); + } finally { + unset($image); + } + } + + /** + * Get the dimensions of the given image contents. + * + * @return array{0: int, 1: int} + */ + public function dimensions(string $contents): array + { + $image = $this->manager->decode($contents); + + try { + return [$image->width(), $image->height()]; + } finally { + unset($image); + } + } + + /** + * Resolve a background color, expanding the "dominant" sentinel when needed. + */ + protected function resolveBackground(ImageInterface $image, ?string $background): ?string + { + return $background === 'dominant' + ? $this->dominantColorFrom($image) + : $background; + } + + /** + * Get the dominant (average) color of the image as a hex string. + */ + public function dominantColor(string $contents): string + { + $image = $this->manager->decode($contents); + + try { + return $this->dominantColorFrom($image); + } finally { + unset($image); + } + } + + /** + * Sample the dominant color by resizing the image to a single pixel. + */ + protected function dominantColorFrom(ImageInterface $image): string + { + $sample = clone $image; + + try { + // Interpolation during the 1x1 resize can leave alpha slightly non-opaque, so it's dropped here. + return substr($sample->resize(1, 1)->colorAt(0, 0)->toHex(true), 0, 7); + } finally { + unset($sample); + } + } + + /** + * Register a transformation handler. + * + * Boot-only. The handler persists on this cached driver for the worker lifetime and affects every subsequent image processed by it. + * + * @param class-string $transformation + */ + public function transformUsing(string $transformation, callable $callback): static + { + $this->transformationHandlers[$transformation] = $callback; + + return $this; + } + + /** + * Get the handler for the given transformation. + */ + protected function transformationHandlerFor(Transformation $transformation): ?callable + { + foreach ($this->transformationHandlers as $class => $handler) { + if ($transformation instanceof $class) { + return $handler; + } + } + + return null; + } +} diff --git a/src/image/src/Image.php b/src/image/src/Image.php new file mode 100644 index 000000000..74bb0731f --- /dev/null +++ b/src/image/src/Image.php @@ -0,0 +1,666 @@ +source = new ImageSource($contents); + $this->pipeline = new ImagePipeline; + } + + /** + * Set the cover dimensions. + * + * @param int<1, max> $width + * @param int<1, max> $height + */ + public function cover(int $width, int $height): static + { + return $this->transform(new Cover(max(1, $width), max(1, $height))); + } + + /** + * Set the contain dimensions. + * + * @param int<1, max> $width + * @param int<1, max> $height + */ + public function contain(int $width, int $height, ?string $background = null): static + { + return $this->transform(new Contain(max(1, $width), max(1, $height), $background)); + } + + /** + * Crop the image to the given dimensions and position. + * + * @param int<1, max> $width + * @param int<1, max> $height + */ + public function crop(int $width, int $height, int $x = 0, int $y = 0): static + { + return $this->transform(new Crop(max(1, $width), max(1, $height), $x, $y)); + } + + /** + * Resize the image to the given dimensions. + * + * @param null|int<1, max> $width + * @param null|int<1, max> $height + */ + public function resize(?int $width = null, ?int $height = null): static + { + if ($width === null && $height === null) { + throw new ImageException('At least one resize dimension must be specified.'); + } + + return $this->transform(new Resize( + $width === null ? null : max(1, $width), + $height === null ? null : max(1, $height), + )); + } + + /** + * Rotate the image clockwise by the given angle. + */ + public function rotate(float $angle, ?string $background = null): static + { + return $this->transform(new Rotate($angle, $background)); + } + + /** + * Set the scale dimensions. + * + * @param null|int<1, max> $width + * @param null|int<1, max> $height + */ + public function scale(?int $width = null, ?int $height = null): static + { + if ($width === null && $height === null) { + throw new ImageException('At least one scale dimension must be specified.'); + } + + return $this->transform(new Scale( + $width === null ? null : max(1, $width), + $height === null ? null : max(1, $height), + )); + } + + /** + * Auto-orient the image based on EXIF data. + */ + public function orient(): static + { + return $this->transform(new Orient); + } + + /** + * Apply a blur effect. + * + * @param int<0, 100> $amount + */ + public function blur(int $amount = 5): static + { + return $this->transform(new Blur(max(0, min(100, $amount)))); + } + + /** + * Convert the image to grayscale. + */ + public function grayscale(): static + { + return $this->transform(new Grayscale); + } + + /** + * Sharpen the image. + * + * @param int<0, 100> $amount + */ + public function sharpen(int $amount = 10): static + { + return $this->transform(new Sharpen(max(0, min(100, $amount)))); + } + + /** + * Flip the image vertically. + */ + public function flipVertically(): static + { + return $this->transform(new FlipVertically); + } + + /** + * Flip the image horizontally. + */ + public function flipHorizontally(): static + { + return $this->transform(new FlipHorizontally); + } + + /** + * Flip the image vertically. + */ + public function flip(): static + { + return $this->flipVertically(); + } + + /** + * Flip the image horizontally. + */ + public function flop(): static + { + return $this->flipHorizontally(); + } + + /** + * Add a transformation to the image pipeline. + */ + public function transform(Transformation $transformation): static + { + return $this->withClone(fn (Image $image) => $image->pipeline->add($transformation)); + } + + /** + * Set the optimization options. + * + * @throws ImageException + */ + public function optimize(string $format = 'webp', int $quality = ImageOutputOptions::DEFAULT_QUALITY): static + { + return $this->toFormat($format)->quality($quality); + } + + /** + * Set the output quality. + * + * @param int<1, 100> $quality + */ + public function quality(int $quality): static + { + return $this->withOutput(fn (ImageOutputOptions $output) => $output->quality = max(1, min(100, $quality))); + } + + /** + * Convert the image to WebP format. + */ + public function toWebp(): static + { + return $this->toFormat('webp'); + } + + /** + * Convert the image to JPEG format. + */ + public function toJpg(): static + { + return $this->toFormat('jpg'); + } + + /** + * Convert the image to JPEG format. + */ + public function toJpeg(): static + { + return $this->toJpg(); + } + + /** + * Convert the image to PNG format. + */ + public function toPng(): static + { + return $this->toFormat('png'); + } + + /** + * Convert the image to GIF format. + */ + public function toGif(): static + { + return $this->toFormat('gif'); + } + + /** + * Convert the image to AVIF format. + */ + public function toAvif(): static + { + return $this->toFormat('avif'); + } + + /** + * Convert the image to HEIC format. + */ + public function toHeic(): static + { + return $this->toFormat('heic'); + } + + /** + * Convert the image to BMP format. + */ + public function toBmp(): static + { + return $this->toFormat('bmp'); + } + + /** + * Set the output format. + * + * @throws ImageException + */ + public function toFormat(string $format): static + { + if (! in_array($format, ['webp', 'jpg', 'jpeg', 'png', 'gif', 'avif', 'heic', 'heif', 'bmp'], true)) { + throw new ImageException("The [{$format}] format is not supported."); + } + + $format = $format === 'heif' ? 'heic' : $format; + + return $this->withOutput(fn (ImageOutputOptions $output) => $output->format = $format); + } + + /** + * Store the processed image on a filesystem disk. + * + * @param array $options + */ + public function store(string $path = '', UnitEnum|string|null $disk = null, array $options = []): string|false + { + return $this->storeAs($path, $this->hashName(), $disk, $options); + } + + /** + * Store the processed image on a filesystem disk with public visibility. + * + * @param array $options + */ + public function storePublicly(string $path = '', UnitEnum|string|null $disk = null, array $options = []): string|false + { + $options['visibility'] = 'public'; + + return $this->storeAs($path, $this->hashName(), $disk, $options); + } + + /** + * Store the processed image on a filesystem disk with a given name. + * + * @param array $options + */ + public function storeAs(string $path, ?string $name = null, UnitEnum|string|null $disk = null, array $options = []): string|false + { + if (is_null($name)) { + [$path, $name] = ['', $path]; + } + + $path = trim($path . '/' . $name, '/'); + + $result = Container::getInstance()->make(FilesystemFactory::class) + ->disk($disk) + ->put($path, $this->toBytes(), $options); + + return $result ? $path : false; + } + + /** + * Store the processed image on a filesystem disk with public visibility and a given name. + * + * @param array $options + */ + public function storePubliclyAs(string $path, ?string $name = null, UnitEnum|string|null $disk = null, array $options = []): string|false + { + if (is_null($name)) { + [$path, $name] = ['', $path]; + } + + $options['visibility'] = 'public'; + + return $this->storeAs($path, $name, $disk, $options); + } + + /** + * Get a hashed filename with the correct extension. + */ + public function hashName(string $path = ''): string + { + $this->hashName ??= Str::random(40); + + $hash = $this->hashName . '.' . $this->extension(); + + return $path ? $path . '/' . $hash : $hash; + } + + /** + * Process the image and return the raw bytes. + */ + public function toBytes(): string + { + if (! $this->pipeline->hasChanges()) { + return $this->source->contents(); + } + + return $this->processedContents ??= $this->process(); + } + + /** + * Process the image recipe. + */ + protected function process(): string + { + try { + return $this->resolveDriver()->process($this->source->contents(), $this->pipeline); + } catch (ImageException $exception) { + throw $exception; + } catch (Exception $exception) { + throw new ImageException("Failed to process image: {$exception->getMessage()}", 0, $exception); + } + } + + /** + * Process the image and return as a base64 encoded string. + */ + public function toBase64(): string + { + return base64_encode($this->toBytes()); + } + + /** + * Process the image and return as a data URI. + */ + public function toDataUri(): string + { + return 'data:' . $this->mimeType() . ';base64,' . $this->toBase64(); + } + + /** + * Get the file extension based on the MIME type. + */ + public function extension(): string + { + return match ($this->mimeType()) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/avif', 'image/x-avif' => 'avif', + 'image/heic', 'image/x-heic', 'image/heif' => 'heic', + 'image/bmp' => 'bmp', + 'image/svg+xml' => 'svg', + 'image/tiff' => 'tiff', + default => 'bin', + }; + } + + /** + * Get the MIME type of the processed image. + */ + public function mimeType(): string + { + return $this->mimeType ??= (new finfo(FILEINFO_MIME_TYPE))->buffer($this->toBytes()); + } + + /** + * Get the dimensions of the processed image. + * + * @return array{0: int, 1: int} + */ + public function dimensions(): array + { + if ($this->dimensions !== null) { + return $this->dimensions; + } + + $contents = $this->toBytes(); + + // getimagesize() misreports HEIC's coded / padded frame size, so read HEIC via the driver... + if (in_array($this->mimeType(), ['image/heic', 'image/heif', 'image/x-heic'], true)) { + try { + return $this->dimensions = $this->resolveDriver()->dimensions($contents); + } catch (Exception) { + // The driver can't decode this image; fall back to the native reader below... + } + } + + $size = @getimagesizefromstring($contents); + + if ($size === false) { + throw new ImageException('Unable to determine the dimensions of the image.'); + } + + return $this->dimensions = [$size[0], $size[1]]; + } + + /** + * Get the width of the processed image. + */ + public function width(): int + { + return $this->dimensions()[0]; + } + + /** + * Get the height of the processed image. + */ + public function height(): int + { + return $this->dimensions()[1]; + } + + /** + * Get the dominant (average) color of the image as a hex string. + */ + public function dominantColor(): string + { + return $this->dominantColor ??= $this->resolveDriver()->dominantColor($this->toBytes()); + } + + /** + * Set the driver to use for processing. + */ + public function using(string $driver): static + { + $clone = clone $this; + + $clone->driver = $driver; + + return $clone; + } + + /** + * Use the GD driver for processing. + */ + public function usingGd(): static + { + return $this->using('gd'); + } + + /** + * Use the Imagick driver for processing. + */ + public function usingImagick(): static + { + return $this->using('imagick'); + } + + /** + * Resolve the image processing driver. + */ + protected function resolveDriver(): Driver + { + /** @var ImageManager $manager */ + $manager = Container::getInstance()->make('image'); + + return $this->driver !== null + ? $manager->driver($this->driver) + : $manager->driver(); + } + + /** + * Get the underlying uploaded file instance. + */ + public function file(): ?UploadedFile + { + return $this->file; + } + + /** + * Clone the pipeline and reset all derived state. + */ + public function __clone(): void + { + $this->pipeline = clone $this->pipeline; + $this->processedContents = null; + $this->mimeType = null; + $this->dimensions = null; + $this->dominantColor = null; + $this->hashName = null; + } + + /** + * Create an immutable clone with updated output options. + */ + protected function withOutput(Closure $callback): static + { + return $this->withClone(fn (Image $image) => $callback($image->pipeline->output)); + } + + /** + * Create an immutable clone with the given callback applied. + */ + protected function withClone(Closure $callback): static + { + $clone = clone $this; + + $callback($clone); + + return $clone; + } + + /** + * Create an HTTP response that represents the image. + */ + public function toResponse(Request $request): Response + { + return new Response($this->toBytes(), 200, [ + 'Content-Type' => $this->mimeType(), + ]); + } + + /** + * Prevent serialization of the image. + * + * @throws ImageException + */ + public function __serialize(): never + { + throw new ImageException('Images cannot be serialized. Store the image first and serialize the path instead.'); + } + + /** + * Get the string representation of the image. + */ + public function toString(): string + { + return $this->toDataUri(); + } + + /** + * Get the string representation of the image. + */ + public function __toString(): string + { + return $this->toString(); + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::flushMacros(); + } +} diff --git a/src/image/src/ImageException.php b/src/image/src/ImageException.php new file mode 100644 index 000000000..0c7b33149 --- /dev/null +++ b/src/image/src/ImageException.php @@ -0,0 +1,11 @@ +, callable>> + */ + protected array $transformationHandlers = []; + + /** + * Create an image instance from raw bytes. + */ + public function fromBytes(string $contents): Image + { + return new Image($contents); + } + + /** + * Create an image instance from a stream. + * + * @param resource $stream + */ + public function fromStream(mixed $stream): Image + { + return new Image(function () use ($stream): string { + $contents = stream_get_contents($stream); + + if ($contents === false || $contents === '') { + throw new ImageException('Invalid stream image data.'); + } + + return $contents; + }); + } + + /** + * Create an image instance from a base64 encoded string. + */ + public function fromBase64(string $base64): Image + { + return new Image(function () use ($base64): string { + $contents = base64_decode($base64, true); + + if ($contents === false || $contents === '') { + throw new ImageException('Invalid base64 image data.'); + } + + return $contents; + }); + } + + /** + * Create an image instance from a file path. + */ + public function fromPath(string $path): Image + { + return new Image( + fn (): string => $this->container->make(Filesystem::class)->get($path), + ); + } + + /** + * Create an image instance from a storage disk path. + */ + public function fromStorage(string $path, UnitEnum|string|null $disk = null): Image + { + return new Image( + fn (): string => $this->container->make(FilesystemFactory::class)->disk($disk)->get($path) + ?? throw new ImageException("Unable to read image from path [{$path}]."), + ); + } + + /** + * Create an image instance from an uploaded file. + */ + public function fromUpload(UploadedFile $file): Image + { + return new Image(fn (): string => $file->getContent(), $file); + } + + /** + * Create an image instance from a URL. + */ + public function fromUrl(string $url): Image + { + return new Image( + fn (): string => $this->container->make(HttpFactory::class)->get($url)->throw()->body(), + ); + } + + /** + * Create a new driver instance. + * + * @throws InvalidArgumentException + */ + protected function createDriver(string $driver): Driver + { + try { + /** @var Driver $instance */ + $instance = parent::createDriver($driver); + } catch (InvalidArgumentException $exception) { + throw new InvalidArgumentException("Image driver [{$driver}] is not supported.", 0, $exception); + } + + $this->applyTransformationHandlers($driver, $instance); + + return $instance; + } + + /** + * Create the GD image driver. + */ + protected function createGdDriver(): GdDriver + { + return new GdDriver; + } + + /** + * Create the Imagick image driver. + */ + protected function createImagickDriver(): ImagickDriver + { + return new ImagickDriver; + } + + /** + * Register a transformation handler for the given driver. + * + * Boot-only. The handler persists on a cached driver for the worker lifetime and affects every subsequent image processed by that driver. + * + * @param class-string $transformation + */ + public function transformUsing(string $driver, string $transformation, callable $callback): static + { + $this->transformationHandlers[$driver][$transformation] = $callback; + + if (isset($this->drivers[$driver])) { + /** @var Driver $instance */ + $instance = $this->drivers[$driver]; + + $this->applyTransformationHandlers($driver, $instance); + } + + return $this; + } + + /** + * Apply registered transformation handlers to the given driver instance. + */ + protected function applyTransformationHandlers(string $driver, Driver $instance): void + { + foreach ($this->transformationHandlers[$driver] ?? [] as $transformation => $callback) { + $instance->transformUsing($transformation, $callback); + } + } + + /** + * Get the default image driver name. + */ + public function getDefaultDriver(): string + { + return $this->config->string('images.default'); + } +} diff --git a/src/image/src/ImageOutputOptions.php b/src/image/src/ImageOutputOptions.php new file mode 100644 index 000000000..3d3368cf5 --- /dev/null +++ b/src/image/src/ImageOutputOptions.php @@ -0,0 +1,35 @@ + + */ + public ?int $quality = null; + + /** + * Determine if any output options have been set. + */ + public function hasChanges(): bool + { + return $this->format !== null || $this->quality !== null; + } +} diff --git a/src/image/src/ImagePipeline.php b/src/image/src/ImagePipeline.php new file mode 100644 index 000000000..22a777023 --- /dev/null +++ b/src/image/src/ImagePipeline.php @@ -0,0 +1,48 @@ + + */ + public array $transformations = []; + + /** + * Create a new image pipeline instance. + */ + public function __construct(public ImageOutputOptions $output = new ImageOutputOptions) + { + } + + /** + * Add a transformation to the pipeline. + */ + public function add(Transformation $transformation): void + { + $this->transformations[] = $transformation; + } + + /** + * Determine if the pipeline has transformations or output changes. + */ + public function hasChanges(): bool + { + return $this->transformations !== [] || $this->output->hasChanges(); + } + + /** + * Clone the output options with the pipeline. + */ + public function __clone(): void + { + $this->output = clone $this->output; + } +} diff --git a/src/image/src/ImageServiceProvider.php b/src/image/src/ImageServiceProvider.php new file mode 100644 index 000000000..2fed686ec --- /dev/null +++ b/src/image/src/ImageServiceProvider.php @@ -0,0 +1,39 @@ +mergeConfigFrom( + dirname(__DIR__) . '/config/images.php', + 'images', + ); + + $this->app->singleton( + 'image', + fn (Container $container): ImageManager => new ImageManager($container), + ); + } + + /** + * Bootstrap Image services. + */ + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + dirname(__DIR__) . '/config/images.php' => config_path('images.php'), + ], 'image-config'); + } + } +} diff --git a/src/image/src/ImageSource.php b/src/image/src/ImageSource.php new file mode 100644 index 000000000..4eb2b4d62 --- /dev/null +++ b/src/image/src/ImageSource.php @@ -0,0 +1,79 @@ +contents = $contents; + } else { + $this->resolver = $contents; + } + } + + /** + * Resolve the image source contents. + */ + public function contents(): string + { + if ($this->contents !== null) { + return $this->contents; + } + + if ($this->exception !== null) { + throw $this->exception; + } + + // This object stays alive while its lock is held, so PHP cannot reuse its object ID for another source. + $key = self::LOCK_KEY_PREFIX . spl_object_id($this); + + if (Locker::lock($key)) { + try { + /** @var Closure $resolver */ + $resolver = $this->resolver; + $contents = $resolver(); + + if (! is_string($contents)) { + throw new ImageException(sprintf( + 'Image source resolver must return a string, %s returned.', + get_debug_type($contents), + )); + } + + $this->contents = $contents; + } catch (Throwable $exception) { + $this->exception = $exception; + } finally { + $this->resolver = null; + Locker::unlock($key); + } + } + + return $this->contents ?? throw $this->exception + ?? new ImageException('Image source resolution was interrupted.'); + } +} diff --git a/src/image/src/Transformations/Blur.php b/src/image/src/Transformations/Blur.php new file mode 100644 index 000000000..e2a15363f --- /dev/null +++ b/src/image/src/Transformations/Blur.php @@ -0,0 +1,14 @@ +|string $environments) - * @method static \Hypervel\Foundation\Application getInstance() - * @method static \Hypervel\Contracts\Container\Container|null setInstance(\Hypervel\Contracts\Container\Container|null $container = null) + * @method static \Hypervel\Container\Container getInstance() + * @method static \Hypervel\Container\Container|null setInstance(\Hypervel\Container\Container|null $container = null) * @method static void macro(string $name, callable|object $macro) * @method static void mixin(object $mixin, bool $replace = true) * @method static bool hasMacro(string $name) diff --git a/src/support/src/Facades/Image.php b/src/support/src/Facades/Image.php new file mode 100644 index 000000000..eaaa8505c --- /dev/null +++ b/src/support/src/Facades/Image.php @@ -0,0 +1,35 @@ + allFiles() * @method static bool hasFile(string $key) * @method static ($key is null ? array : null|\Hypervel\Http\UploadedFile|\Hypervel\Http\UploadedFile[]) file(string|null $key = null, mixed $default = null) + * @method static \Hypervel\Image\Image|null image(string $key) * @method static \Hypervel\Http\Request dump(mixed $keys = []) * @method static never dd(mixed ...$args) * @method static bool exists(array|string $key) diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php index 442949ac3..2b294140d 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -73,6 +73,7 @@ * @method static \Symfony\Component\HttpFoundation\StreamedResponse response(string $path, string|null $name = null, array $headers = [], string $disposition = 'inline') * @method static \Symfony\Component\HttpFoundation\Response serve(\Hypervel\Http\Request $request, string $path, string|null $name = null, array $headers = []) * @method static \Symfony\Component\HttpFoundation\StreamedResponse download(string $path, string|null $name = null, array $headers = []) + * @method static \Hypervel\Image\Image image(string $path) * @method static string|false checksum(string $path, array $options = []) * @method static string|false mimeType(string $path) * @method static string url(string $path) diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index d35809ef4..fdd7d31fa 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -317,6 +317,7 @@ protected function flushFrameworkState(): void $this->flushFortifyState(); $this->flushHorizonState(); + $this->flushImageState(); $this->flushInertiaState(); $this->flushJwtState(); $this->flushNestedSetState(); @@ -351,6 +352,14 @@ protected function flushHorizonState(): void $this->callIfExists(\Hypervel\Horizon\WorkerCommandString::class, 'flushState'); } + /** + * Flush Image state. + */ + protected function flushImageState(): void + { + $this->callIfExists(\Hypervel\Image\Image::class, 'flushState'); + } + /** * Flush Inertia state. */ diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index 29f84f347..9e2ff5a70 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -1361,9 +1361,9 @@ public function validateHexColor(string $attribute, mixed $value): bool */ public function validateImage(string $attribute, mixed $value, array $parameters = []): bool { - $mimes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; + $mimes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'avif', 'heic', 'heif']; - if (in_array('allow_svg', $parameters)) { + if (in_array('allow_svg', $parameters, true)) { $mimes[] = 'svg'; } diff --git a/src/watcher/composer.json b/src/watcher/composer.json index fccc8469f..7cc644746 100644 --- a/src/watcher/composer.json +++ b/src/watcher/composer.json @@ -33,7 +33,7 @@ "php": "^8.4", "ext-pcntl": "*", "ext-posix": "*", - "ext-swoole": "^6.2", + "ext-swoole": "^6.2.2", "hypervel/console": "^0.4", "hypervel/contracts": "^0.4", "hypervel/coroutine": "^0.4", diff --git a/src/websocket-server/composer.json b/src/websocket-server/composer.json index 49f7eec57..c42dc6e38 100644 --- a/src/websocket-server/composer.json +++ b/src/websocket-server/composer.json @@ -30,7 +30,7 @@ }, "require": { "php": "^8.4", - "ext-swoole": "^6.2", + "ext-swoole": "^6.2.2", "hypervel/collections": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", diff --git a/tests/Cache/Redis/Console/DoctorCommandTest.php b/tests/Cache/Redis/Console/DoctorCommandTest.php index 2e368c9c8..9bfb822fd 100644 --- a/tests/Cache/Redis/Console/DoctorCommandTest.php +++ b/tests/Cache/Redis/Console/DoctorCommandTest.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Cache\CacheManager; use Hypervel\Cache\Redis\Console\Doctor\Checks\HashFieldExpirationCheck; +use Hypervel\Cache\Redis\Console\Doctor\Checks\PhpRedisCheck; use Hypervel\Cache\Redis\Console\Doctor\DoctorContext; use Hypervel\Cache\Redis\Console\DoctorCommand; use Hypervel\Cache\Redis\Support\StoreContext; @@ -20,6 +21,7 @@ use Hypervel\Redis\RedisConnection; use Hypervel\Testbench\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; @@ -30,6 +32,48 @@ */ class DoctorCommandTest extends TestCase { + #[DataProvider('phpRedisRequirements')] + public function testPhpRedisCheckUsesRequirementForTagMode(string $taggingMode, string $requiredVersion): void + { + $installedVersion = phpversion('redis'); + $this->assertIsString($installedVersion); + + $check = new PhpRedisCheck($taggingMode); + $result = $check->run(); + $meetsRequirement = version_compare($installedVersion, $requiredVersion, '>='); + + $this->assertSame([ + [ + 'passed' => true, + 'description' => "PHPRedis extension is installed (v{$installedVersion})", + ], + [ + 'passed' => $meetsRequirement, + 'description' => "PHPRedis version >= {$requiredVersion}", + ], + ], $result->assertions); + + $this->assertSame( + $meetsRequirement + ? null + : "Upgrade PHPRedis: pie install phpredis/phpredis (current: {$installedVersion}, required: {$requiredVersion}+)", + $check->getFixInstructions(), + ); + } + + /** + * Provide PHPRedis requirements by tag mode. + * + * @return array + */ + public static function phpRedisRequirements(): array + { + return [ + 'all mode' => ['all', '6.1.0'], + 'any mode' => ['any', '6.3.0'], + ]; + } + public function testHashFieldExpirationCheckSkipsAllMode(): void { $connection = m::mock(RedisConnection::class); diff --git a/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php b/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php index 418e58fb2..eb9961c3a 100644 --- a/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php +++ b/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php @@ -19,11 +19,11 @@ public function testPutManyWithTagsStoresMultipleItems(): void $connection = $this->mockConnection(); // Standard mode uses pipeline() not multi() - $connection->shouldReceive('pipeline')->andReturn($connection); + $connection->shouldReceive('pipeline')->twice()->andReturn($connection); // First pipeline for getting old tags (smembers) $connection->shouldReceive('smembers')->twice()->andReturn($connection); - $connection->shouldReceive('exec')->andReturn([[], []]); // No old tags for first pipeline + $connection->shouldReceive('exec')->twice()->andReturn([[], []], []); // Second pipeline for setex, reverse index updates, and tag hashes $connection->shouldReceive('setex')->twice()->andReturn($connection); @@ -31,9 +31,16 @@ public function testPutManyWithTagsStoresMultipleItems(): void $connection->shouldReceive('sadd')->twice()->andReturn($connection); $connection->shouldReceive('expire')->twice()->andReturn($connection); - // hSet and hexpire for tag hashes (batch operation) - $connection->shouldReceive('hSet')->andReturn($connection); - $connection->shouldReceive('hexpire')->andReturn($connection); + $connection->shouldReceive('hsetex') + ->once() + ->with( + 'prefix:_any:tag:users:entries', + ['foo' => '1', 'baz' => '1'], + ['EX' => 60], + ) + ->andReturn($connection); + $connection->shouldNotReceive('hSet'); + $connection->shouldNotReceive('hexpire'); // zadd for registry $connection->shouldReceive('zadd')->andReturn($connection); @@ -46,4 +53,35 @@ public function testPutManyWithTagsStoresMultipleItems(): void ], 60, ['users']); $this->assertTrue($result); } + + public function testPutManyUsesOneBatchedHsetexPerTagInClusterMode(): void + { + [$redis, , $connection] = $this->createClusterStore(tagMode: 'any'); + + $connection->shouldReceive('smembers')->twice()->andReturn([]); + $connection->shouldReceive('setex')->twice()->andReturnTrue(); + $connection->shouldReceive('multi')->twice()->andReturn($connection); + $connection->shouldReceive('del')->twice()->andReturn($connection); + $connection->shouldReceive('sadd')->twice()->andReturn($connection); + $connection->shouldReceive('expire')->twice()->andReturn($connection); + $connection->shouldReceive('exec')->twice()->andReturn([]); + $connection->shouldReceive('hsetex') + ->once() + ->with( + 'prefix:_any:tag:users:entries', + ['foo' => '1', 'baz' => '1'], + ['EX' => 60], + ) + ->andReturnTrue(); + $connection->shouldNotReceive('hSet'); + $connection->shouldNotReceive('hexpire'); + $connection->shouldReceive('zadd')->once()->andReturn(1); + + $result = $redis->anyTagOps()->putMany()->execute([ + 'foo' => 'bar', + 'baz' => 'qux', + ], 60, ['users']); + + $this->assertTrue($result); + } } diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index e82f290ef..44fceeb18 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -15,6 +15,7 @@ use Hypervel\Contracts\Container\CircularDependencyException; use Hypervel\Contracts\Container\ContextualAttribute; use Hypervel\Contracts\Container\SelfBuilding; +use Hypervel\Foundation\Application; use Hypervel\Tests\TestCase; use InvalidArgumentException; use LogicException; @@ -39,6 +40,16 @@ public function testContainerSingleton() $this->assertNotSame($container, $container2); } + public function testContainerSingletonIsSharedAcrossInheritanceHierarchy(): void + { + $application = new Application; + + Container::setInstance($application); + + $this->assertSame($application, Container::getInstance()); + $this->assertSame($application, Application::getInstance()); + } + public function testClosureResolution() { $container = new Container; diff --git a/tests/Filesystem/ClientPooledFilesystemTest.php b/tests/Filesystem/ClientPooledFilesystemTest.php index bb6ed47fd..6ff0eb1c9 100644 --- a/tests/Filesystem/ClientPooledFilesystemTest.php +++ b/tests/Filesystem/ClientPooledFilesystemTest.php @@ -15,6 +15,7 @@ use Hypervel\Http\IterableStreamedResponse; use Hypervel\Http\Request; use Hypervel\Http\Response; +use Hypervel\Image\ImageException; use Hypervel\ObjectPool\Contracts\Factory; use Hypervel\ObjectPool\Contracts\InvalidatesPool; use Hypervel\ObjectPool\Contracts\ObjectPool as ObjectPoolContract; @@ -82,6 +83,45 @@ public function testSynchronousOperationsBuildFreshStacksAroundOnePooledClient() $this->assertSame(1, $this->pools->get('filesystem:test')->getObjectNumberInPool()); } + public function testImageDefersAndBalancesItsClientBorrowUntilMaterialization(): void + { + $this->driver->write('image.bin', 'contents'); + $clientCreations = 0; + $stackCreations = 0; + $disk = $this->disk($clientCreations, $stackCreations); + + $image = $disk->image('image.bin'); + + $this->assertSame(0, $clientCreations); + $this->assertSame(0, $stackCreations); + $this->assertSame('contents', $image->toBytes()); + $this->assertSame(1, $clientCreations); + $this->assertSame(1, $stackCreations); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + + $this->assertSame('contents', $image->toBytes()); + $this->assertSame(1, $stackCreations); + } + + public function testMissingImageReleasesItsClientBorrowAndReportsTheCallerPath(): void + { + $clientCreations = 0; + $stackCreations = 0; + $disk = $this->disk($clientCreations, $stackCreations); + $image = $disk->image('missing.bin'); + + try { + $image->toBytes(); + $this->fail('Expected the missing image to be rejected.'); + } catch (ImageException $exception) { + $this->assertSame('Unable to read image from path [missing.bin].', $exception->getMessage()); + } + + $this->assertSame(1, $clientCreations); + $this->assertSame(1, $stackCreations); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + } + public function testSynchronousFlysystemMethodsAndConditionableUseTheProxyBoundary(): void { $clientCreations = 0; diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index 3da720a33..85a97e8e4 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -17,6 +17,8 @@ use Hypervel\Http\Request; use Hypervel\Http\Response; use Hypervel\Http\UploadedFile; +use Hypervel\Image\Image; +use Hypervel\Image\ImageException; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Json; use Hypervel\Testbench\TestCase; @@ -354,6 +356,30 @@ public function testJsonReturnsDecodedScalarData(): void } } + public function testImage(): void + { + $file = UploadedFile::fake()->image('photo.jpg', 100, 100); + $this->filesystem->write('photo.jpg', file_get_contents($file->getRealPath())); + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + $image = $filesystemAdapter->image('photo.jpg'); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame([100, 100], $image->dimensions()); + } + + public function testMissingImageFailsLazilyWithCallerPath(): void + { + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + $image = $filesystemAdapter->image('missing.jpg'); + + $this->expectException(ImageException::class); + $this->expectExceptionMessage('Unable to read image from path [missing.jpg].'); + + $image->toBytes(); + } + public function testMimeTypeNotDetected() { $this->filesystem->write('unknown.mime-type', ''); diff --git a/tests/Filesystem/FilesystemPoolProxyTest.php b/tests/Filesystem/FilesystemPoolProxyTest.php index 17efddd19..16d024d4d 100644 --- a/tests/Filesystem/FilesystemPoolProxyTest.php +++ b/tests/Filesystem/FilesystemPoolProxyTest.php @@ -13,6 +13,7 @@ use Hypervel\Http\IterableStreamedResponse; use Hypervel\Http\Request; use Hypervel\Http\Response; +use Hypervel\Image\ImageException; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolManager; use Hypervel\ObjectPool\PoolOptions; @@ -85,6 +86,60 @@ public function testJsonReturnsScalarDataAndReleasesTheDriver(): void $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); } + public function testImageDefersAndBalancesTheWholeDriverLease(): void + { + $this->driver->write('photo.jpg', 'image bytes'); + $creations = 0; + $releaseCalls = 0; + $proxy = $this->proxy( + function () use (&$creations): FilesystemAdapter { + ++$creations; + + return $this->filesystem(); + }, + function (object $filesystem) use (&$releaseCalls): void { + ++$releaseCalls; + }, + ); + + $image = $proxy->image('photo.jpg'); + + $this->assertSame(0, $creations); + $this->assertFalse($this->pools->has('filesystem:driver')); + $this->assertSame('image bytes', $image->toBytes()); + $this->assertSame(1, $creations); + $this->assertSame(1, $releaseCalls); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + + $this->assertSame('image bytes', $image->toBytes()); + $this->assertSame(1, $releaseCalls); + } + + public function testMissingImageReleasesTheWholeDriverLease(): void + { + $releaseCalls = 0; + $proxy = $this->proxy( + fn (): FilesystemAdapter => $this->filesystem(), + function (object $filesystem) use (&$releaseCalls): void { + ++$releaseCalls; + }, + ); + $image = $proxy->image('missing.jpg'); + + try { + $image->toBytes(); + $this->fail('Expected the missing image read to fail.'); + } catch (ImageException $exception) { + $this->assertSame( + 'Unable to read image from path [missing.jpg].', + $exception->getMessage(), + ); + } + + $this->assertSame(1, $releaseCalls); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + } + public function testAssertEmptyReturnsTheProxyAndReleasesTheDriver(): void { $proxy = $this->proxy(fn (): FilesystemAdapter => $this->filesystem()); diff --git a/tests/Filesystem/PackageMetadataTest.php b/tests/Filesystem/PackageMetadataTest.php new file mode 100644 index 000000000..986c4318e --- /dev/null +++ b/tests/Filesystem/PackageMetadataTest.php @@ -0,0 +1,30 @@ +assertArrayHasKey('hypervel/image', $composer['suggest']); + $this->assertIsString($composer['suggest']['hypervel/image']); + $this->assertNotSame('', trim($composer['suggest']['hypervel/image'])); + } +} diff --git a/tests/Filesystem/ScopedFilesystemProxyTest.php b/tests/Filesystem/ScopedFilesystemProxyTest.php index 35108a013..c15b9f282 100644 --- a/tests/Filesystem/ScopedFilesystemProxyTest.php +++ b/tests/Filesystem/ScopedFilesystemProxyTest.php @@ -15,6 +15,7 @@ use Hypervel\Http\Request; use Hypervel\Http\Response; use Hypervel\Http\UploadedFile; +use Hypervel\Image\ImageException; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; use League\Flysystem\CorruptedPathDetected; @@ -259,6 +260,91 @@ public function testPutFileAsRejectsFinalTargetEscapeBeforeWriting(): void $this->assertTrue($this->disk->exists('tenant/safe.txt')); } + public function testImageCapturesTheNormalizedPrefixAndResolvedDiskBeforeLazyReading(): void + { + $readCalls = 0; + $first = m::mock(FilesystemContract::class); + $first->shouldReceive('get') + ->once() + ->with('tenants/first/photo.jpg') + ->andReturnUsing(function () use (&$readCalls): string { + ++$readCalls; + + return 'first image'; + }); + $second = m::mock(FilesystemContract::class); + $second->shouldNotReceive('get'); + $currentDisk = $first; + $currentPrefix = 'tenants/first/images/..'; + $diskCalls = 0; + $prefixCalls = 0; + $proxy = new ScopedFilesystemProxy( + function () use (&$currentDisk, &$diskCalls): FilesystemContract { + ++$diskCalls; + + return $currentDisk; + }, + function () use (&$currentPrefix, &$prefixCalls): string { + ++$prefixCalls; + + return $currentPrefix; + }, + ); + + $image = $proxy->image('photo.jpg'); + + $this->assertSame(1, $prefixCalls); + $this->assertSame(1, $diskCalls); + $this->assertSame(0, $readCalls); + + $currentDisk = $second; + $currentPrefix = 'tenants/second'; + + $this->assertSame('first image', $image->toBytes()); + $this->assertSame(1, $prefixCalls); + $this->assertSame(1, $diskCalls); + $this->assertSame(1, $readCalls); + } + + public function testImageFailsClosedForAnEmptyPrefixAtCreation(): void + { + $proxy = new ScopedFilesystemProxy($this->disk, static fn (): string => ''); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('returned an empty prefix'); + + $proxy->image('photo.jpg'); + } + + public function testImageHonorsExplicitRootPassthrough(): void + { + $this->disk->put('photo.jpg', 'root image'); + $proxy = new ScopedFilesystemProxy($this->disk, static fn (): string => '.', true); + + $image = $proxy->image('photo.jpg'); + + $this->assertSame('root image', $image->toBytes()); + } + + public function testMissingImageReportsOnlyTheCallerPath(): void + { + $inner = m::mock(FilesystemContract::class); + $inner->shouldReceive('get')->once()->with('secret-tenant/missing.jpg')->andReturnNull(); + $proxy = new ScopedFilesystemProxy($inner, static fn (): string => 'secret-tenant'); + $image = $proxy->image('missing.jpg'); + + try { + $image->toBytes(); + $this->fail('Expected the missing image read to fail.'); + } catch (ImageException $exception) { + $this->assertSame( + 'Unable to read image from path [missing.jpg].', + $exception->getMessage(), + ); + $this->assertStringNotContainsString('secret-tenant', $exception->getMessage()); + } + } + public function testNoPathMethodsDoNotResolveThePrefix(): void { $inner = m::mock(FilesystemAdapter::class); @@ -632,6 +718,27 @@ function () use ($inner, &$diskCalls): Cloud { $this->assertSame(1, $diskCalls); } + public function testCloudVariantInheritsLazyImageScoping(): void + { + $readCalls = 0; + $inner = m::mock(Cloud::class); + $inner->shouldReceive('get') + ->once() + ->with('tenant/photo.jpg') + ->andReturnUsing(function () use (&$readCalls): string { + ++$readCalls; + + return 'cloud image'; + }); + $proxy = new ScopedCloudFilesystemProxy($inner, static fn (): string => 'tenant'); + + $image = $proxy->image('photo.jpg'); + + $this->assertSame(0, $readCalls); + $this->assertSame('cloud image', $image->toBytes()); + $this->assertSame(1, $readCalls); + } + public function testCloudVariantRejectsANonCloudResolvedDisk(): void { $inner = m::mock(FilesystemContract::class); diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php index dde78b512..73089e504 100644 --- a/tests/Http/HttpRequestTest.php +++ b/tests/Http/HttpRequestTest.php @@ -8,6 +8,7 @@ use Carbon\Unit; use Hypervel\Http\Request; use Hypervel\Http\UploadedFile; +use Hypervel\Image\Image; use Hypervel\Routing\Route; use Hypervel\Session\Store; use Hypervel\Support\CarbonImmutable as Carbon; @@ -1293,6 +1294,27 @@ public function testFileMethod(): void $this->assertInstanceOf(SymfonyUploadedFile::class, $request->file('foo')); } + public function testImageMethod(): void + { + $files = [ + 'avatar' => [ + 'size' => 500, + 'name' => 'avatar.jpg', + 'tmp_name' => __FILE__, + 'type' => 'image/jpeg', + 'error' => null, + ], + ]; + $request = Request::create('/', 'GET', [], [], $files); + $this->assertInstanceOf(Image::class, $request->image('avatar')); + } + + public function testImageMethodReturnsNullForMissingKey(): void + { + $request = Request::create('/', 'GET', [], [], []); + $this->assertNull($request->image('avatar')); + } + public function testHasFileMethod(): void { $request = Request::create('/', 'GET', [], [], []); diff --git a/tests/Http/PackageMetadataTest.php b/tests/Http/PackageMetadataTest.php index 512bce2ff..58ce9f7a2 100644 --- a/tests/Http/PackageMetadataTest.php +++ b/tests/Http/PackageMetadataTest.php @@ -10,11 +10,11 @@ class PackageMetadataTest extends TestCase { /** - * Ensure HTTP extension dependencies are declared consistently. + * Ensure HTTP runtime and optional dependencies are declared consistently. * * @throws JsonException */ - public function testExtensionDependenciesAreDeclared(): void + public function testRuntimeAndOptionalDependenciesAreDeclared(): void { $composer = json_decode( file_get_contents(__DIR__ . '/../../src/http/composer.json'), @@ -40,5 +40,8 @@ public function testExtensionDependenciesAreDeclared(): void 'Required to use Hypervel\Http\Testing\FileFactory::image().', $composer['suggest']['ext-gd'] ); + $this->assertArrayHasKey('hypervel/image', $composer['suggest']); + $this->assertIsString($composer['suggest']['hypervel/image']); + $this->assertNotSame('', trim($composer['suggest']['hypervel/image'])); } } diff --git a/tests/Image/CoroutineSafetyTest.php b/tests/Image/CoroutineSafetyTest.php new file mode 100644 index 000000000..71ef6b331 --- /dev/null +++ b/tests/Image/CoroutineSafetyTest.php @@ -0,0 +1,157 @@ +using('first'); + $second = $image->using('second'); + + $results = parallel([ + 'first' => static fn (): string => $first->toBytes(), + 'second' => static fn (): string => $second->toBytes(), + ]); + + $this->assertSame('shared image', $results['first']); + $this->assertSame('shared image', $results['second']); + $this->assertSame(1, $resolutionCalls); + } + + public function testClonedImagesReceiveTheOriginalTerminalSourceException(): void + { + $resolutionCalls = 0; + $terminalException = new RuntimeException('Source failed.'); + $image = new Image(function () use (&$resolutionCalls, $terminalException): never { + ++$resolutionCalls; + usleep(5000); + + throw $terminalException; + }); + $first = $image->using('first'); + $second = $image->using('second'); + + $results = parallel([ + 'first' => static function () use ($first): Throwable { + try { + $first->toBytes(); + } catch (Throwable $exception) { + return $exception; + } + + throw new RuntimeException('Expected source resolution to fail.'); + }, + 'second' => static function () use ($second): Throwable { + try { + $second->toBytes(); + } catch (Throwable $exception) { + return $exception; + } + + throw new RuntimeException('Expected source resolution to fail.'); + }, + ]); + + $this->assertSame($terminalException, $results['first']); + $this->assertSame($terminalException, $results['second']); + $this->assertSame(1, $resolutionCalls); + } + + public function testSingletonDriverDoesNotMixConcurrentImageOperations(): void + { + $container = new Container; + $container->instance('config', new Repository([ + 'images' => ['default' => 'interleaving'], + ])); + $driver = new InterleavingImageDriver; + $manager = new ImageManager($container); + $manager->extend('interleaving', static fn (): Driver => $driver); + $container->instance('image', $manager); + Container::setInstance($container); + + $first = (new Image('first'))->toPng(); + $second = (new Image('second'))->toWebp(); + + $results = parallel([ + 'first' => static fn (): string => $first->toBytes(), + 'second' => static fn (): string => $second->toBytes(), + ]); + + $this->assertSame('first:png', $results['first']); + $this->assertSame('second:webp', $results['second']); + $this->assertSame($driver, $manager->driver()); + } +} + +class InterleavingImageDriver implements Driver +{ + /** + * The registered transformation handlers. + * + * @var array, callable> + */ + private array $transformationHandlers = []; + + /** + * Process the image contents. + */ + public function process(string $contents, ImagePipeline $pipeline): string + { + usleep(5000); + + return $contents . ':' . $pipeline->output->format; + } + + /** + * Get the image dimensions. + * + * @return array{0: int, 1: int} + */ + public function dimensions(string $contents): array + { + return [1, 1]; + } + + /** + * Get the dominant image color. + */ + public function dominantColor(string $contents): string + { + return '#000000'; + } + + /** + * Register a transformation handler. + * + * @param class-string $transformation + */ + public function transformUsing(string $transformation, callable $callback): static + { + $this->transformationHandlers[$transformation] = $callback; + + return $this; + } +} diff --git a/tests/Image/Drivers/GdDriverTest.php b/tests/Image/Drivers/GdDriverTest.php new file mode 100644 index 000000000..9a4938db3 --- /dev/null +++ b/tests/Image/Drivers/GdDriverTest.php @@ -0,0 +1,538 @@ +fakeImageContents(200, 200); + + $pipeline = $this->pipeline(new Cover(100, 50)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(50, $height); + } + + #[RequiresFunction('imagewebp')] + public function testProcessesOptimizeToWebp(): void + { + $driver = new GdDriver; + + $pipeline = $this->pipeline(format: 'webp'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_WEBP, getimagesizefromstring($result)[2]); + } + + public function testProcessesOptimizeToJpeg(): void + { + $driver = new GdDriver; + + $pipeline = $this->pipeline(format: 'jpg'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_JPEG, getimagesizefromstring($result)[2]); + } + + public function testProcessesOptimizeToPng(): void + { + $driver = new GdDriver; + + $pipeline = $this->pipeline(format: 'png'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_PNG, getimagesizefromstring($result)[2]); + } + + public function testProcessesOptimizeToGif(): void + { + $driver = new GdDriver; + + $pipeline = $this->pipeline(format: 'gif'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_GIF, getimagesizefromstring($result)[2]); + } + + #[RequiresFunction('imageavif')] + public function testProcessesOptimizeToAvif(): void + { + $driver = new GdDriver; + + $pipeline = $this->pipeline(format: 'avif'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_AVIF, getimagesizefromstring($result)[2]); + } + + #[RequiresFunction('imageavif')] + public function testProcessesAvifInput(): void + { + $driver = new GdDriver; + $contents = $driver->process($this->fakeImageContents(), $this->pipeline(format: 'avif')); + + $result = $driver->process($contents, $this->pipeline(new Cover(50, 25), format: 'jpg')); + + $this->assertSame([50, 25], array_slice(getimagesizefromstring($result), 0, 2)); + } + + public function testProcessesOptimizeToBmp(): void + { + $driver = new GdDriver; + + $pipeline = $this->pipeline(format: 'bmp'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_BMP, getimagesizefromstring($result)[2]); + } + + #[RequiresFunction('imagewebp')] + public function testProcessesCoverAndOptimizeTogether(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(300, 300); + + $pipeline = $this->pipeline(new Cover(75, 75), format: 'webp'); + + $result = $driver->process($contents, $pipeline); + + [$width, $height, $type] = getimagesizefromstring($result); + + $this->assertSame(75, $width); + $this->assertSame(75, $height); + $this->assertSame(IMAGETYPE_WEBP, $type); + } + + public function testProcessesContain(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Contain(200, 200, '#ffffff')); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(200, $height); + } + + public function testProcessesContainWithDominantBackground(): void + { + $driver = new GdDriver; + $contents = $this->solidColorImageContents(255, 0, 0, 400, 200); + + $pipeline = $this->pipeline(new Contain(200, 200, 'dominant')); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(200, $height); + } + + public function testDominantColorReturnsHexForSolidImage(): void + { + $driver = new GdDriver; + $contents = $this->solidColorImageContents(0, 128, 255); + + $this->assertSame('#0080ff', $driver->dominantColor($contents)); + } + + public function testDominantColorIgnoresAlphaChannel(): void + { + $driver = new GdDriver; + $contents = $this->semiTransparentColorImageContents(0, 128, 255, 128); + + $this->assertSame('#0080ff', $driver->dominantColor($contents)); + } + + public function testProcessesCrop(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Crop(100, 50, 10, 20)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(50, $height); + } + + public function testProcessesResize(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Resize(200, 200)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(200, $height); + } + + public function testProcessesRotate(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(100, 50); + + $pipeline = $this->pipeline(new Rotate(90)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(50, $width); + $this->assertSame(100, $height); + } + + public function testProcessesRotateWithDominantBackground(): void + { + $driver = new GdDriver; + $contents = $this->solidColorImageContents(0, 255, 0, 100, 50); + + $pipeline = $this->pipeline(new Rotate(45, 'dominant')); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + $this->assertNotFalse(getimagesizefromstring($result)); + } + + public function testProcessesScale(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Scale(200, 200)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(100, $height); + } + + public function testProcessesScaleWidthOnly(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Scale(200, null)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(100, $height); + } + + public function testProcessesScaleHeightOnly(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Scale(null, 100)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(100, $height); + } + + public function testScaleDoesNotUpscale(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(100, 80); + + $pipeline = $this->pipeline(new Scale(800, 600)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(80, $height); + } + + #[RequiresFunction('imagewebp')] + public function testFormatConversionPreservesDimensions(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(300, 200); + + $pipeline = $this->pipeline(format: 'webp'); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(300, $width); + $this->assertSame(200, $height); + } + + public function testQualityPreservesDimensions(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(300, 200); + + $pipeline = $this->pipeline(quality: 50); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(300, $width); + $this->assertSame(200, $height); + } + + public function testProcessesOrient(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new Orient); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(100, $height); + } + + public function testProcessesBlur(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new Blur(10)); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + $this->assertNotSame($contents, $result); + } + + public function testProcessesGrayscale(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new Grayscale); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + $this->assertNotSame($contents, $result); + } + + public function testProcessesSharpen(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new Sharpen(10)); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + $this->assertNotSame($contents, $result); + } + + public function testProcessesFlipVertically(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new FlipVertically); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + } + + public function testProcessesFlipHorizontally(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new FlipHorizontally); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + } + + public function testProcessesCustomTransformation(): void + { + $driver = new GdDriver; + $transformation = new readonly class implements Transformation { + }; + $received = null; + + $driver->transformUsing($transformation::class, function (ImageInterface $image, Transformation $transformation) use (&$received) { + $received = $transformation; + + return $image->scaleDown(50, 50); + }); + + $result = $driver->process($this->fakeImageContents(100, 100), $this->pipeline($transformation)); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame($transformation, $received); + $this->assertSame(50, $width); + $this->assertSame(50, $height); + } + + public function testThrowsForUnsupportedInputFormat(): void + { + $driver = new GdDriver; + + $this->expectExceptionObject(new ImageException('The image format [text/plain] is not supported.')); + + $driver->process('not-an-image', new ImagePipeline); + } + + public function testReturnsImageWithoutOptions(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(100, 100); + + $result = $driver->process($contents, new ImagePipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(100, $height); + } + + public function testQualityAffectsFileSize(): void + { + $driver = new GdDriver; + $contents = $this->fakeImageContents(100, 100); + + $lowQuality = $this->pipeline(format: 'jpg', quality: 1); + $highQuality = $this->pipeline(format: 'jpg', quality: 100); + + $lowResult = $driver->process($contents, $lowQuality); + $highResult = $driver->process($contents, $highQuality); + + $this->assertLessThan(strlen($highResult), strlen($lowResult)); + } + + public function testEnsureRequirementsPasses(): void + { + $driver = new GdDriver; + + $driver->ensureRequirementsAreMet(); + + $this->assertTrue(true); + } + + public function testDimensionsReturnsTheDecodedSize(): void + { + $driver = new GdDriver; + $contents = $driver->process($this->fakeImageContents(320, 240), $this->pipeline(new Cover(200, 150), format: 'png')); + + $this->assertSame([200, 150], $driver->dimensions($contents)); + } + + protected function fakeImageContents(int $width = 100, int $height = 100): string + { + $file = UploadedFile::fake()->image('test.jpg', $width, $height); + + return file_get_contents($file->getRealPath()); + } + + protected function solidColorImageContents(int $red, int $green, int $blue, int $width = 100, int $height = 100): string + { + $image = imagecreatetruecolor($width, $height); + $color = imagecolorallocate($image, $red, $green, $blue); + imagefill($image, 0, 0, $color); + + ob_start(); + imagepng($image); + + return ob_get_clean(); + } + + protected function semiTransparentColorImageContents(int $red, int $green, int $blue, int $alpha, int $width = 100, int $height = 100): string + { + $image = imagecreatetruecolor($width, $height); + imagesavealpha($image, true); + // GD alpha runs 0 (opaque) to 127 (fully transparent), the inverse of a 0-255 alpha channel. + $gdAlpha = (int) round((255 - $alpha) / 255 * 127); + $color = imagecolorallocatealpha($image, $red, $green, $blue, $gdAlpha); + imagefill($image, 0, 0, $color); + + ob_start(); + imagepng($image); + + return ob_get_clean(); + } + + protected function pipeline(?Transformation $transformation = null, ?string $format = null, ?int $quality = null): ImagePipeline + { + $pipeline = new ImagePipeline; + + if ($transformation !== null) { + $pipeline->add($transformation); + } + + $pipeline->output->format = $format; + $pipeline->output->quality = $quality; + + return $pipeline; + } +} diff --git a/tests/Image/Drivers/ImagickDriverTest.php b/tests/Image/Drivers/ImagickDriverTest.php new file mode 100644 index 000000000..b8d5cb0f6 --- /dev/null +++ b/tests/Image/Drivers/ImagickDriverTest.php @@ -0,0 +1,646 @@ +fakeImageContents(200, 200); + + $pipeline = $this->pipeline(new Cover(100, 50)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(50, $height); + } + + public function testProcessesOptimizeToWebp(): void + { + $this->ensureImageFormatCanBeEncoded('webp'); + + $driver = new ImagickDriver; + + $pipeline = $this->pipeline(format: 'webp'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_WEBP, getimagesizefromstring($result)[2]); + } + + public function testProcessesOptimizeToJpeg(): void + { + $driver = new ImagickDriver; + + $pipeline = $this->pipeline(format: 'jpg'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_JPEG, getimagesizefromstring($result)[2]); + } + + public function testProcessesOptimizeToPng(): void + { + $driver = new ImagickDriver; + + $pipeline = $this->pipeline(format: 'png'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_PNG, getimagesizefromstring($result)[2]); + } + + public function testProcessesOptimizeToGif(): void + { + $driver = new ImagickDriver; + + $pipeline = $this->pipeline(format: 'gif'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_GIF, getimagesizefromstring($result)[2]); + } + + public function testProcessesOptimizeToAvif(): void + { + $this->ensureImageFormatCanBeEncoded('avif'); + + $driver = new ImagickDriver; + + $pipeline = $this->pipeline(format: 'avif'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + // PHP's getimagesizefromstring()/finfo AVIF detection varies by the + // libavif/libmagic versions installed, so we assert on the ISOBMFF + // "ftyp" box and brand directly instead of relying on either. + $this->assertStringContainsString('ftyp', substr($result, 0, 16)); + $this->assertMatchesRegularExpression('/avif|avis|mif1/', substr($result, 0, 32)); + } + + public function testProcessesAvifInput(): void + { + $this->ensureImageFormatCanBeEncoded('avif'); + + $driver = new ImagickDriver; + $contents = $driver->process($this->fakeImageContents(), $this->pipeline(format: 'avif')); + + $result = $driver->process($contents, $this->pipeline(new Cover(50, 25), format: 'jpg')); + + $this->assertSame([50, 25], array_slice(getimagesizefromstring($result), 0, 2)); + } + + public function testProcessesOptimizeToHeic(): void + { + $this->ensureImageFormatCanBeEncoded('heic'); + + $driver = new ImagickDriver; + + $result = $driver->process($this->fakeImageContents(), $this->pipeline(format: 'heic')); + + $this->assertStringContainsString('ftyp', substr($result, 0, 16)); + $this->assertMatchesRegularExpression('/heic|heix|hevc|hevx|mif1/', substr($result, 0, 32)); + } + + public function testProcessesHeicInput(): void + { + $this->ensureImageFormatCanBeEncoded('heic'); + + $driver = new ImagickDriver; + $contents = $driver->process($this->fakeImageContents(), $this->pipeline(format: 'heic')); + + $result = $driver->process($contents, $this->pipeline(new Cover(50, 25))); + + $this->assertStringContainsString('ftyp', substr($result, 0, 16)); + $this->assertMatchesRegularExpression('/heic|heix|hevc|hevx|mif1/', substr($result, 0, 32)); + + $result = $driver->process($result, $this->pipeline(format: 'jpg')); + + $this->assertSame([50, 25], array_slice(getimagesizefromstring($result), 0, 2)); + } + + public function testDimensionsReturnsTheDecodedSize(): void + { + $driver = new ImagickDriver; + $contents = $driver->process($this->fakeImageContents(320, 240), $this->pipeline(new Cover(200, 150), format: 'png')); + + $this->assertSame([200, 150], $driver->dimensions($contents)); + } + + public function testDimensionsReturnsTheDisplaySizeForHeic(): void + { + $this->ensureImageFormatCanBeEncoded('heic'); + + $driver = new ImagickDriver; + + // 137x73 is a size where HEIC's coded/padded frame differs from the display size, which + // getimagesize() misreports; the driver must return the true display size. + $contents = $driver->process($this->fakeImageContents(400, 300), $this->pipeline(new Cover(137, 73), format: 'heic')); + + $this->assertSame([137, 73], $driver->dimensions($contents)); + } + + public function testImageDimensionsAreCorrectForRealHeicThroughPublicApi(): void + { + $this->ensureImageFormatCanBeEncoded('heic'); + + // The public API must use the driver dimensions instead of HEIC's padded native frame size. + $heic = (new ImagickDriver)->process( + $this->fakeImageContents(400, 300), + $this->pipeline(new Cover(137, 73), format: 'heic') + ); + + $container = new Container; + $container->instance('config', new Repository(['images' => ['default' => 'imagick']])); + $container->instance('image', new ImageManager($container)); + Container::setInstance($container); + + try { + $this->assertSame([137, 73], (new Image($heic))->dimensions()); + } finally { + Container::setInstance(null); + } + } + + public function testProcessesOptimizeToBmp(): void + { + $driver = new ImagickDriver; + + $pipeline = $this->pipeline(format: 'bmp'); + + $result = $driver->process($this->fakeImageContents(), $pipeline); + + $this->assertSame(IMAGETYPE_BMP, getimagesizefromstring($result)[2]); + } + + public function testProcessesCoverAndOptimizeTogether(): void + { + $this->ensureImageFormatCanBeEncoded('webp'); + + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(300, 300); + + $pipeline = $this->pipeline(new Cover(75, 75), format: 'webp'); + + $result = $driver->process($contents, $pipeline); + + [$width, $height, $type] = getimagesizefromstring($result); + + $this->assertSame(75, $width); + $this->assertSame(75, $height); + $this->assertSame(IMAGETYPE_WEBP, $type); + } + + public function testProcessesContain(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Contain(200, 200, '#ffffff')); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(200, $height); + } + + public function testProcessesContainWithDominantBackground(): void + { + $driver = new ImagickDriver; + $contents = $this->solidColorImageContents(255, 0, 0, 400, 200); + + $pipeline = $this->pipeline(new Contain(200, 200, 'dominant')); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(200, $height); + } + + public function testDominantColorReturnsHexForSolidImage(): void + { + $driver = new ImagickDriver; + $contents = $this->solidColorImageContents(0, 128, 255); + + $this->assertSame('#0080ff', $driver->dominantColor($contents)); + } + + public function testDominantColorIgnoresAlphaChannel(): void + { + $driver = new ImagickDriver; + $contents = $this->semiTransparentColorImageContents(0, 128, 255, 128); + + $this->assertSame('#0080ff', $driver->dominantColor($contents)); + } + + public function testProcessesCrop(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Crop(100, 50, 10, 20)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(50, $height); + } + + public function testProcessesResize(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Resize(200, 200)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(200, $height); + } + + public function testProcessesRotate(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(100, 50); + + $pipeline = $this->pipeline(new Rotate(90)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(50, $width); + $this->assertSame(100, $height); + } + + public function testProcessesRotateWithDominantBackground(): void + { + $driver = new ImagickDriver; + $contents = $this->solidColorImageContents(0, 255, 0, 100, 50); + + $pipeline = $this->pipeline(new Rotate(45, 'dominant')); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + $this->assertNotFalse(getimagesizefromstring($result)); + } + + public function testProcessesScale(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Scale(200, 200)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(100, $height); + } + + public function testProcessesScaleWidthOnly(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Scale(200, null)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(100, $height); + } + + public function testProcessesScaleHeightOnly(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(400, 200); + + $pipeline = $this->pipeline(new Scale(null, 100)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(100, $height); + } + + public function testScaleDoesNotUpscale(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(100, 80); + + $pipeline = $this->pipeline(new Scale(800, 600)); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(80, $height); + } + + public function testFormatConversionPreservesDimensions(): void + { + $this->ensureImageFormatCanBeEncoded('webp'); + + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(300, 200); + + $pipeline = $this->pipeline(format: 'webp'); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(300, $width); + $this->assertSame(200, $height); + } + + public function testQualityPreservesDimensions(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(300, 200); + + $pipeline = $this->pipeline(quality: 50); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(300, $width); + $this->assertSame(200, $height); + } + + public function testProcessesOrient(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new Orient); + + $result = $driver->process($contents, $pipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(100, $height); + } + + public function testProcessesBlur(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new Blur(10)); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + $this->assertNotSame($contents, $result); + } + + public function testProcessesGrayscale(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new Grayscale); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + $this->assertNotSame($contents, $result); + } + + public function testProcessesSharpen(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new Sharpen(10)); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + $this->assertNotSame($contents, $result); + } + + public function testProcessesFlipVertically(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new FlipVertically); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + } + + public function testProcessesFlipHorizontally(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(100, 100); + + $pipeline = $this->pipeline(new FlipHorizontally); + + $result = $driver->process($contents, $pipeline); + + $this->assertNotEmpty($result); + } + + public function testProcessesCustomTransformation(): void + { + $driver = new ImagickDriver; + $transformation = new readonly class implements Transformation { + }; + $received = null; + + $driver->transformUsing($transformation::class, function (ImageInterface $image, Transformation $transformation) use (&$received) { + $received = $transformation; + + return $image->scaleDown(50, 50); + }); + + $result = $driver->process($this->fakeImageContents(100, 100), $this->pipeline($transformation)); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame($transformation, $received); + $this->assertSame(50, $width); + $this->assertSame(50, $height); + } + + public function testThrowsForUnsupportedInputFormat(): void + { + $driver = new ImagickDriver; + + $this->expectExceptionObject(new ImageException('The image format [text/plain] is not supported.')); + + $driver->process('not-an-image', new ImagePipeline); + } + + public function testReturnsImageWithoutOptions(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(100, 100); + + $result = $driver->process($contents, new ImagePipeline); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(100, $height); + } + + public function testQualityAffectsFileSize(): void + { + $driver = new ImagickDriver; + $contents = $this->fakeImageContents(100, 100); + + $lowQuality = $this->pipeline(format: 'jpg', quality: 1); + $highQuality = $this->pipeline(format: 'jpg', quality: 100); + + $lowResult = $driver->process($contents, $lowQuality); + $highResult = $driver->process($contents, $highQuality); + + $this->assertLessThan(strlen($highResult), strlen($lowResult)); + } + + public function testEnsureRequirementsPasses(): void + { + $driver = new ImagickDriver; + + $driver->ensureRequirementsAreMet(); + + $this->assertTrue(true); + } + + protected function fakeImageContents(int $width = 100, int $height = 100): string + { + $file = UploadedFile::fake()->image('test.jpg', $width, $height); + + return file_get_contents($file->getRealPath()); + } + + protected function ensureImageFormatCanBeEncoded(string $format): void + { + if (Imagick::queryFormats(strtoupper($format)) === []) { + $this->markTestSkipped("The Imagick extension was not compiled with {$format} support."); + } + + $imagick = null; + + try { + $imagick = new Imagick; + $imagick->newImage(1, 1, 'white'); + $imagick->setImageFormat($format); + $encoded = $imagick->getImageBlob(); + } catch (ImagickException) { + // Some builds ship the HEIC decode delegate but no encoder, in which case encoding + // raises instead of returning an empty blob. Either way, encoding is unavailable. + $encoded = ''; + } finally { + if ($imagick instanceof Imagick) { + $imagick->clear(); + $imagick->destroy(); + } + } + + if ($encoded === '') { + $this->markTestSkipped("The Imagick extension cannot encode {$format} images."); + } + } + + protected function solidColorImageContents(int $red, int $green, int $blue, int $width = 100, int $height = 100): string + { + $imagick = new Imagick; + $imagick->newImage($width, $height, new ImagickPixel(sprintf('rgb(%d,%d,%d)', $red, $green, $blue))); + $imagick->setImageAlphaChannel(Imagick::ALPHACHANNEL_OPAQUE); + $imagick->setImageFormat('png'); + + $contents = $imagick->getImageBlob(); + $imagick->clear(); + $imagick->destroy(); + + return $contents; + } + + protected function semiTransparentColorImageContents(int $red, int $green, int $blue, int $alpha, int $width = 100, int $height = 100): string + { + $imagick = new Imagick; + $imagick->newImage($width, $height, new ImagickPixel(sprintf('rgba(%d,%d,%d,%.2f)', $red, $green, $blue, $alpha / 255))); + $imagick->setImageFormat('png'); + + $contents = $imagick->getImageBlob(); + $imagick->clear(); + $imagick->destroy(); + + return $contents; + } + + protected function pipeline(?Transformation $transformation = null, ?string $format = null, ?int $quality = null): ImagePipeline + { + $pipeline = new ImagePipeline; + + if ($transformation !== null) { + $pipeline->add($transformation); + } + + $pipeline->output->format = $format; + $pipeline->output->quality = $quality; + + return $pipeline; + } +} diff --git a/tests/Image/Drivers/InterventionDriverTest.php b/tests/Image/Drivers/InterventionDriverTest.php new file mode 100644 index 000000000..50203334b --- /dev/null +++ b/tests/Image/Drivers/InterventionDriverTest.php @@ -0,0 +1,107 @@ +fail('Expected the driver requirement check to fail.'); + } catch (ImageException $exception) { + $this->assertSame('Missing image dependency.', $exception->getMessage()); + } + + $this->assertFalse($managerCreated); + } + + public function testTransformationHandlersPersistOnTheDriver(): void + { + $driver = new InspectableInterventionDriver(m::mock(ImageManagerInterface::class)); + $callback = static function (): void { + }; + + $this->assertSame( + $driver, + $driver->transformUsing(InterventionDriverTestTransformation::class, $callback), + ); + $this->assertSame($callback, $driver->handlerFor(new InterventionDriverTestTransformation)); + $this->assertSame($callback, $driver->handlerFor(new InterventionDriverTestTransformation)); + } +} + +class FailingRequirementsInterventionDriver extends InterventionDriver +{ + /** + * Create a driver with a manager-creation recorder. + */ + public function __construct(private Closure $managerRecorder) + { + parent::__construct(); + } + + /** + * Fail the dependency requirement check. + */ + public function ensureRequirementsAreMet(): never + { + throw new ImageException('Missing image dependency.'); + } + + /** + * Record an attempted manager creation. + */ + protected function createManager(): ImageManagerInterface + { + ($this->managerRecorder)(); + + throw new RuntimeException('The image manager must not be created.'); + } +} + +class InspectableInterventionDriver extends InterventionDriver +{ + /** + * Create a driver with the given image manager. + */ + public function __construct(private ImageManagerInterface $testManager) + { + parent::__construct(); + } + + /** + * Create the underlying image manager. + */ + protected function createManager(): ImageManagerInterface + { + return $this->testManager; + } + + /** + * Get the registered handler for a transformation. + */ + public function handlerFor(Transformation $transformation): ?callable + { + return $this->transformationHandlerFor($transformation); + } +} + +readonly class InterventionDriverTestTransformation implements Transformation +{ +} diff --git a/tests/Image/ImageManagerTest.php b/tests/Image/ImageManagerTest.php new file mode 100644 index 000000000..6aa9a2be3 --- /dev/null +++ b/tests/Image/ImageManagerTest.php @@ -0,0 +1,593 @@ +makeApp(['images.default' => 'imagick']); + + $manager = new ImageManager($app); + + $this->assertSame('imagick', $manager->getDefaultDriver()); + } + + public function testExtendRegistersCustomDriver(): void + { + $app = $this->makeApp(['images.default' => 'custom']); + + $mockDriver = m::mock(Driver::class); + + $manager = new ImageManager($app); + $manager->extend('custom', function ($app) use ($mockDriver) { + return $mockDriver; + }); + + $this->assertSame($mockDriver, $manager->driver('custom')); + } + + public function testDriverCachesResolvedInstances(): void + { + $app = $this->makeApp([]); + + $mockDriver = m::mock(Driver::class); + + $manager = new ImageManager($app); + $manager->extend('custom', function () use ($mockDriver) { + return $mockDriver; + }); + + $first = $manager->driver('custom'); + $second = $manager->driver('custom'); + + $this->assertSame($first, $second); + } + + public function testThrowsForUnsupportedDriver(): void + { + $app = $this->makeApp([]); + + $manager = new ImageManager($app); + + $this->expectExceptionObject(new InvalidArgumentException('Image driver [nonexistent] is not supported.')); + + $manager->driver('nonexistent'); + } + + public function testFromBytesReturnsImageWithContents(): void + { + $app = $this->makeApp([]); + $manager = new ImageManager($app); + + $contents = $this->fakeImageContents(); + $image = $manager->fromBytes($contents); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame($contents, $image->toBytes()); + } + + public function testFromPathReturnsImageFromFilePath(): void + { + $file = UploadedFile::fake()->image('test.jpg', 100, 100); + $path = $file->getRealPath(); + + $filesystem = m::mock(Filesystem::class); + $filesystem->expects('get') + ->with($path) + ->andReturn(file_get_contents($path)); + + $app = $this->makeApp([]); + $app->expects('make') + ->with(Filesystem::class) + ->andReturn($filesystem); + + $manager = new ImageManager($app); + $image = $manager->fromPath($path); + + $this->assertInstanceOf(Image::class, $image); + $this->assertNotEmpty($image->toBytes()); + } + + public function testFromPathIsLazy(): void + { + $app = $this->makeApp([]); + $app->shouldNotReceive('make')->with(Filesystem::class); + + $manager = new ImageManager($app); + $image = $manager->fromPath('/some/path.jpg'); + + $this->assertInstanceOf(Image::class, $image); + } + + public function testFromStorageReturnsImageFromStorageDiskPath(): void + { + $contents = $this->fakeImageContents(); + + $disk = m::mock(FilesystemContract::class); + $disk->expects('get') + ->with('images/avatar.jpg') + ->andReturn($contents); + + $filesystem = m::mock(FilesystemFactory::class); + $filesystem->expects('disk') + ->with('public') + ->andReturn($disk); + + $app = $this->makeApp([]); + $app->expects('make') + ->with(FilesystemFactory::class) + ->andReturn($filesystem); + + $manager = new ImageManager($app); + $image = $manager->fromStorage('images/avatar.jpg', 'public'); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame($contents, $image->toBytes()); + } + + public function testFromStorageAcceptsBackedEnumDisk(): void + { + $contents = $this->fakeImageContents(); + + $disk = m::mock(FilesystemContract::class); + $disk->expects('get') + ->with('images/avatar.jpg') + ->andReturn($contents); + + $filesystem = m::mock(FilesystemFactory::class); + $filesystem->expects('disk') + ->with(ImageDiskStub::Public) + ->andReturn($disk); + + $app = $this->makeApp([]); + $app->expects('make') + ->with(FilesystemFactory::class) + ->andReturn($filesystem); + + $manager = new ImageManager($app); + $image = $manager->fromStorage('images/avatar.jpg', ImageDiskStub::Public); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame($contents, $image->toBytes()); + } + + public function testFromStorageAcceptsUnitEnumDisk(): void + { + $contents = $this->fakeImageContents(); + + $disk = m::mock(FilesystemContract::class); + $disk->expects('get') + ->with('images/avatar.jpg') + ->andReturn($contents); + + $filesystem = m::mock(FilesystemFactory::class); + $filesystem->expects('disk') + ->with(ImageUnitDiskStub::public) + ->andReturn($disk); + + $app = $this->makeApp([]); + $app->expects('make') + ->with(FilesystemFactory::class) + ->andReturn($filesystem); + + $manager = new ImageManager($app); + $image = $manager->fromStorage('images/avatar.jpg', ImageUnitDiskStub::public); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame($contents, $image->toBytes()); + } + + public function testFromStorageReportsAMissingPath(): void + { + $disk = m::mock(FilesystemContract::class); + $disk->expects('get')->with('images/missing.jpg')->andReturnNull(); + + $filesystem = m::mock(FilesystemFactory::class); + $filesystem->expects('disk')->with('public')->andReturn($disk); + + $app = $this->makeApp([]); + $app->expects('make')->with(FilesystemFactory::class)->andReturn($filesystem); + + $manager = new ImageManager($app); + $image = $manager->fromStorage('images/missing.jpg', 'public'); + + $this->expectException(ImageException::class); + $this->expectExceptionMessage('Unable to read image from path [images/missing.jpg].'); + + $image->toBytes(); + } + + public function testFromStorageIsLazy(): void + { + $app = $this->makeApp([]); + $app->shouldNotReceive('make')->with(FilesystemFactory::class); + + $manager = new ImageManager($app); + $image = $manager->fromStorage('images/avatar.jpg', 'public'); + + $this->assertInstanceOf(Image::class, $image); + } + + public function testFromStreamReturnsImage(): void + { + $contents = $this->fakeImageContents(); + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $contents); + rewind($stream); + + $app = $this->makeApp([]); + $manager = new ImageManager($app); + $image = $manager->fromStream($stream); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame($contents, $image->toBytes()); + + fclose($stream); + } + + public function testFromStreamIsLazy(): void + { + $contents = $this->fakeImageContents(); + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $contents); + rewind($stream); + + $app = $this->makeApp([]); + $manager = new ImageManager($app); + $image = $manager->fromStream($stream); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame(0, ftell($stream)); + + fclose($stream); + } + + public function testFromStreamThrowsForInvalidData(): void + { + $stream = fopen('php://memory', 'r+'); + + $app = $this->makeApp([]); + $manager = new ImageManager($app); + + $this->expectExceptionObject(new ImageException('Invalid stream image data.')); + + try { + $manager->fromStream($stream)->toBytes(); + } finally { + fclose($stream); + } + } + + public function testFromStreamAcceptsTheNonEmptyStringZero(): void + { + $stream = fopen('php://memory', 'r+'); + fwrite($stream, '0'); + rewind($stream); + + try { + $manager = new ImageManager($this->makeApp([])); + + $this->assertSame('0', $manager->fromStream($stream)->toBytes()); + } finally { + fclose($stream); + } + } + + public function testFromUploadReturnsImageFromUploadedFile(): void + { + $file = UploadedFile::fake()->image('avatar.jpg', 100, 100); + + $app = $this->makeApp([]); + $manager = new ImageManager($app); + $image = $manager->fromUpload($file); + + $this->assertInstanceOf(Image::class, $image); + $this->assertStringEqualsFile($file->getRealPath(), $image->toBytes()); + $this->assertSame($file, $image->file()); + } + + public function testFromUrlReturnsImage(): void + { + $contents = $this->fakeImageContents(); + + $http = m::mock(HttpFactory::class); + $response = m::mock(ClientResponse::class); + $response->expects('throw')->once()->andReturnSelf(); + $response->expects('body')->andReturn($contents); + $http->expects('get')->with('https://example.com/photo.jpg')->andReturn($response); + + $app = $this->makeApp([]); + $app->expects('make') + ->with(HttpFactory::class) + ->andReturn($http); + + $manager = new ImageManager($app); + $image = $manager->fromUrl('https://example.com/photo.jpg'); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame($contents, $image->toBytes()); + } + + public function testFromUrlIsLazy(): void + { + $app = $this->makeApp([]); + $app->shouldNotReceive('make')->with(HttpFactory::class); + + $manager = new ImageManager($app); + $image = $manager->fromUrl('https://example.com/photo.jpg'); + + $this->assertInstanceOf(Image::class, $image); + } + + public function testFromUrlResolvesOnceAcrossSequentialVariants(): void + { + $http = m::mock(HttpFactory::class); + $response = m::mock(ClientResponse::class); + $response->expects('throw')->once()->andReturnSelf(); + $response->expects('body')->once()->andReturn('shared image'); + $http->expects('get')->once()->with('https://example.com/photo.jpg')->andReturn($response); + + $app = $this->makeApp([]); + $app->expects('make')->once()->with(HttpFactory::class)->andReturn($http); + + $manager = new ImageManager($app); + $image = $manager->fromUrl('https://example.com/photo.jpg'); + $first = $image->using('first'); + $second = $image->using('second'); + + $this->assertSame('shared image', $first->toBytes()); + $this->assertSame('shared image', $second->toBytes()); + } + + #[DataProvider('httpErrorStatusProvider')] + public function testFromUrlRejectsClientAndServerErrors(int $status): void + { + $response = new ClientResponse(new Psr7Response($status, [], 'Request Failed')); + + $http = m::mock(HttpFactory::class); + $http->expects('get')->once()->with('https://example.com/missing.jpg')->andReturn($response); + + $app = $this->makeApp([]); + $app->expects('make')->once()->with(HttpFactory::class)->andReturn($http); + + $image = (new ImageManager($app))->fromUrl('https://example.com/missing.jpg'); + + $this->expectException(RequestException::class); + $this->expectExceptionCode($status); + + $image->toBytes(); + } + + public static function httpErrorStatusProvider(): array + { + return [[404], [500]]; + } + + public function testFromBase64ReturnsImage(): void + { + $contents = $this->fakeImageContents(); + $base64 = base64_encode($contents); + + $app = $this->makeApp([]); + $manager = new ImageManager($app); + + $image = $manager->fromBase64($base64); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame($contents, $image->toBytes()); + } + + public function testFromBase64ThrowsForInvalidData(): void + { + $app = $this->makeApp([]); + $manager = new ImageManager($app); + + $this->expectExceptionObject(new ImageException('Invalid base64 image data.')); + + $manager->fromBase64('!!!not-base64!!!')->toBytes(); + } + + public function testFromBase64ThrowsForEmptyData(): void + { + $manager = new ImageManager($this->makeApp([])); + + $this->expectExceptionObject(new ImageException('Invalid base64 image data.')); + + $manager->fromBase64('')->toBytes(); + } + + public function testFromBase64AcceptsTheNonEmptyStringZero(): void + { + $manager = new ImageManager($this->makeApp([])); + + $this->assertSame('0', $manager->fromBase64(base64_encode('0'))->toBytes()); + } + + public function testExtendOverwritesPreviousRegistration(): void + { + $app = $this->makeApp([]); + + $firstDriver = m::mock(Driver::class); + $secondDriver = m::mock(Driver::class); + + $manager = new ImageManager($app); + $manager->extend('custom', fn () => $firstDriver); + $manager->extend('custom', fn () => $secondDriver); + + $this->assertSame($secondDriver, $manager->driver('custom')); + } + + public function testDriverCachesSeparatelyByName(): void + { + $app = $this->makeApp([]); + + $driver1 = m::mock(Driver::class); + $driver2 = m::mock(Driver::class); + + $manager = new ImageManager($app); + $manager->extend('one', fn () => $driver1); + $manager->extend('two', fn () => $driver2); + + $this->assertSame($driver1, $manager->driver('one')); + $this->assertSame($driver2, $manager->driver('two')); + $this->assertNotSame($manager->driver('one'), $manager->driver('two')); + } + + public function testCustomBackendProcessesWithoutInterventionInheritance(): void + { + $container = new Container; + $container->instance('config', new Repository(['images.default' => 'custom'])); + $driver = m::mock(Driver::class); + $driver->expects('process') + ->with( + 'source image', + m::on(static fn (ImagePipeline $pipeline): bool => $pipeline->output->format === 'png'), + ) + ->andReturn('custom image'); + + $manager = new ImageManager($container); + $manager->extend('custom', static fn (): Driver => $driver); + $container->instance('image', $manager); + Container::setInstance($container); + + try { + $this->assertSame('custom image', $manager->fromBytes('source image')->toPng()->toBytes()); + $this->assertNotInstanceOf(InterventionDriver::class, $manager->driver()); + } finally { + Container::setInstance(null); + } + } + + public function testTransformUsingAppliesHandlersToNewDriverInstances(): void + { + $app = $this->makeApp([]); + $driver = new class implements Driver { + public array $handlers = []; + + public function process(string $contents, ImagePipeline $pipeline): string + { + return $contents; + } + + public function dominantColor(string $contents): string + { + return '#000000'; + } + + public function dimensions(string $contents): array + { + return [0, 0]; + } + + public function transformUsing(string $transformation, callable $callback): static + { + $this->handlers[$transformation] = $callback; + + return $this; + } + }; + $transformation = new readonly class implements Transformation { + }; + $callback = fn () => null; + + $manager = new ImageManager($app); + $manager->extend('custom', fn () => $driver); + $manager->transformUsing('custom', $transformation::class, $callback); + + $this->assertSame($callback, $manager->driver('custom')->handlers[$transformation::class]); + } + + public function testTransformUsingAppliesHandlersToResolvedDriverInstances(): void + { + $app = $this->makeApp([]); + $driver = new class implements Driver { + public array $handlers = []; + + public function process(string $contents, ImagePipeline $pipeline): string + { + return $contents; + } + + public function dominantColor(string $contents): string + { + return '#000000'; + } + + public function dimensions(string $contents): array + { + return [0, 0]; + } + + public function transformUsing(string $transformation, callable $callback): static + { + $this->handlers[$transformation] = $callback; + + return $this; + } + }; + $transformation = new readonly class implements Transformation { + }; + $callback = fn () => null; + + $manager = new ImageManager($app); + $manager->extend('custom', fn () => $driver); + $manager->driver('custom'); + $manager->transformUsing('custom', $transformation::class, $callback); + + $this->assertSame($callback, $driver->handlers[$transformation::class]); + } + + protected function fakeImageContents(): string + { + $file = UploadedFile::fake()->image('test.jpg', 100, 100); + + return file_get_contents($file->getRealPath()); + } + + protected function makeApp(array $config): Application + { + $app = m::mock(Application::class); + + $configRepo = new Repository($config); + + $app->shouldReceive('make')->with('config')->andReturn($configRepo)->byDefault(); + + return $app; + } +} + +enum ImageDiskStub: string +{ + case Public = 'public'; +} + +enum ImageUnitDiskStub +{ + case public; +} diff --git a/tests/Image/ImageServiceProviderTest.php b/tests/Image/ImageServiceProviderTest.php new file mode 100644 index 000000000..fda47ab56 --- /dev/null +++ b/tests/Image/ImageServiceProviderTest.php @@ -0,0 +1,50 @@ +assertSame($expected, $this->app->make('config')->array('images')); + } + + public function testPublishesConfiguration(): void + { + $this->assertSame([ + dirname(__DIR__, 2) . '/src/image/config/images.php' => config_path('images.php'), + ], ServiceProvider::pathsToPublish(ImageServiceProvider::class, 'image-config')); + } + + #[RequiresPhpExtension('gd')] + #[WithConfig('images.default', 'gd')] + public function testCanonicalAliasSharesTheWorkerLifetimeManagerAndDriver(): void + { + $manager = $this->app->make('image'); + $driver = $manager->driver(); + + $this->assertInstanceOf(ImageManager::class, $manager); + $this->assertInstanceOf(GdDriver::class, $driver); + $this->assertSame($manager, $this->app->make(ImageManager::class)); + $this->assertSame($manager, $this->app->make('image')); + $this->assertSame($driver, $this->app->make('image')->driver()); + } +} diff --git a/tests/Image/ImageTest.php b/tests/Image/ImageTest.php new file mode 100644 index 000000000..81b48bb59 --- /dev/null +++ b/tests/Image/ImageTest.php @@ -0,0 +1,1596 @@ +makeImage(); + $result = $image->cover(100, 200); + + $this->assertNotSame($image, $result); + } + + public function testScaleReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->scale(800, 600); + + $this->assertNotSame($image, $result); + } + + public function testContainReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->contain(800, 600); + + $this->assertNotSame($image, $result); + } + + public function testCropReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->crop(100, 100); + + $this->assertNotSame($image, $result); + } + + public function testResizeReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->resize(800, 600); + + $this->assertNotSame($image, $result); + } + + public function testRotateReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->rotate(90); + + $this->assertNotSame($image, $result); + } + + public function testOrientReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->orient(); + + $this->assertNotSame($image, $result); + } + + public function testBlurReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->blur(10); + + $this->assertNotSame($image, $result); + } + + public function testGrayscaleReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->grayscale(); + + $this->assertNotSame($image, $result); + } + + public function testOptimizeReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->optimize('webp'); + + $this->assertNotSame($image, $result); + } + + public function testQualityReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->quality(80); + + $this->assertNotSame($image, $result); + } + + public function testToWebpReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->toWebp()); + } + + public function testToJpgReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->toJpg()); + } + + public function testToPngReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->toPng()); + } + + public function testToGifReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->toGif()); + } + + public function testToAvifReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->toAvif()); + } + + public function testToHeicReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->toHeic()); + } + + public function testToBmpReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->toBmp()); + } + + public function testUsingReturnsNewInstance(): void + { + $image = $this->makeImage(); + $result = $image->using('imagick'); + + $this->assertNotSame($image, $result); + } + + public function testOriginalIsNotMutated(): void + { + $image = $this->makeImage(); + $originalOptions = clone $this->getOptions($image); + + $image->cover(100, 100)->optimize('webp'); + + $this->assertEquals($originalOptions, $this->getOptions($image)); + } + + public function testChainedOperationsAccumulate(): void + { + $image = $this->makeImage(); + $result = $image->cover(100, 100)->optimize('webp', 90)->blur(5); + + $options = $this->getOptions($result); + + $this->assertSame(100, $options->coverWidth); + $this->assertSame(100, $options->coverHeight); + $this->assertSame('webp', $options->format); + $this->assertSame(90, $options->quality); + $this->assertSame(5, $options->blur); + } + + public function testVariantsFromSameSourceAreIndependent(): void + { + $image = $this->makeImage(); + + $thumb = $image->cover(100, 100); + $large = $image->scale(800, 600); + + $thumbOptions = $this->getOptions($thumb); + $largeOptions = $this->getOptions($large); + + $this->assertSame(100, $thumbOptions->coverWidth); + $this->assertNull($thumbOptions->scaleWidth); + + $this->assertNull($largeOptions->coverWidth); + $this->assertSame(800, $largeOptions->scaleWidth); + } + + public function testToBytesReturnsString(): void + { + $contents = $this->fakeImageContents(); + $image = new Image($contents); + + $this->assertSame($contents, $image->toBytes()); + } + + public function testToBytesWithClosure(): void + { + $contents = $this->fakeImageContents(); + $image = new Image(fn () => $contents); + + $this->assertSame($contents, $image->toBytes()); + } + + public function testClosureIsNotCalledUntilToBytes(): void + { + $called = false; + + $image = new Image(function () use (&$called) { + $called = true; + + return $this->fakeImageContents(); + }); + + $this->assertFalse($called); + + $image->toBytes(); + + $this->assertTrue($called); + } + + public function testClosureIsOnlyCalledOnceForRepeatedRawBytes(): void + { + $calls = 0; + $image = new Image(function () use (&$calls): string { + ++$calls; + + return 'source image'; + }); + + $this->assertSame('source image', $image->toBytes()); + $this->assertSame('source image', $image->toBytes()); + $this->assertSame(1, $calls); + } + + public function testClosureMustReturnString(): void + { + $image = new Image(fn (): int => 123); + + $this->expectExceptionObject(new ImageException( + 'Image source resolver must return a string, int returned.', + )); + + $image->toBytes(); + } + + public function testMimeTypeDetectsJpeg(): void + { + $image = new Image($this->fakeImageContents()); + + $this->assertSame('image/jpeg', $image->mimeType()); + } + + public function testExtensionReturnsJpgForJpeg(): void + { + $image = new Image($this->fakeImageContents()); + + $this->assertSame('jpg', $image->extension()); + } + + #[RequiresPhpExtension('imagick')] + public function testExtensionReturnsAvifForAvif(): void + { + $imagick = new Imagick; + + try { + $imagick->newImage(10, 10, 'red'); + $imagick->setImageFormat('avif'); + $contents = $imagick->getImageBlob(); + } finally { + $imagick->clear(); + $imagick->destroy(); + } + + if ((new finfo(FILEINFO_MIME_TYPE))->buffer($contents) !== 'image/avif') { + $this->markTestSkipped('The installed fileinfo database does not recognize AVIF.'); + } + + $image = new Image($contents); + + $this->assertSame('avif', $image->extension()); + } + + public function testDimensionsReturnsWidthAndHeight(): void + { + $image = new Image($this->fakeImageContents(300, 200)); + + $this->assertSame([300, 200], $image->dimensions()); + } + + public function testDimensionsThrowsWhenDimensionsCannotBeDetermined(): void + { + $image = new Image('not-an-image'); + + $this->expectExceptionObject(new ImageException('Unable to determine the dimensions of the image.')); + + $image->dimensions(); + } + + public function testDimensionsDefersToTheDriverForHeicImages(): void + { + $container = new Container; + $container->instance('config', new Repository(['images' => ['default' => 'fake']])); + + $manager = new ImageManager($container); + $manager->extend('fake', fn () => new class implements Driver { + public function process(string $contents, ImagePipeline $pipeline): string + { + // Bytes that finfo reports as image/heic but getimagesizefromstring() cannot read. + return "\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1heic"; + } + + public function dimensions(string $contents): array + { + return [123, 45]; + } + + public function dominantColor(string $contents): string + { + return '#000000'; + } + + public function transformUsing(string $transformation, callable $callback): static + { + return $this; + } + }); + $container->instance('image', $manager); + + Container::setInstance($container); + + try { + $image = (new Image($this->fakeImageContents()))->using('fake')->cover(1, 1); + + $this->assertSame([123, 45], $image->dimensions()); + $this->assertSame(123, $image->width()); + $this->assertSame(45, $image->height()); + } finally { + Container::setInstance(null); + } + } + + public function testDimensionsFallsBackToNativeReaderWhenTheDriverCannotDecodeHeic(): void + { + $container = new Container; + $container->instance('config', new Repository(['images' => ['default' => 'fake']])); + + $manager = new ImageManager($container); + $manager->extend('fake', fn () => new class implements Driver { + public function process(string $contents, ImagePipeline $pipeline): string + { + return "\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1heic"; + } + + public function dimensions(string $contents): array + { + throw new ImageException('The driver cannot decode this image.'); + } + + public function dominantColor(string $contents): string + { + return '#000000'; + } + + public function transformUsing(string $transformation, callable $callback): static + { + return $this; + } + }); + $container->instance('image', $manager); + + Container::setInstance($container); + + try { + $image = (new Image($this->fakeImageContents()))->using('fake')->cover(1, 1); + + $this->expectExceptionObject(new ImageException('Unable to determine the dimensions of the image.')); + + $image->dimensions(); + } finally { + Container::setInstance(null); + } + } + + public function testDimensionsDoesNotMaskDriverTypeErrorsForHeic(): void + { + $contents = "\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1heic"; + $driver = m::mock(Driver::class); + $driver->expects('process')->once()->andReturn($contents); + $driver->expects('dimensions')->once()->andThrow(new TypeError('broken dimensions')); + $this->registerDrivers(['fake' => $driver]); + + $this->expectExceptionObject(new TypeError('broken dimensions')); + + (new Image('source image'))->using('fake')->blur()->dimensions(); + } + + public function testDominantColorReusesProcessedBytes(): void + { + $driver = m::mock(Driver::class); + $driver->expects('process') + ->once() + ->with('source image', m::type(ImagePipeline::class)) + ->andReturn('processed image'); + $driver->expects('dominantColor') + ->once() + ->with('processed image') + ->andReturn('#123456'); + $this->registerDrivers(['fake' => $driver]); + + $image = (new Image('source image'))->using('fake')->blur(); + + $this->assertSame('#123456', $image->dominantColor()); + $this->assertSame('processed image', $image->toBytes()); + $this->assertSame('#123456', $image->dominantColor()); + } + + public function testStorePassesOptionsToTheFilesystem(): void + { + $contents = $this->fakeImageContents(); + $image = new Image($contents); + $path = $image->hashName('images'); + + $this->expectImageStored('photos', $path, $contents, ['visibility' => 'private']); + + $this->assertSame($path, $image->store('images', 'photos', ['visibility' => 'private'])); + } + + public function testStoreReturnsFalseWhenTheWriteFails(): void + { + $contents = $this->fakeImageContents(); + $image = new Image($contents); + $path = $image->hashName('images'); + + $this->expectImageStored('photos', $path, $contents, [], result: false); + + $this->assertFalse($image->store('images', 'photos')); + } + + public function testStorePubliclyForcesPublicVisibility(): void + { + $contents = $this->fakeImageContents(); + $image = new Image($contents); + $path = $image->hashName('images'); + + $this->expectImageStored('photos', $path, $contents, ['visibility' => 'public']); + + $this->assertSame($path, $image->storePublicly('images', 'photos', ['visibility' => 'private'])); + } + + public function testStoreAsPassesOptionsToTheFilesystem(): void + { + $contents = $this->fakeImageContents(); + $image = new Image($contents); + + $this->expectImageStored('photos', 'images/avatar.jpg', $contents, ['visibility' => 'private']); + + $this->assertSame( + 'images/avatar.jpg', + $image->storeAs('images', 'avatar.jpg', 'photos', ['visibility' => 'private']), + ); + } + + public function testStorePubliclyAsForcesPublicVisibility(): void + { + $contents = $this->fakeImageContents(); + $image = new Image($contents); + + $this->expectImageStored('photos', 'images/avatar.jpg', $contents, ['visibility' => 'public']); + + $this->assertSame( + 'images/avatar.jpg', + $image->storePubliclyAs('images', 'avatar.jpg', 'photos', ['visibility' => 'private']), + ); + } + + public function testHashNameReturnsNameWithExtension(): void + { + $image = new Image($this->fakeImageContents()); + + $name = $image->hashName(); + + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{40}\.jpg$/', $name); + } + + public function testHashNameWithPath(): void + { + $image = new Image($this->fakeImageContents()); + + $name = $image->hashName('avatars'); + + $this->assertStringStartsWith('avatars/', $name); + $this->assertMatchesRegularExpression('/^avatars\/[a-zA-Z0-9]{40}\.jpg$/', $name); + } + + public function testFileReturnsUploadedFileWhenProvided(): void + { + $file = UploadedFile::fake()->image('avatar.jpg'); + $image = new Image(fn () => $file->getContent(), $file); + + $this->assertSame($file, $image->file()); + } + + public function testFileReturnsNullWhenNotProvided(): void + { + $image = new Image($this->fakeImageContents()); + + $this->assertNull($image->file()); + } + + public function testClonePreservesUploadedFile(): void + { + $file = UploadedFile::fake()->image('avatar.jpg'); + $image = new Image(fn () => $file->getContent(), $file); + + $cloned = $image->cover(100, 100); + + $this->assertSame($file, $cloned->file()); + } + + public function testOptimizeHasDefaults(): void + { + $image = $this->makeImage(); + $result = $image->optimize(); + + $options = $this->getOptions($result); + + $this->assertSame('webp', $options->format); + $this->assertSame(70, $options->quality); + } + + public function testOptimizeThrowsForUnsupportedFormat(): void + { + $image = $this->makeImage(); + + $this->expectExceptionObject(new ImageException('The [tiff] format is not supported.')); + + $image->optimize('tiff'); + } + + public function testQualitySetsOption(): void + { + $image = $this->makeImage(); + $result = $image->quality(60); + + $this->assertSame(60, $this->getOptions($result)->quality); + } + + public function testEffectAndQualityValuesAreClamped(): void + { + $image = $this->makeImage(); + + $this->assertSame(0, $this->getOptions($image->blur(-1))->blur); + $this->assertSame(100, $this->getOptions($image->blur(101))->blur); + $this->assertSame(0, $this->getOptions($image->sharpen(-1))->sharpen); + $this->assertSame(100, $this->getOptions($image->sharpen(101))->sharpen); + $this->assertSame(1, $this->getOptions($image->quality(0))->quality); + $this->assertSame(100, $this->getOptions($image->quality(101))->quality); + } + + public function testToWebpSetsFormat(): void + { + $image = $this->makeImage(); + + $this->assertSame('webp', $this->getOptions($image->toWebp())->format); + } + + public function testToJpgSetsFormat(): void + { + $image = $this->makeImage(); + + $this->assertSame('jpg', $this->getOptions($image->toJpg())->format); + } + + public function testToJpegIsAliasForToJpg(): void + { + $image = $this->makeImage(); + + $this->assertSame('jpg', $this->getOptions($image->toJpeg())->format); + } + + public function testToPngSetsFormat(): void + { + $image = $this->makeImage(); + + $this->assertSame('png', $this->getOptions($image->toPng())->format); + } + + public function testToGifSetsFormat(): void + { + $image = $this->makeImage(); + + $this->assertSame('gif', $this->getOptions($image->toGif())->format); + } + + public function testToAvifSetsFormat(): void + { + $image = $this->makeImage(); + + $this->assertSame('avif', $this->getOptions($image->toAvif())->format); + } + + public function testToHeicSetsFormat(): void + { + $image = $this->makeImage(); + + $this->assertSame('heic', $this->getOptions($image->toHeic())->format); + } + + public function testToBmpSetsFormat(): void + { + $image = $this->makeImage(); + + $this->assertSame('bmp', $this->getOptions($image->toBmp())->format); + } + + public function testToFormatSupportsEveryPublicFormatSpelling(): void + { + $image = $this->makeImage(); + + foreach ([ + 'webp' => 'webp', + 'jpg' => 'jpg', + 'jpeg' => 'jpeg', + 'png' => 'png', + 'gif' => 'gif', + 'avif' => 'avif', + 'heic' => 'heic', + 'heif' => 'heic', + 'bmp' => 'bmp', + ] as $format => $expected) { + $this->assertSame($expected, $this->getOptions($image->toFormat($format))->format); + } + } + + public function testToFormatRejectsUnsupportedFormat(): void + { + $this->expectExceptionObject(new ImageException('The [WEBP] format is not supported.')); + + $this->makeImage()->toFormat('WEBP'); + } + + public function testQualitySurvivesFormatConversion(): void + { + $image = $this->makeImage(); + + $this->assertSame(50, $this->getOptions($image->quality(50)->toJpg())->quality); + $this->assertSame(90, $this->getOptions($image->quality(90)->toWebp())->quality); + } + + public function testFormatAndQualityCanBeSetSeparately(): void + { + $image = $this->makeImage(); + $result = $image->toWebp()->quality(60); + + $options = $this->getOptions($result); + + $this->assertSame('webp', $options->format); + $this->assertSame(60, $options->quality); + } + + public function testBlurHasDefault(): void + { + $image = $this->makeImage(); + $result = $image->blur(); + + $this->assertSame(5, $this->getOptions($result)->blur); + } + + public function testSharpenReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->sharpen()); + } + + public function testSharpenSetsOption(): void + { + $image = $this->makeImage(); + + $this->assertSame(20, $this->getOptions($image->sharpen(20))->sharpen); + } + + public function testSharpenHasDefault(): void + { + $image = $this->makeImage(); + + $this->assertSame(10, $this->getOptions($image->sharpen())->sharpen); + } + + public function testFlipVerticallyReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->flipVertically()); + } + + public function testFlipVerticallySetsOption(): void + { + $image = $this->makeImage(); + + $this->assertTrue($this->getOptions($image->flipVertically())->flipVertically); + } + + public function testFlipHorizontallyReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->flipHorizontally()); + } + + public function testFlipHorizontallySetsOption(): void + { + $image = $this->makeImage(); + + $this->assertTrue($this->getOptions($image->flipHorizontally())->flipHorizontally); + } + + public function testWidthReturnsInt(): void + { + $image = new Image($this->fakeImageContents(300, 200)); + + $this->assertSame(300, $image->width()); + } + + public function testHeightReturnsInt(): void + { + $image = new Image($this->fakeImageContents(300, 200)); + + $this->assertSame(200, $image->height()); + } + + public function testToBase64ReturnsEncodedString(): void + { + $contents = $this->fakeImageContents(); + $image = new Image($contents); + + $this->assertSame(base64_encode($contents), $image->toBase64()); + } + + public function testToDataUriReturnsDataUri(): void + { + $image = new Image($this->fakeImageContents()); + + $dataUri = $image->toDataUri(); + + $this->assertStringStartsWith('data:image/jpeg;base64,', $dataUri); + } + + public function testDriverExceptionIsWrappedInImageException(): void + { + $image = new Image($this->fakeImageContents()); + + $this->expectExceptionObject(new ImageException('Failed to process image:')); + + // Trigger a driver error by using a non-existent driver + $image->using('nonexistent')->cover(100, 100)->toBytes(); + } + + public function testWrappedExceptionPreservesOriginal(): void + { + $image = new Image($this->fakeImageContents()); + + try { + $image->using('nonexistent')->cover(100, 100)->toBytes(); + } catch (ImageException $exception) { + $this->assertNotNull($exception->getPrevious()); + + return; + } + + $this->fail('ImageException was not thrown.'); + } + + public function testProcessingDoesNotMaskDriverTypeErrors(): void + { + $driver = m::mock(Driver::class); + $driver->expects('process')->once()->andThrow(new TypeError('broken process')); + $this->registerDrivers(['fake' => $driver]); + + $this->expectExceptionObject(new TypeError('broken process')); + + (new Image('source image'))->using('fake')->blur()->toBytes(); + } + + public function testToBytesReturnsSameResultOnMultipleCalls(): void + { + $image = new Image($this->fakeImageContents()); + + $first = $image->toBytes(); + $second = $image->toBytes(); + + $this->assertSame($first, $second); + } + + public function testToBytesProcessesARecipeOnlyOnce(): void + { + $driver = m::mock(Driver::class); + $driver->expects('process') + ->once() + ->with('source image', m::type(ImagePipeline::class)) + ->andReturn('processed image'); + $this->registerDrivers(['fake' => $driver]); + + $image = (new Image('source image'))->using('fake')->blur(); + + $this->assertSame('processed image', $image->toBytes()); + $this->assertSame('processed image', $image->toBytes()); + } + + public function testToBytesWithoutOperationsReturnsOriginal(): void + { + $contents = $this->fakeImageContents(); + $image = new Image($contents); + + $this->assertSame($contents, $image->toBytes()); + } + + public function testHasChangesWithOnlyQualitySet(): void + { + $image = $this->makeImage(); + $result = $image->quality(50); + + $this->assertTrue($this->getPipeline($result)->hasChanges()); + } + + public function testCloneDoesNotShareHashNameCache(): void + { + $image = $this->makeImage(); + $originalName = $image->hashName(); + + $clone = $image->usingGd(); + $cloneName = $clone->hashName(); + + $this->assertNotSame($originalName, $cloneName); + $this->assertSame($originalName, $image->hashName()); + $this->assertSame($cloneName, $clone->hashName()); + } + + public function testHashNameIsConsistentOnSameInstance(): void + { + $image = $this->makeImage(); + + $this->assertSame($image->hashName(), $image->hashName()); + } + + public function testInstanceMetadataIsCachedAndInvalidatedOnClones(): void + { + $contents = $this->fakeImageContents(300, 200); + $driver = m::mock(Driver::class); + $driver->expects('process')->once()->andReturn($contents); + $driver->expects('dominantColor')->once()->with($contents)->andReturn('#123456'); + $this->registerDrivers(['fake' => $driver]); + + $image = (new Image('source image'))->using('fake')->blur(); + + $this->assertSame('image/jpeg', $image->mimeType()); + $this->assertSame('image/jpeg', $image->mimeType()); + $this->assertSame([300, 200], $image->dimensions()); + $this->assertSame([300, 200], $image->dimensions()); + $this->assertSame('#123456', $image->dominantColor()); + $this->assertSame('#123456', $image->dominantColor()); + $this->assertSame($image->hashName(), $image->hashName()); + + $clone = $image->quality(80); + + foreach (['processedContents', 'mimeType', 'dimensions', 'dominantColor', 'hashName'] as $property) { + $this->assertNull((new ReflectionProperty($clone, $property))->getValue($clone)); + } + } + + public function testFlipAliasSetsVerticalOption(): void + { + $image = $this->makeImage(); + + $this->assertTrue($this->getOptions($image->flip())->flipVertically); + } + + public function testFlopAliasSetsHorizontalOption(): void + { + $image = $this->makeImage(); + + $this->assertTrue($this->getOptions($image->flop())->flipHorizontally); + } + + public function testFlipVerticallyAndHorizontallyTogether(): void + { + $image = $this->makeImage(); + $result = $image->flipVertically()->flipHorizontally(); + + $this->assertTrue($this->getOptions($result)->flipVertically); + $this->assertTrue($this->getOptions($result)->flipHorizontally); + } + + public function testMultipleOperationsChained(): void + { + $image = $this->makeImage(); + $result = $image->orient()->cover(200, 200)->blur(10)->grayscale()->sharpen(5)->toWebp()->quality(75); + + $options = $this->getOptions($result); + + $this->assertTrue($options->orient); + $this->assertSame(200, $options->coverWidth); + $this->assertSame(200, $options->coverHeight); + $this->assertSame(10, $options->blur); + $this->assertTrue($options->grayscale); + $this->assertSame(5, $options->sharpen); + $this->assertSame('webp', $options->format); + $this->assertSame(75, $options->quality); + } + + public function testLaterOperationOverridesEarlier(): void + { + $image = $this->makeImage(); + $result = $image->cover(200, 200)->cover(100, 100); + + $options = $this->getOptions($result); + + $this->assertSame(100, $options->coverWidth); + $this->assertSame(100, $options->coverHeight); + } + + public function testExtensionReturnsBinForUnknownMime(): void + { + $image = new Image('not-an-image'); + + $this->assertSame('bin', $image->extension()); + } + + public function testFileReturnsNullForNonUpload(): void + { + $image = Image::class; + $instance = new $image($this->fakeImageContents()); + + $this->assertNull($instance->file()); + } + + public function testUsingGdShortcut(): void + { + $image = $this->makeImage(); + $result = $image->usingGd(); + + $driver = (new ReflectionProperty($result, 'driver'))->getValue($result); + + $this->assertSame('gd', $driver); + } + + public function testUsingImagickShortcut(): void + { + $image = $this->makeImage(); + $result = $image->usingImagick(); + + $driver = (new ReflectionProperty($result, 'driver'))->getValue($result); + + $this->assertSame('imagick', $driver); + } + + public function testDimensionsOnTinyImage(): void + { + $image = new Image($this->fakeImageContents(1, 1)); + + $this->assertSame([1, 1], $image->dimensions()); + $this->assertSame(1, $image->width()); + $this->assertSame(1, $image->height()); + } + + public function testToDataUriContainsValidBase64(): void + { + $image = new Image($this->fakeImageContents()); + + $dataUri = $image->toDataUri(); + $base64Part = substr($dataUri, strpos($dataUri, ',') + 1); + + $this->assertNotFalse(base64_decode($base64Part, true)); + } + + public function testOptimizeThrowsForJpgWithWrongSpelling(): void + { + $image = $this->makeImage(); + + $this->expectExceptionObject(new ImageException('The [jpge] format is not supported.')); + + $image->optimize('jpge'); + } + + public function testOptimizeAllowsPng(): void + { + $result = $this->makeImage()->optimize('png'); + + $this->assertSame('png', $this->getOptions($result)->format); + } + + public function testSerializationThrowsException(): void + { + $image = new Image($this->fakeImageContents()); + + $this->expectExceptionObject(new ImageException('Images cannot be serialized. Store the image first and serialize the path instead.')); + + serialize($image); + } + + public function testImagePipelineHasNoChangesByDefault(): void + { + $pipeline = new ImagePipeline; + + $this->assertFalse($pipeline->hasChanges()); + } + + public function testImagePipelineHasChangesWithZeroQuality(): void + { + $pipeline = new ImagePipeline; + $pipeline->output->quality = 0; + + $this->assertTrue($pipeline->hasChanges()); + } + + public function testImagePipelineHasChangesWithZeroBlur(): void + { + $pipeline = new ImagePipeline; + $pipeline->add(new Blur(0)); + + $this->assertTrue($pipeline->hasChanges()); + } + + public function testImagePipelineHasChangesWithZeroSharpen(): void + { + $pipeline = new ImagePipeline; + $pipeline->add(new Sharpen(0)); + + $this->assertTrue($pipeline->hasChanges()); + } + + public function testImageOutputOptionsDefaultQualityConstant(): void + { + $this->assertSame(70, ImageOutputOptions::DEFAULT_QUALITY); + } + + public function testCoverSetsBothDimensions(): void + { + $image = $this->makeImage(); + $result = $image->cover(300, 150); + + $options = $this->getOptions($result); + + $this->assertSame(300, $options->coverWidth); + $this->assertSame(150, $options->coverHeight); + } + + public function testScaleSetsBothDimensions(): void + { + $image = $this->makeImage(); + $result = $image->scale(1200, 800); + + $options = $this->getOptions($result); + + $this->assertSame(1200, $options->scaleWidth); + $this->assertSame(800, $options->scaleHeight); + } + + public function testContainSetsDimensionsAndBackground(): void + { + $image = $this->makeImage(); + $result = $image->contain(1200, 800, '#ffffff'); + + $options = $this->getOptions($result); + + $this->assertSame(1200, $options->containWidth); + $this->assertSame(800, $options->containHeight); + $this->assertSame('#ffffff', $options->containBackground); + } + + public function testContainSetsDominantBackground(): void + { + $image = $this->makeImage(); + $result = $image->contain(1200, 800, 'dominant'); + + $options = $this->getOptions($result); + + $this->assertSame(1200, $options->containWidth); + $this->assertSame(800, $options->containHeight); + $this->assertSame('dominant', $options->containBackground); + } + + public function testCropSetsDimensionsAndPosition(): void + { + $image = $this->makeImage(); + $result = $image->crop(300, 200, 10, 20); + + $options = $this->getOptions($result); + + $this->assertSame(300, $options->cropWidth); + $this->assertSame(200, $options->cropHeight); + $this->assertSame(10, $options->cropX); + $this->assertSame(20, $options->cropY); + } + + public function testDimensionTransformationsClampNonPositiveDimensions(): void + { + $image = $this->makeImage(); + + $cover = $this->getOptions($image->cover(0, -1)); + $contain = $this->getOptions($image->contain(0, -1)); + $crop = $this->getOptions($image->crop(0, -1, -2, -3)); + $resize = $this->getOptions($image->resize(0, -1)); + $scale = $this->getOptions($image->scale(0, -1)); + + $this->assertSame([1, 1], [$cover->coverWidth, $cover->coverHeight]); + $this->assertSame([1, 1], [$contain->containWidth, $contain->containHeight]); + $this->assertSame([1, 1, -2, -3], [$crop->cropWidth, $crop->cropHeight, $crop->cropX, $crop->cropY]); + $this->assertSame([1, 1], [$resize->resizeWidth, $resize->resizeHeight]); + $this->assertSame([1, 1], [$scale->scaleWidth, $scale->scaleHeight]); + } + + public function testResizeSetsBothDimensions(): void + { + $image = $this->makeImage(); + $result = $image->resize(1200, 800); + + $options = $this->getOptions($result); + + $this->assertSame(1200, $options->resizeWidth); + $this->assertSame(800, $options->resizeHeight); + } + + public function testResizeSetsWidthOnly(): void + { + $image = $this->makeImage(); + $result = $image->resize(width: 1200); + + $options = $this->getOptions($result); + + $this->assertSame(1200, $options->resizeWidth); + $this->assertNull($options->resizeHeight); + } + + public function testResizeSetsHeightOnly(): void + { + $image = $this->makeImage(); + $result = $image->resize(height: 800); + + $options = $this->getOptions($result); + + $this->assertNull($options->resizeWidth); + $this->assertSame(800, $options->resizeHeight); + } + + public function testResizeRequiresAtLeastOneDimension(): void + { + $this->expectExceptionObject(new ImageException('At least one resize dimension must be specified.')); + + $this->makeImage()->resize(); + } + + public function testRotateSetsAngleAndBackground(): void + { + $image = $this->makeImage(); + $result = $image->rotate(90, '#ffffff'); + + $options = $this->getOptions($result); + + $this->assertSame(90.0, $options->rotateAngle); + $this->assertSame('#ffffff', $options->rotateBackground); + } + + public function testRotateSetsDominantBackground(): void + { + $image = $this->makeImage(); + $result = $image->rotate(45, 'dominant'); + + $options = $this->getOptions($result); + + $this->assertSame(45.0, $options->rotateAngle); + $this->assertSame('dominant', $options->rotateBackground); + } + + public function testScaleSetsWidthOnly(): void + { + $image = $this->makeImage(); + $result = $image->scale(width: 1200); + + $options = $this->getOptions($result); + + $this->assertSame(1200, $options->scaleWidth); + $this->assertNull($options->scaleHeight); + } + + public function testScaleSetsHeightOnly(): void + { + $image = $this->makeImage(); + $result = $image->scale(height: 800); + + $options = $this->getOptions($result); + + $this->assertNull($options->scaleWidth); + $this->assertSame(800, $options->scaleHeight); + } + + public function testScaleRequiresAtLeastOneDimension(): void + { + $this->expectExceptionObject(new ImageException('At least one scale dimension must be specified.')); + + $this->makeImage()->scale(); + } + + public function testOrientSetsOption(): void + { + $image = $this->makeImage(); + $result = $image->orient(); + + $this->assertTrue($this->getOptions($result)->orient); + } + + public function testOptimizeSetsBothFormatAndQuality(): void + { + $image = $this->makeImage(); + $result = $image->optimize('jpg', 90); + + $options = $this->getOptions($result); + + $this->assertSame('jpg', $options->format); + $this->assertSame(90, $options->quality); + } + + public function testOptimizeAllowsGif(): void + { + $result = $this->makeImage()->optimize('gif'); + + $this->assertSame('gif', $this->getOptions($result)->format); + } + + public function testOptimizeAllowsAvif(): void + { + $result = $this->makeImage()->optimize('avif'); + + $this->assertSame('avif', $this->getOptions($result)->format); + } + + public function testOptimizeAllowsHeic(): void + { + $result = $this->makeImage()->optimize('heic'); + + $this->assertSame('heic', $this->getOptions($result)->format); + } + + public function testOptimizeNormalizesHeifToHeic(): void + { + $result = $this->makeImage()->optimize('heif'); + + $this->assertSame('heic', $this->getOptions($result)->format); + } + + public function testOptimizeAllowsJpegSpelling(): void + { + $image = $this->makeImage(); + $result = $image->optimize('jpeg', 90); + + $this->assertSame('jpeg', $this->getOptions($result)->format); + } + + public function testScaleDoesNotSetCover(): void + { + $image = $this->makeImage(); + $result = $image->scale(800, 600); + + $options = $this->getOptions($result); + + $this->assertNull($options->coverWidth); + $this->assertNull($options->coverHeight); + } + + public function testCoverDoesNotSetScale(): void + { + $image = $this->makeImage(); + $result = $image->cover(200, 200); + + $options = $this->getOptions($result); + + $this->assertNull($options->scaleWidth); + $this->assertNull($options->scaleHeight); + } + + public function testThreeVariantsFromSameSource(): void + { + $image = $this->makeImage(); + + $a = $image->cover(100, 100); + $b = $image->scale(800, 600); + $c = $image->blur(10); + + $this->assertSame(100, $this->getOptions($a)->coverWidth); + $this->assertNull($this->getOptions($a)->scaleWidth); + $this->assertNull($this->getOptions($a)->blur); + + $this->assertNull($this->getOptions($b)->coverWidth); + $this->assertSame(800, $this->getOptions($b)->scaleWidth); + $this->assertNull($this->getOptions($b)->blur); + + $this->assertNull($this->getOptions($c)->coverWidth); + $this->assertNull($this->getOptions($c)->scaleWidth); + $this->assertSame(10, $this->getOptions($c)->blur); + } + + public function testMaterializedCloneReprocessesTheOriginalSourceWithTheFullRecipe(): void + { + $recipes = []; + $driver = m::mock(Driver::class); + $driver->expects('process') + ->twice() + ->andReturnUsing(static function (string $contents, ImagePipeline $pipeline) use (&$recipes): string { + $recipes[] = [ + $contents, + array_map( + static fn (Transformation $transformation): string => $transformation::class, + $pipeline->transformations, + ), + ]; + + return 'processed image ' . count($recipes); + }); + $this->registerDrivers(['fake' => $driver]); + + $image = (new Image('original image'))->using('fake')->blur(1); + + $this->assertSame('processed image 1', $image->toBytes()); + + $clone = $image->sharpen(2); + + $this->assertSame('processed image 2', $clone->toBytes()); + $this->assertSame([ + ['original image', [Blur::class]], + ['original image', [Blur::class, Sharpen::class]], + ], $recipes); + } + + public function testUsingSetsDriverString(): void + { + $image = $this->makeImage(); + $result = $image->using('custom-driver'); + + $driver = (new ReflectionProperty($result, 'driver'))->getValue($result); + + $this->assertSame('custom-driver', $driver); + } + + public function testSwitchingDriversReprocessesTheRetainedRecipe(): void + { + $firstDriver = m::mock(Driver::class); + $firstDriver->expects('process') + ->once() + ->withArgs(static fn (string $contents, ImagePipeline $pipeline): bool => $contents === 'source image' + && count($pipeline->transformations) === 1 + && $pipeline->transformations[0] instanceof Blur) + ->andReturn('first output'); + + $secondDriver = m::mock(Driver::class); + $secondDriver->expects('process') + ->once() + ->withArgs(static fn (string $contents, ImagePipeline $pipeline): bool => $contents === 'source image' + && count($pipeline->transformations) === 1 + && $pipeline->transformations[0] instanceof Blur) + ->andReturn('second output'); + + $this->registerDrivers(['first' => $firstDriver, 'second' => $secondDriver], 'first'); + + $image = (new Image('source image'))->using('first')->blur(); + + $this->assertSame('first output', $image->toBytes()); + $this->assertSame('second output', $image->using('second')->toBytes()); + } + + public function testImplementsStringable(): void + { + $image = new Image($this->fakeImageContents()); + + $this->assertInstanceOf(Stringable::class, $image); + } + + public function testToStringReturnsDataUri(): void + { + $image = new Image($this->fakeImageContents()); + + $this->assertSame($image->toDataUri(), $image->toString()); + } + + public function testMagicToStringReturnsDataUri(): void + { + $image = new Image($this->fakeImageContents()); + + $this->assertSame($image->toDataUri(), (string) $image); + } + + public function testImageExceptionExtendsRuntimeException(): void + { + $exception = new ImageException('test'); + + $this->assertInstanceOf(RuntimeException::class, $exception); + } + + public function testImplementsResponsable(): void + { + $image = new Image($this->fakeImageContents()); + + $this->assertInstanceOf(Responsable::class, $image); + } + + public function testToResponseReturnsResponseWithImageBytes(): void + { + $contents = $this->fakeImageContents(); + $image = new Image($contents); + + $response = $image->toResponse(new Request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($contents, $response->getContent()); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testToResponseSetsContentTypeHeader(): void + { + $image = new Image($this->fakeImageContents()); + + $response = $image->toResponse(new Request); + + $this->assertSame('image/jpeg', $response->headers->get('Content-Type')); + } + + public function testFlushStateRemovesMacros(): void + { + Image::macro('temporaryMacro', static fn (): string => 'registered'); + + $this->assertTrue(Image::hasMacro('temporaryMacro')); + + Image::flushState(); + + $this->assertFalse(Image::hasMacro('temporaryMacro')); + } + + /** + * Register image drivers in a concrete global container. + * + * @param array $drivers + */ + protected function registerDrivers(array $drivers, string $default = 'fake'): void + { + $container = new Container; + $container->instance('config', new Repository(['images' => ['default' => $default]])); + + $manager = new ImageManager($container); + + foreach ($drivers as $name => $driver) { + $manager->extend($name, static fn (): Driver => $driver); + } + + $container->instance('image', $manager); + Container::setInstance($container); + } + + protected function makeImage(): Image + { + return new Image($this->fakeImageContents()); + } + + /** + * Expect an image to be stored with the given options through a concrete global container. + * + * @param array $options + */ + protected function expectImageStored( + string $diskName, + string $path, + string $contents, + array $options, + bool|string $result = true, + ): void { + $filesystem = m::mock(FilesystemContract::class); + $filesystem->expects('put') + ->with($path, $contents, $options) + ->andReturn($result); + + $factory = m::mock(FilesystemFactory::class); + $factory->expects('disk') + ->with($diskName) + ->andReturn($filesystem); + + $container = new Container; + $container->instance(FilesystemFactory::class, $factory); + + Container::setInstance($container); + } + + protected function fakeImageContents(int $width = 100, int $height = 100): string + { + $file = UploadedFile::fake()->image('test.jpg', $width, $height); + + return file_get_contents($file->getRealPath()); + } + + protected function getOptions(Image $image): object + { + $pipeline = (new ReflectionProperty($image, 'pipeline'))->getValue($image); + + $options = (object) [ + 'coverWidth' => null, + 'coverHeight' => null, + 'containWidth' => null, + 'containHeight' => null, + 'containBackground' => null, + 'cropWidth' => null, + 'cropHeight' => null, + 'cropX' => null, + 'cropY' => null, + 'resizeWidth' => null, + 'resizeHeight' => null, + 'rotateAngle' => null, + 'rotateBackground' => null, + 'scaleWidth' => null, + 'scaleHeight' => null, + 'orient' => null, + 'blur' => null, + 'grayscale' => null, + 'sharpen' => null, + 'flipVertically' => null, + 'flipHorizontally' => null, + 'format' => $pipeline->output->format, + 'quality' => $pipeline->output->quality, + ]; + + foreach ($pipeline->transformations as $transformation) { + match (true) { + $transformation instanceof Cover => [$options->coverWidth, $options->coverHeight] = [$transformation->width, $transformation->height], + $transformation instanceof Contain => [$options->containWidth, $options->containHeight, $options->containBackground] = [$transformation->width, $transformation->height, $transformation->background], + $transformation instanceof Crop => [$options->cropWidth, $options->cropHeight, $options->cropX, $options->cropY] = [$transformation->width, $transformation->height, $transformation->x, $transformation->y], + $transformation instanceof Resize => [$options->resizeWidth, $options->resizeHeight] = [$transformation->width, $transformation->height], + $transformation instanceof Rotate => [$options->rotateAngle, $options->rotateBackground] = [$transformation->angle, $transformation->background], + $transformation instanceof Scale => [$options->scaleWidth, $options->scaleHeight] = [$transformation->width, $transformation->height], + $transformation instanceof Orient => $options->orient = true, + $transformation instanceof Blur => $options->blur = $transformation->amount, + $transformation instanceof Grayscale => $options->grayscale = true, + $transformation instanceof Sharpen => $options->sharpen = $transformation->amount, + $transformation instanceof FlipVertically => $options->flipVertically = true, + $transformation instanceof FlipHorizontally => $options->flipHorizontally = true, + default => null, + }; + } + + return $options; + } + + protected function getPipeline(Image $image): ImagePipeline + { + return (new ReflectionProperty($image, 'pipeline'))->getValue($image); + } +} diff --git a/tests/Integration/Image/ImageTest.php b/tests/Integration/Image/ImageTest.php new file mode 100644 index 000000000..e5328ad3f --- /dev/null +++ b/tests/Integration/Image/ImageTest.php @@ -0,0 +1,858 @@ +fakeImageContents(200, 200)); + + $result = $image->cover(100, 100)->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(100, $height); + } + + public function testScaleAndToBytes(): void + { + $image = new Image($this->fakeImageContents(400, 200)); + + $result = $image->scale(200, 200)->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(100, $height); + } + + public function testContainAndToBytes(): void + { + $image = new Image($this->fakeImageContents(400, 200)); + + $result = $image->contain(200, 200, '#ffffff')->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(200, $height); + } + + public function testContainWithDominantBackground(): void + { + $image = new Image($this->solidColorImageContents(255, 0, 0, 400, 200)); + + $result = $image->contain(200, 200, 'dominant')->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(200, $height); + } + + public function testDominantColorReturnsHex(): void + { + $image = new Image($this->solidColorImageContents(0, 128, 255)); + + $this->assertSame('#0080ff', $image->dominantColor()); + } + + public function testCropAndToBytes(): void + { + $image = new Image($this->fakeImageContents(400, 200)); + + $result = $image->crop(100, 50, 10, 20)->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(50, $height); + } + + public function testResizeAndToBytes(): void + { + $image = new Image($this->fakeImageContents(400, 200)); + + $result = $image->resize(200, 200)->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(200, $height); + } + + public function testRotateAndToBytes(): void + { + $image = new Image($this->fakeImageContents(100, 50)); + + $result = $image->rotate(90)->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(50, $width); + $this->assertSame(100, $height); + } + + public function testRotateWithDominantBackground(): void + { + $image = new Image($this->solidColorImageContents(0, 255, 0, 100, 50)); + + $result = $image->rotate(45, 'dominant')->toBytes(); + + $this->assertNotEmpty($result); + $this->assertNotFalse(getimagesizefromstring($result)); + } + + public function testToPngAndToBytes(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + + $result = $image->toPng()->toBytes(); + + $this->assertSame(IMAGETYPE_PNG, getimagesizefromstring($result)[2]); + } + + #[RequiresFunction('imagewebp')] + public function testToWebpAndToBytes(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + + $result = $image->toWebp()->toBytes(); + + $this->assertSame(IMAGETYPE_WEBP, getimagesizefromstring($result)[2]); + } + + public function testBlurAndToBytes(): void + { + $contents = $this->fakeImageContents(100, 100); + $image = new Image($contents); + + $result = $image->blur(10)->toBytes(); + + $this->assertNotSame($contents, $result); + } + + public function testGrayscaleAndToBytes(): void + { + $contents = $this->fakeImageContents(100, 100); + $image = new Image($contents); + + $result = $image->grayscale()->toBytes(); + + $this->assertNotSame($contents, $result); + } + + #[RequiresFunction('imagewebp')] + public function testImmutabilityWithVariants(): void + { + $image = new Image($this->fakeImageContents(400, 400)); + + $thumb = $image->cover(100, 100)->toWebp(); + $large = $image->scale(200, 200)->toWebp(); + + $thumbBytes = $thumb->toBytes(); + $largeBytes = $large->toBytes(); + + $thumbSize = getimagesizefromstring($thumbBytes); + $largeSize = getimagesizefromstring($largeBytes); + + $this->assertSame(100, $thumbSize[0]); + $this->assertSame(100, $thumbSize[1]); + $this->assertSame(IMAGETYPE_WEBP, $thumbSize[2]); + + $this->assertSame(200, $largeSize[0]); + $this->assertSame(200, $largeSize[1]); + $this->assertSame(IMAGETYPE_WEBP, $largeSize[2]); + } + + #[RequiresFunction('imagewebp')] + public function testStoreSavesToDisk(): void + { + Storage::fake('local'); + + $image = new Image($this->fakeImageContents(100, 100)); + + $image->toWebp()->store('images', 'local'); + + $files = Storage::disk('local')->files('images'); + + $this->assertCount(1, $files); + $this->assertStringEndsWith('.webp', $files[0]); + } + + #[RequiresFunction('imagewebp')] + public function testStoreAsSavesWithCustomName(): void + { + Storage::fake('local'); + + $image = new Image($this->fakeImageContents(100, 100)); + + $image->toWebp()->storeAs('images', 'avatar.webp', 'local'); + + Storage::disk('local')->assertExists('images/avatar.webp'); + } + + #[RequiresFunction('imagewebp')] + public function testMimeTypeAfterFormatConversion(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + + $this->assertSame('image/webp', $image->toWebp()->mimeType()); + } + + #[RequiresFunction('imagewebp')] + public function testExtensionAfterFormatConversion(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + + $this->assertSame('webp', $image->toWebp()->extension()); + $this->assertSame('jpg', $image->extension()); + } + + public function testDimensionsAfterCover(): void + { + $image = new Image($this->fakeImageContents(400, 300)); + + $this->assertSame([200, 200], $image->cover(200, 200)->dimensions()); + $this->assertSame([400, 300], $image->dimensions()); + } + + public function testQualityAffectsFileSize(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + + $low = $image->toJpg()->quality(1)->toBytes(); + $high = $image->toJpg()->quality(100)->toBytes(); + + $this->assertLessThan(strlen($high), strlen($low)); + } + + #[RequiresFunction('imagewebp')] + public function testFullAvatarPipeline(): void + { + Storage::fake('local'); + + $image = new Image($this->fakeImageContents(800, 600)); + + $result = $image->orient()->cover(200, 200)->toWebp()->quality(80); + $result->store('avatars', 'local'); + + $this->assertSame([200, 200], $result->dimensions()); + $this->assertSame('image/webp', $result->mimeType()); + + $files = Storage::disk('local')->files('avatars'); + $this->assertCount(1, $files); + $this->assertStringEndsWith('.webp', $files[0]); + } + + #[RequiresFunction('imagewebp')] + public function testTwoVariantsFromUploadedFile(): void + { + Storage::fake('local'); + + $file = UploadedFile::fake()->image('photo.jpg', 800, 600); + $image = new Image(fn () => $file->getContent(), $file); + + $thumb = $image->cover(100, 100)->toWebp(); + $large = $image->scale(400, 400)->toWebp(); + + $thumb->store('thumbs', 'local'); + $large->store('photos', 'local'); + + $thumbFiles = Storage::disk('local')->files('thumbs'); + $largeFiles = Storage::disk('local')->files('photos'); + + $this->assertCount(1, $thumbFiles); + $this->assertCount(1, $largeFiles); + + $thumbBytes = Storage::disk('local')->get($thumbFiles[0]); + $largeBytes = Storage::disk('local')->get($largeFiles[0]); + + $thumbSize = getimagesizefromstring($thumbBytes); + $largeSize = getimagesizefromstring($largeBytes); + + $this->assertSame(100, $thumbSize[0]); + $this->assertSame(100, $thumbSize[1]); + $this->assertSame(IMAGETYPE_WEBP, $thumbSize[2]); + + $this->assertLessThanOrEqual(400, $largeSize[0]); + $this->assertLessThanOrEqual(400, $largeSize[1]); + $this->assertSame(IMAGETYPE_WEBP, $largeSize[2]); + + $this->assertSame($file, $image->file()); + $this->assertSame($file, $thumb->file()); + $this->assertSame($file, $large->file()); + } + + #[RequiresFunction('imagewebp')] + public function testTwoVariantsFromRequestImage(): void + { + Storage::fake('local'); + + $file = UploadedFile::fake()->image('avatar.jpg', 600, 600); + + $image = new Image(fn () => $file->getContent(), $file); + + $avatar = $image->orient()->cover(200, 200)->toWebp(); + $placeholder = $image->scale(40, 40)->blur(15)->toWebp()->quality(50); + + $avatar->store('avatars', 'local'); + $placeholder->store('placeholders', 'local'); + + $avatarFiles = Storage::disk('local')->files('avatars'); + $placeholderFiles = Storage::disk('local')->files('placeholders'); + + $this->assertCount(1, $avatarFiles); + $this->assertCount(1, $placeholderFiles); + + $avatarSize = getimagesizefromstring(Storage::disk('local')->get($avatarFiles[0])); + $placeholderSize = getimagesizefromstring(Storage::disk('local')->get($placeholderFiles[0])); + + $this->assertSame(200, $avatarSize[0]); + $this->assertSame(200, $avatarSize[1]); + + $this->assertSame(40, $placeholderSize[0]); + $this->assertSame(40, $placeholderSize[1]); + + $this->assertSame([600, 600], $image->dimensions()); + $this->assertSame('avatar.jpg', $image->file()->getClientOriginalName()); + } + + public function testFromPathFacadeCreatesImage(): void + { + $file = UploadedFile::fake()->image('test.jpg', 200, 200); + + $image = ImageFacade::fromPath($file->getRealPath()); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame([200, 200], $image->dimensions()); + } + + public function testFromBytesFacadeCreatesImage(): void + { + $contents = $this->fakeImageContents(150, 150); + + $image = ImageFacade::fromBytes($contents); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame([150, 150], $image->dimensions()); + } + + public function testFromBase64FacadeCreatesImage(): void + { + $contents = $this->fakeImageContents(120, 120); + + $image = ImageFacade::fromBase64(base64_encode($contents)); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame([120, 120], $image->dimensions()); + } + + public function testStorageImageCreatesImage(): void + { + Storage::fake('local'); + + $contents = $this->fakeImageContents(300, 200); + Storage::disk('local')->put('photos/test.jpg', $contents); + + $image = Storage::disk('local')->image('photos/test.jpg'); + + $this->assertInstanceOf(Image::class, $image); + $this->assertSame([300, 200], $image->dimensions()); + } + + public function testSharpenAfterScale(): void + { + $image = new Image($this->fakeImageContents(400, 400)); + + $result = $image->scale(200, 200)->sharpen(10)->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(200, $width); + $this->assertSame(200, $height); + } + + public function testFlipVerticallyPreservesDimensions(): void + { + $image = new Image($this->fakeImageContents(300, 200)); + + $result = $image->flipVertically()->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(300, $width); + $this->assertSame(200, $height); + } + + public function testFlipHorizontallyPreservesDimensions(): void + { + $image = new Image($this->fakeImageContents(300, 200)); + + $result = $image->flipHorizontally()->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(300, $width); + $this->assertSame(200, $height); + } + + public function testFlipVerticallyAndHorizontallyTogether(): void + { + $image = new Image($this->fakeImageContents(200, 200)); + + $result = $image->flipVertically()->flipHorizontally()->toBytes(); + + $this->assertNotEmpty($result); + $this->assertSame([200, 200], getimagesizefromstring($result) ? [getimagesizefromstring($result)[0], getimagesizefromstring($result)[1]] : [0, 0]); + } + + #[RequiresFunction('imagewebp')] + public function testAllOperationsCombined(): void + { + $image = new Image($this->fakeImageContents(800, 600)); + + $result = $image + ->orient() + ->cover(200, 200) + ->blur(5) + ->grayscale() + ->sharpen(10) + ->flipVertically() + ->toWebp() + ->quality(80); + + $bytes = $result->toBytes(); + $size = getimagesizefromstring($bytes); + + $this->assertSame(200, $size[0]); + $this->assertSame(200, $size[1]); + $this->assertSame(IMAGETYPE_WEBP, $size[2]); + } + + #[RequiresFunction('imagewebp')] + public function testToBytesIsIdempotent(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + $processed = $image->cover(50, 50)->toWebp(); + + $first = $processed->toBytes(); + $second = $processed->toBytes(); + + $this->assertSame($first, $second); + } + + public function testMaterializingBeforeAppendingPreservesOneFullRecipe(): void + { + $image = new Image($this->fakeImageContents(100, 50)); + $materialized = $image->rotate(90)->toJpg()->quality(10); + + $materialized->toBytes(); + + $appended = $materialized->rotate(90)->toPng(); + $direct = $image->rotate(90)->rotate(90)->toPng()->quality(10); + + $this->assertSame($direct->toBytes(), $appended->toBytes()); + $this->assertSame([100, 50], $appended->dimensions()); + $this->assertSame('image/png', $appended->mimeType()); + } + + public function testWidthAndHeightHelpers(): void + { + $image = new Image($this->fakeImageContents(400, 300)); + $covered = $image->cover(200, 150); + + $this->assertSame(200, $covered->width()); + $this->assertSame(150, $covered->height()); + } + + #[RequiresFunction('imagewebp')] + public function testToBase64ProducesValidBase64(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + $result = $image->cover(50, 50)->toWebp(); + + $base64 = $result->toBase64(); + + $this->assertNotFalse(base64_decode($base64, true)); + $this->assertSame($result->toBytes(), base64_decode($base64)); + } + + #[RequiresFunction('imagewebp')] + public function testToDataUriProducesValidDataUri(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + $result = $image->toWebp(); + + $dataUri = $result->toDataUri(); + + $this->assertStringStartsWith('data:image/webp;base64,', $dataUri); + } + + #[RequiresFunction('imagewebp')] + public function testStoreWithStringDiskOption(): void + { + Storage::fake('custom'); + + $image = new Image($this->fakeImageContents(100, 100)); + $image->toWebp()->store('images', 'custom'); + + $files = Storage::disk('custom')->files('images'); + + $this->assertCount(1, $files); + } + + #[RequiresFunction('imagewebp')] + public function testStoreWithArrayOptions(): void + { + Storage::fake('custom'); + + $image = new Image($this->fakeImageContents(100, 100)); + $image->toWebp()->store('images', 'custom', ['visibility' => 'public']); + + $files = Storage::disk('custom')->files('images'); + + $this->assertCount(1, $files); + } + + public function testSecondCoverOverridesFirst(): void + { + $image = new Image($this->fakeImageContents(400, 400)); + + $result = $image->cover(200, 200)->cover(100, 100)->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(100, $height); + } + + public function testBranchingAfterToBytesDoesNotReapplyStaleTransformations(): void + { + $image = new Image($this->fakeImageContents(100, 50)); + + $rotatedOnce = $image->rotate(90); + $rotatedOnce->toBytes(); + + $rotatedTwice = $rotatedOnce->rotate(90); + + [$width, $height] = getimagesizefromstring($rotatedTwice->toBytes()); + + $this->assertSame(100, $width); + $this->assertSame(50, $height); + } + + public function testStoreAsWithNameOnly(): void + { + Storage::fake('local'); + + $image = new Image($this->fakeImageContents(100, 100)); + + $image->storeAs('avatar.jpg', disk: 'local'); + + Storage::disk('local')->assertExists('avatar.jpg'); + } + + #[RequiresFunction('imagewebp')] + public function testStorePubliclyStoresTheProcessedImage(): void + { + Storage::fake('local'); + + $image = new Image($this->fakeImageContents(100, 100)); + + $image->toWebp()->storePublicly('images', 'local'); + + $files = Storage::disk('local')->files('images'); + + $this->assertCount(1, $files); + } + + #[RequiresFunction('imagewebp')] + public function testStorePubliclyAs(): void + { + Storage::fake('local'); + + $image = new Image($this->fakeImageContents(100, 100)); + + $image->toWebp()->storePubliclyAs('images', 'public-avatar.webp', 'local'); + + Storage::disk('local')->assertExists('images/public-avatar.webp'); + } + + public function testStoreWithEmptyPath(): void + { + Storage::fake('local'); + + $image = new Image($this->fakeImageContents(100, 100)); + + $image->store('', 'local'); + + $files = Storage::disk('local')->allFiles(); + + $this->assertCount(1, $files); + $this->assertStringEndsWith('.jpg', $files[0]); + } + + #[RequiresFunction('imagewebp')] + public function testHashNameChangesExtensionAfterFormatConversion(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + + $jpgName = $image->hashName(); + $webpName = $image->toWebp()->hashName(); + + $this->assertStringEndsWith('.jpg', $jpgName); + $this->assertStringEndsWith('.webp', $webpName); + } + + public function testToJpgConversion(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + + $result = $image->toJpg()->toBytes(); + + $this->assertSame(IMAGETYPE_JPEG, getimagesizefromstring($result)[2]); + } + + public function testToJpegAliasWorks(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + + $result = $image->toJpeg()->toBytes(); + + $this->assertSame(IMAGETYPE_JPEG, getimagesizefromstring($result)[2]); + } + + #[RequiresFunction('imagewebp')] + public function testOptimizeShortcutProducesWebp(): void + { + $image = new Image($this->fakeImageContents(100, 100)); + + $result = $image->optimize()->toBytes(); + + $this->assertSame(IMAGETYPE_WEBP, getimagesizefromstring($result)[2]); + } + + public function testOrientDoesNotChangeDimensionsOnNonRotatedImage(): void + { + $image = new Image($this->fakeImageContents(300, 200)); + + $result = $image->orient(); + + $this->assertSame(300, $result->width()); + $this->assertSame(200, $result->height()); + } + + public function testGrayscaleDoesNotChangeDimensions(): void + { + $image = new Image($this->fakeImageContents(200, 150)); + + $result = $image->grayscale(); + + $this->assertSame(200, $result->width()); + $this->assertSame(150, $result->height()); + } + + public function testQualityAloneChangesFileSize(): void + { + $image = new Image($this->fakeImageContents(200, 200)); + + $default = $image->toBytes(); + $low = $image->quality(1)->toBytes(); + + $this->assertNotSame(strlen($default), strlen($low)); + } + + public function testRequestImageReturnsImageWithFile(): void + { + $file = UploadedFile::fake()->image('avatar.jpg', 100, 100); + $image = new Image(fn () => $file->getContent(), $file); + + $this->assertNotNull($image->file()); + $this->assertSame('avatar.jpg', $image->file()->getClientOriginalName()); + $this->assertSame([100, 100], $image->dimensions()); + } + + #[RequiresFunction('imagewebp')] + public function testFormatConversionDoesNotChangeDimensions(): void + { + $image = new Image($this->fakeImageContents(300, 200)); + + $webp = $image->toWebp()->toBytes(); + $jpg = $image->toJpg()->toBytes(); + + [$webpWidth, $webpHeight] = getimagesizefromstring($webp); + [$jpgWidth, $jpgHeight] = getimagesizefromstring($jpg); + + $this->assertSame(300, $webpWidth); + $this->assertSame(200, $webpHeight); + $this->assertSame(300, $jpgWidth); + $this->assertSame(200, $jpgHeight); + } + + public function testQualityDoesNotChangeDimensions(): void + { + $image = new Image($this->fakeImageContents(300, 200)); + + $result = $image->quality(50)->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(300, $width); + $this->assertSame(200, $height); + } + + #[RequiresFunction('imagewebp')] + public function testQualityAndFormatDoesNotChangeDimensions(): void + { + $image = new Image($this->fakeImageContents(300, 200)); + + $result = $image->quality(90)->toWebp()->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(300, $width); + $this->assertSame(200, $height); + } + + public function testScaleDownDoesNotUpscale(): void + { + $image = new Image($this->fakeImageContents(100, 80)); + + $result = $image->scale(800, 600)->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(100, $width); + $this->assertSame(80, $height); + } + + public function testScaleDownShrinksLargerImages(): void + { + $image = new Image($this->fakeImageContents(800, 600)); + + $result = $image->scale(400, 400)->toBytes(); + + [$width, $height] = getimagesizefromstring($result); + + $this->assertSame(400, $width); + $this->assertSame(300, $height); + } + + #[RequiresFunction('imagewebp')] + public function testStoreWithDefaultDisk(): void + { + Storage::fake(); + + $image = new Image($this->fakeImageContents(100, 100)); + $image->toWebp()->store('avatars'); + + $files = Storage::files('avatars'); + + $this->assertCount(1, $files); + $this->assertStringEndsWith('.webp', $files[0]); + } + + public function testStoreWithNoArguments(): void + { + Storage::fake(); + + $image = new Image($this->fakeImageContents(100, 100)); + $image->store(); + + $files = Storage::allFiles(); + + $this->assertCount(1, $files); + } + + public function testStoreAsWithPathAndNameOnly(): void + { + Storage::fake(); + + $image = new Image($this->fakeImageContents(100, 100)); + $image->storeAs('avatars', 'photo.jpg'); + + Storage::assertExists('avatars/photo.jpg'); + } + + public function testStoreAsWithNameOnlyNoOptions(): void + { + Storage::fake(); + + $image = new Image($this->fakeImageContents(100, 100)); + $image->storeAs('photo.jpg'); + + Storage::assertExists('photo.jpg'); + } + + public function testStorePubliclyWithDefaultDisk(): void + { + Storage::fake(); + + $image = new Image($this->fakeImageContents(100, 100)); + $image->storePublicly('avatars'); + + $files = Storage::files('avatars'); + + $this->assertCount(1, $files); + } + + public function testStorePubliclyAsWithNameOnly(): void + { + Storage::fake(); + + $image = new Image($this->fakeImageContents(100, 100)); + $image->storePubliclyAs('avatar.jpg'); + + Storage::assertExists('avatar.jpg'); + } + + public function testStorePubliclyAsWithPathAndName(): void + { + Storage::fake(); + + $image = new Image($this->fakeImageContents(100, 100)); + $image->storePubliclyAs('avatars', 'photo.jpg'); + + Storage::assertExists('avatars/photo.jpg'); + } + + protected function fakeImageContents(int $width = 100, int $height = 100): string + { + $file = UploadedFile::fake()->image('test.jpg', $width, $height); + + return file_get_contents($file->getRealPath()); + } + + protected function solidColorImageContents(int $red, int $green, int $blue, int $width = 100, int $height = 100): string + { + $image = imagecreatetruecolor($width, $height); + $color = imagecolorallocate($image, $red, $green, $blue); + imagefill($image, 0, 0, $color); + + ob_start(); + imagepng($image); + + return ob_get_clean(); + } +} diff --git a/tests/Passkeys/AaguidSyncScriptTest.php b/tests/Passkeys/AaguidSyncScriptTest.php index 94070fd11..1d8d2bdcc 100644 --- a/tests/Passkeys/AaguidSyncScriptTest.php +++ b/tests/Passkeys/AaguidSyncScriptTest.php @@ -78,7 +78,10 @@ public function testTheSynchronizationWorkflowTargetsTheDefaultBranch(): void $this->assertArrayHasKey('schedule', $workflow['on']); $this->assertArrayHasKey('workflow_dispatch', $workflow['on']); $this->assertSame(['contents' => 'write', 'pull-requests' => 'write'], $workflow['permissions']); - $this->assertStringContainsString('apt-get install -y -qq git', $steps['Install Git']['run']); + $this->assertSame( + 'ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2', + $workflow['jobs']['synchronize']['container']['image'], + ); $this->assertSame($defaultBranch, $steps['Checkout default branch']['with']['ref']); $this->assertFalse($steps['Checkout default branch']['with']['persist-credentials']); $this->assertSame('git config --global --add safe.directory "$GITHUB_WORKSPACE"', $steps['Trust checkout directory']['run']); diff --git a/tests/Pool/HeartbeatConnectionTest.php b/tests/Pool/HeartbeatConnectionTest.php index a5a30a2c0..7f44bbd7e 100644 --- a/tests/Pool/HeartbeatConnectionTest.php +++ b/tests/Pool/HeartbeatConnectionTest.php @@ -208,7 +208,7 @@ public function testConnectionCloseProtocolRunsOnPoolFlush(): void protected function getContainer(array $poolConfig = []): ContainerContract { - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class); Container::setInstance($container); $container->shouldReceive('make')->with(HeartbeatPoolStub::class)->andReturnUsing(function () use ($container, $poolConfig) { diff --git a/tests/Sentry/Features/StorageIntegrationTest.php b/tests/Sentry/Features/StorageIntegrationTest.php index 8c3a19c2f..5f79a15f6 100644 --- a/tests/Sentry/Features/StorageIntegrationTest.php +++ b/tests/Sentry/Features/StorageIntegrationTest.php @@ -234,6 +234,7 @@ public static function adapterDecoratorPairs(): array 'getAdapter', 'getConfig', 'getDriver', + 'image', 'json', 'macroCall', 'missing', diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php index 06a81ed89..701ce236a 100644 --- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php @@ -21,6 +21,7 @@ use Hypervel\Http\Resources\JsonApi\JsonApiResource; use Hypervel\Http\Response as HttpResponse; use Hypervel\Http\UploadedFile; +use Hypervel\Image\Image; use Hypervel\NestedSet\NestedSet; use Hypervel\Process\InvokedProcess; use Hypervel\Support\Carbon; @@ -67,6 +68,7 @@ public function testFrameworkCleanupFlushesEveryMacroableRegistry(): void JsonApiResource::class, HttpResponse::class, UploadedFile::class, + Image::class, InvokedProcess::class, NotificationFake::class, ]; diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 76ff62c01..bf90f3551 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -5333,7 +5333,7 @@ public function testFakedDnsLookupsCanBeToggledAndAreFlushed(): void $this->assertFalse($property->getValue()); } - public function testValidateImage() + public function testValidateImage(): void { $trans = $this->getArrayTranslator(); $file = $this->uploadedFile(__FILE__, '', guessedExtension: 'php', clientOriginalExtension: 'php'); @@ -5344,10 +5344,6 @@ public function testValidateImage() $v = new Validator($trans, ['x' => $file2], ['x' => 'image']); $this->assertTrue($v->passes()); - $file2 = $this->uploadedFile(__FILE__, '', guessedExtension: 'jpeg', clientOriginalExtension: 'jpeg'); - $v = new Validator($trans, ['x' => $file2], ['x' => 'image']); - $this->assertTrue($v->passes()); - $file2 = $this->uploadedFile(__FILE__, '', guessedExtension: 'jpg', clientOriginalExtension: 'jpg'); $v = new Validator($trans, ['x' => $file2], ['x' => 'image']); $this->assertTrue($v->passes()); @@ -5376,6 +5372,18 @@ public function testValidateImage() $v = new Validator($trans, ['x' => $file7], ['x' => 'Image']); $this->assertTrue($v->passes()); + $file8 = $this->uploadedFile(__FILE__, '', guessedExtension: 'avif', clientOriginalExtension: 'avif'); + $v = new Validator($trans, ['x' => $file8], ['x' => 'image']); + $this->assertTrue($v->passes()); + + $file9 = $this->uploadedFile(__FILE__, '', guessedExtension: 'heic', clientOriginalExtension: 'heic'); + $v = new Validator($trans, ['x' => $file9], ['x' => 'image']); + $this->assertTrue($v->passes()); + + $file10 = $this->uploadedFile(__FILE__, '', guessedExtension: 'heif', clientOriginalExtension: 'heif'); + $v = new Validator($trans, ['x' => $file10], ['x' => 'image']); + $this->assertTrue($v->passes()); + $file2 = $this->uploadedFile(__FILE__, '', guessedExtension: 'jpg', clientOriginalExtension: 'jpg'); $v = new Validator($trans, ['x' => $file2], ['x' => 'Image']); $this->assertTrue($v->passes());