From f3247d975c7f956bafbd7bbad23267d483bb2eb7 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 27 Mar 2026 16:35:45 -0400 Subject: [PATCH 1/7] Improve 3.x test and CI workflows --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++-- .lintstagedrc.json | 2 +- README.md | 13 ++++++++ composer.json | 10 ++++-- src/UrlSigner.php | 4 +-- tests/docker-compose.yaml | 10 ++++++ tests/unit/AssetsControllerTest.php | 50 +++++++++++++++++++++++++++++ 7 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 tests/unit/AssetsControllerTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0415c37..22c2f8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,7 @@ on: workflow_dispatch: push: branches: - - 1.x - - 2.x + - 3.x pull_request: permissions: contents: read @@ -18,4 +17,48 @@ jobs: with: node_version: '18' php_version: '8.3' - jobs: '["ecs", "phpstan", "prettier"]' + jobs: '["ecs", "prettier"]' + + compatibility: + name: Compatibility (Craft ${{ matrix.craft }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + craft: + - '^4.6' + - '^5' + services: + mysql: + image: mysql:8.0 + ports: + - 33066:3306 + env: + MYSQL_DATABASE: db + MYSQL_USER: db + MYSQL_PASSWORD: db + MYSQL_ROOT_PASSWORD: root + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -udb -pdb" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Resolve dependencies + run: composer update "craftcms/cms:${{ matrix.craft }}" --with-all-dependencies --no-interaction --no-progress --prefer-dist + + - name: Prepare test environment + run: composer test:init + + - name: Run PHPStan + run: composer phpstan + + - name: Run unit tests + run: composer test diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 14c7e6f..4a3c535 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,4 @@ { - "**/*.php": ["composer run fix-cs"], + "**/*.php": ["composer run cs:fix"], "*": "prettier --ignore-unknown --write" } diff --git a/README.md b/README.md index 8ae4927..de4d811 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,19 @@ When you [deploy](https://craftcms.com/knowledge-base/cloud-deployment) a projec When setting up your project’s assets, use the provided **Craft Cloud** filesystem type. Read more about [managing assets in Cloud projects](https://craftcms.com/knowledge-base/cloud-assets). +## Testing + +The Codeception `unit` suite on `3.x` boots Craft and expects a local test database. + +```bash +composer test:init +composer test:up +composer test +composer test:down +``` + +`composer test:init` will create `tests/.env` from `tests/.env.example` if it does not already exist. `composer test:up` uses that file when starting the MySQL service defined in `tests/docker-compose.yaml`. + ## Developer Features ### Template Helpers diff --git a/composer.json b/composer.json index 0e628fc..6f3a683 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,15 @@ "codeception/module-yii2": "^1.0" }, "scripts": { - "check-cs": "ecs check --ansi", - "fix-cs": "ecs check --ansi --fix", + "cs:check": "ecs check --ansi", + "cs:fix": "ecs check --ansi --fix", "phpstan": "phpstan --memory-limit=1G", + "test:init": "@php -r \"file_exists('tests/.env') || copy('tests/.env.example', 'tests/.env');\"", + "test:up": [ + "@test:init", + "docker compose --env-file tests/.env -f tests/docker-compose.yaml up -d --wait" + ], + "test:down": "docker compose --env-file tests/.env -f tests/docker-compose.yaml down", "test": "codecept run unit", "test:coverage": "codecept run unit --coverage" }, diff --git a/src/UrlSigner.php b/src/UrlSigner.php index c288d93..8e46aa6 100644 --- a/src/UrlSigner.php +++ b/src/UrlSigner.php @@ -26,9 +26,9 @@ public function sign(string $url): string private function getSigningData(string $url): string { - return Modifier::wrap($url) + return rtrim((string) Modifier::wrap($url) ->removeQueryParameters($this->signatureParameter) - ->sortQuery(); + ->sortQuery(), '?'); } public function verify(string $url): bool diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml index 39c705f..0ef9e69 100644 --- a/tests/docker-compose.yaml +++ b/tests/docker-compose.yaml @@ -8,6 +8,16 @@ services: MYSQL_DATABASE: ${CRAFT_DB_DATABASE} MYSQL_USER: ${CRAFT_DB_USER} MYSQL_PASSWORD: ${CRAFT_DB_PASSWORD} + healthcheck: + test: + [ + 'CMD-SHELL', + 'mysqladmin ping -h 127.0.0.1 -u$$MYSQL_USER -p$$MYSQL_PASSWORD --silent', + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s # postgres: # image: postgres:15 # ports: diff --git a/tests/unit/AssetsControllerTest.php b/tests/unit/AssetsControllerTest.php new file mode 100644 index 0000000..d6d85dd --- /dev/null +++ b/tests/unit/AssetsControllerTest.php @@ -0,0 +1,50 @@ +markTestSkipped('Craft 5 volume subpath behavior is covered by a separate test.'); + } + + $this->assertSame('', $this->invokeVolumeSubpath($volume)); + } + + public function testVolumeSubpathReturnsVolumeSubpathOnCraft5(): void + { + $volume = new Volume(); + + if (!method_exists($volume, 'getSubpath')) { + $this->markTestSkipped('Craft 4 volumes do not implement getSubpath().'); + } + + $volume->setSubpath('volume-prefix'); + + $this->assertSame('volume-prefix/', $this->invokeVolumeSubpath($volume)); + } + + private function invokeVolumeSubpath(Volume $volume): string + { + $controller = new AssetsController('cloud-assets', Craft::$app); + $method = new ReflectionMethod($controller, 'volumeSubpath'); + $method->setAccessible(true); + + return $method->invoke($controller, $volume); + } +} From 14592e567713b450aa8f6ad120ca74e26c1398a4 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 27 Mar 2026 16:38:37 -0400 Subject: [PATCH 2/7] Fix Craft 4 compatibility workflow resolution --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22c2f8b..119bf21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,11 @@ jobs: strategy: fail-fast: false matrix: - craft: - - '^4.6' - - '^5' + include: + - craft: '^4.6' + flysystem: '^1.0' + - craft: '^5' + flysystem: '^2.0' services: mysql: image: mysql:8.0 @@ -52,7 +54,7 @@ jobs: coverage: none - name: Resolve dependencies - run: composer update "craftcms/cms:${{ matrix.craft }}" --with-all-dependencies --no-interaction --no-progress --prefer-dist + run: composer update "craftcms/cms:${{ matrix.craft }}" "craftcms/flysystem:${{ matrix.flysystem }}" --with-all-dependencies --no-interaction --no-progress --prefer-dist --no-audit - name: Prepare test environment run: composer test:init From d0a797e0137382c3ecbcaf67fd3c9ece83335473 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 27 Mar 2026 16:41:05 -0400 Subject: [PATCH 3/7] Document local Craft 4 compatibility workflow --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index de4d811..22e6adf 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,18 @@ composer test:down `composer test:init` will create `tests/.env` from `tests/.env.example` if it does not already exist. `composer test:up` uses that file when starting the MySQL service defined in `tests/docker-compose.yaml`. +For local compatibility work on `3.x`, it can be helpful to keep your main checkout on the default/latest Craft 5 dependency set and use a separate Git worktree for Craft 4 so each checkout can keep its own `vendor/`, `composer.lock`, and `tests/.env` state. + +```bash +git worktree add ../cloud-3x-craft4 3.x + +# In the Craft 4 worktree: +composer update "craftcms/cms:^4.6" "craftcms/flysystem:^1.0" --with-all-dependencies --no-audit + +# In your main checkout: +composer update "craftcms/cms:^5" "craftcms/flysystem:^2.0" --with-all-dependencies +``` + ## Developer Features ### Template Helpers From e83a8941cdb0d227cc7e6278788a7835ac7f8527 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 27 Mar 2026 16:46:03 -0400 Subject: [PATCH 4/7] Use Uri query cleanup in UrlSigner --- src/UrlSigner.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/UrlSigner.php b/src/UrlSigner.php index 8e46aa6..a630ad1 100644 --- a/src/UrlSigner.php +++ b/src/UrlSigner.php @@ -26,9 +26,10 @@ public function sign(string $url): string private function getSigningData(string $url): string { - return rtrim((string) Modifier::wrap($url) + return (string) Modifier::wrap($url) ->removeQueryParameters($this->signatureParameter) - ->sortQuery(), '?'); + ->sortQuery() + ->removeEmptyQueryPairs(); } public function verify(string $url): bool From 300a197ff75a9b01fe630adb64f0cf15d005ec45 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 27 Mar 2026 17:45:16 -0400 Subject: [PATCH 5/7] Avoid phpstan version-specific generateUrl calls --- src/imagetransforms/ImageTransformer.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index 58c7e75..bb7df53 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -26,11 +26,10 @@ class ImageTransformer extends Component implements ImageTransformerInterface public function getTransformUrl(Asset $asset, \craft\models\ImageTransform $imageTransform, bool $immediately): string { if (version_compare(Craft::$app->version, '5.0', '>=')) { - $assetUrl = Html::encodeSpaces(Assets::generateUrl($asset)); + $assetUrl = Html::encodeSpaces(call_user_func([Assets::class, 'generateUrl'], $asset)); } else { $fs = $asset->getVolume()->getTransformFs(); - /** @phpstan-ignore argument.type (Craft 4 compatibility) */ - $assetUrl = Html::encodeSpaces(Assets::generateUrl($fs, $asset)); + $assetUrl = Html::encodeSpaces(call_user_func([Assets::class, 'generateUrl'], $fs, $asset)); } $mimeType = $asset->getMimeType(); From 81f7d7d34055d9792aee1fd2d2f397a25b684299 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 27 Mar 2026 17:47:37 -0400 Subject: [PATCH 6/7] Ignore version-specific phpstan errors --- phpstan.neon | 9 +++++++++ src/imagetransforms/ImageTransformer.php | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 44554e4..ea0d504 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,3 +5,12 @@ parameters: level: 5 paths: - src + ignoreErrors: + - + path: src/imagetransforms/ImageTransformer.php + identifier: argument.type + reportUnmatched: false + - + path: src/imagetransforms/ImageTransformer.php + identifier: arguments.count + reportUnmatched: false diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index bb7df53..11e9784 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -26,10 +26,10 @@ class ImageTransformer extends Component implements ImageTransformerInterface public function getTransformUrl(Asset $asset, \craft\models\ImageTransform $imageTransform, bool $immediately): string { if (version_compare(Craft::$app->version, '5.0', '>=')) { - $assetUrl = Html::encodeSpaces(call_user_func([Assets::class, 'generateUrl'], $asset)); + $assetUrl = Html::encodeSpaces(Assets::generateUrl($asset)); } else { $fs = $asset->getVolume()->getTransformFs(); - $assetUrl = Html::encodeSpaces(call_user_func([Assets::class, 'generateUrl'], $fs, $asset)); + $assetUrl = Html::encodeSpaces(Assets::generateUrl($fs, $asset)); } $mimeType = $asset->getMimeType(); From c86dbb4f8994c914355f6175a237011ed9b4b391 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 27 Mar 2026 17:49:03 -0400 Subject: [PATCH 7/7] Use inline phpstan compatibility ignores --- phpstan.neon | 10 +--------- src/imagetransforms/ImageTransformer.php | 2 ++ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index ea0d504..375f39a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,14 +3,6 @@ includes: parameters: level: 5 + reportUnmatchedIgnoredErrors: false paths: - src - ignoreErrors: - - - path: src/imagetransforms/ImageTransformer.php - identifier: argument.type - reportUnmatched: false - - - path: src/imagetransforms/ImageTransformer.php - identifier: arguments.count - reportUnmatched: false diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index 11e9784..87b5aca 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -26,9 +26,11 @@ class ImageTransformer extends Component implements ImageTransformerInterface public function getTransformUrl(Asset $asset, \craft\models\ImageTransform $imageTransform, bool $immediately): string { if (version_compare(Craft::$app->version, '5.0', '>=')) { + // @phpstan-ignore argument.type, arguments.count (Craft 5 compatibility) $assetUrl = Html::encodeSpaces(Assets::generateUrl($asset)); } else { $fs = $asset->getVolume()->getTransformFs(); + // @phpstan-ignore argument.type, arguments.count (Craft 4 compatibility) $assetUrl = Html::encodeSpaces(Assets::generateUrl($fs, $asset)); }