diff --git a/.docker/Dockerfile b/.docker/Dockerfile new file mode 100644 index 0000000..1647f3f --- /dev/null +++ b/.docker/Dockerfile @@ -0,0 +1,49 @@ +ARG PHP_VERSION=8.3 +FROM php:${PHP_VERSION}-cli + +# git + unzip: the official php:*-cli image ships neither. Composer needs git to fall +# back to a source clone when a dist (zipball) download fails transiently — without it a +# single failed download aborts the whole install ("Source fallback is disabled. Not +# trying alternative sources."). unzip gives faster, more reliable archive extraction +# than composer's PHP-zip fallback. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git unzip \ + && rm -rf /var/lib/apt/lists/* + +# mlocati's installer builds the needed extensions across all target PHP versions. +COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ +RUN install-php-extensions intl gd mysqli pdo_mysql zip bcmath exif pcov + +# Measure coverage against the mounted module source only. +RUN echo 'pcov.directory=/module/src' > /usr/local/etc/php/conf.d/99-pcov.ini + +# The TinyMCE build task loads a translation catalogue per i18n locale (hundreds), +# so PHP's 128M default is insufficient; raise it container-wide so every invocation +# (task, CI running vendor/bin/phpunit directly, and manual runs) is covered. +RUN echo 'memory_limit=512M' > /usr/local/etc/php/conf.d/99-memory.ini + +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +COPY app/composer.json /app/composer.json + +# SilverStripe requires Page and PageController classes in the project. These are +# generated here (not committed) to avoid duplicate class detection when the module +# is symlinked into vendor/. SilverStripe discovers modules by looking for _config/; +# without a project-root _config/, /app/src/ is not scanned and Page/PageController +# stay invisible to the class manifest (which breaks temp-database creation in tests). +RUN mkdir -p /app/src /app/_config \ + && printf ' /app/src/Page.php \ + && printf ' /app/src/PageController.php + +COPY app/_config/ /app/_config/ +COPY app/phpunit.xml.dist /app/phpunit.xml.dist +COPY app/phpstan.neon.dist /app/phpstan.neon.dist +COPY app/phpstan-php85.neon.dist /app/phpstan-php85.neon.dist +COPY app/rector.php /app/rector.php + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["entrypoint.sh"] diff --git a/.docker/app/_config/config.yml b/.docker/app/_config/config.yml new file mode 100644 index 0000000..3d07c13 --- /dev/null +++ b/.docker/app/_config/config.yml @@ -0,0 +1,3 @@ +--- +Name: app-e2e +--- diff --git a/.docker/app/composer.json b/.docker/app/composer.json new file mode 100644 index 0000000..22bf89a --- /dev/null +++ b/.docker/app/composer.json @@ -0,0 +1,60 @@ +{ + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "silverstripe/recipe-cms": "^6.0", + "wedevelopnl/silverstripe-e2e": "*" + }, + "repositories": [ + { + "type": "path", + "url": "/module", + "options": { + "symlink": true + } + } + ], + "require-dev": { + "cambis/silverstan": "2.1.9", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "2.2.1", + "phpstan/phpstan-deprecation-rules": "2.0.4", + "phpunit/phpunit": "11.5.55", + "rregeer/phpunit-coverage-check": "0.3.1", + "tomasvotruba/type-coverage": "2.2.1", + "wernerkrauss/silverstripe-rector": "1.3.0" + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "autoload-dev": { + "psr-4": { + "WeDevelop\\E2e\\Tests\\": "vendor/wedevelopnl/silverstripe-e2e/tests/" + } + }, + "extra": { + "project-files-installed": [ + ".htaccess", + "app/.htaccess", + "app/_config/mimevalidator.yml", + "app/_config/mysite.yml", + "app/src/Page.php", + "app/src/PageController.php" + ], + "public-files-installed": [ + ".htaccess", + "index.php", + "web.config" + ] + }, + "config": { + "allow-plugins": { + "composer/installers": true, + "phpstan/extension-installer": true, + "silverstripe/recipe-plugin": true, + "silverstripe/vendor-plugin": true + } + } +} diff --git a/.docker/app/phpstan-php85.neon.dist b/.docker/app/phpstan-php85.neon.dist new file mode 100644 index 0000000..7cb9023 --- /dev/null +++ b/.docker/app/phpstan-php85.neon.dist @@ -0,0 +1,9 @@ +# Secondary PHPStan pass with the analysis target pinned to PHP 8.5, so +# target-gated checks (e.g. #[\NoDiscard]) run while the primary pass keeps +# verifying the 8.3 floor. +includes: + - phpstan.neon.dist +parameters: + phpVersion: + min: 80500 + max: 80599 diff --git a/.docker/app/phpstan.neon.dist b/.docker/app/phpstan.neon.dist new file mode 100644 index 0000000..fc9ed85 --- /dev/null +++ b/.docker/app/phpstan.neon.dist @@ -0,0 +1,13 @@ +parameters: + level: max + phpVersion: + min: 80300 + max: 80599 + paths: + - /module/src + type_coverage: + return_type: 100 + param_type: 100 + property_type: 100 + constant_type: 100 + declare: 100 diff --git a/.docker/app/phpunit.xml.dist b/.docker/app/phpunit.xml.dist new file mode 100644 index 0000000..7e4c4f0 --- /dev/null +++ b/.docker/app/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + vendor/wedevelopnl/silverstripe-e2e/tests/Unit + + + vendor/wedevelopnl/silverstripe-e2e/tests/Integration + + + + + vendor/wedevelopnl/silverstripe-e2e/src + + + diff --git a/.docker/app/rector.php b/.docker/app/rector.php new file mode 100644 index 0000000..5e362da --- /dev/null +++ b/.docker/app/rector.php @@ -0,0 +1,34 @@ +withPaths([ + '/module/src', + ]) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + instanceOf: true, + earlyReturn: true, + rectorPreset: true, + ) + ->withPhpSets(php83: true) + ->withSets([ + SilverstripeSetList::CODE_STYLE, + SilverstripeLevelSetList::UP_TO_SS_6_0, + ]) + ->withSkip([ + // Subjective style choices we deliberately keep. + ChangeOrIfContinueToMultiContinueRector::class, + FlipTypeControlToUseExclusiveTypeRector::class, + PostIncDecToPreIncDecRector::class, + ]); diff --git a/.docker/compose.ci.yml b/.docker/compose.ci.yml new file mode 100644 index 0000000..de8694b --- /dev/null +++ b/.docker/compose.ci.yml @@ -0,0 +1,16 @@ +# CI-only overlay adding GitHub Actions layer caching + a mirrored db image. +# Layered after compose.yml by .github/workflows/ci.yml: +# COMPOSE_BAKE=true docker compose -f .docker/compose.yml -f .docker/compose.ci.yml build app +# CACHE_SCOPE namespaces the cache per PHP version so matrix jobs don't clobber +# each other. cache_to ignore-error=true lets fork/Dependabot PRs (no cache write +# access) degrade to an uncached build instead of failing. +services: + db: + image: mirror.gcr.io/library/mysql:8 + + app: + build: + cache_from: + - type=gha,scope=${CACHE_SCOPE:-app} + cache_to: + - type=gha,mode=max,scope=${CACHE_SCOPE:-app},ignore-error=true diff --git a/.docker/compose.yml b/.docker/compose.yml new file mode 100644 index 0000000..7fbca93 --- /dev/null +++ b/.docker/compose.yml @@ -0,0 +1,49 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + PHP_VERSION: ${PHP_VERSION:-8.3} + volumes: + - ../composer.json:/module/composer.json:ro + - ../src:/module/src + - ../tests:/module/tests:ro + - ../_config:/module/_config:ro + - ../coverage:/app/coverage + - vendor:/app/vendor + healthcheck: + test: test -f /tmp/.app-ready + interval: 3s + start_period: 60s + retries: 20 + depends_on: + db: + condition: service_healthy + environment: + SS_DATABASE_SERVER: db + SS_DATABASE_NAME: silverstripe + SS_DATABASE_USERNAME: silverstripe + SS_DATABASE_PASSWORD: silverstripe + SS_DEFAULT_ADMIN_USERNAME: admin + SS_DEFAULT_ADMIN_PASSWORD: admin + SS_ENVIRONMENT_TYPE: dev + SS_PHPUNIT_FLUSH: 1 + + db: + image: mysql:8 + volumes: + - db-data:/var/lib/mysql + environment: + MYSQL_DATABASE: silverstripe + MYSQL_USER: silverstripe + MYSQL_PASSWORD: silverstripe + MYSQL_ROOT_PASSWORD: root + healthcheck: + test: mysqladmin ping -h localhost + interval: 5s + retries: 10 + +volumes: + vendor: + db-data: diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh new file mode 100755 index 0000000..916f28c --- /dev/null +++ b/.docker/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +composer install --no-interaction + +# Build the schema + class/config manifest so silverstan and sapphire tests have +# a ready environment. DB is guaranteed up (compose depends_on: db healthy). +vendor/bin/sake dev/build flush=1 + +touch /tmp/.app-ready + +# No webserver: block so `docker compose exec` can run tests/analysis in this container. +exec tail -f /dev/null diff --git a/.github/actions/setup-docker-mirror/action.yml b/.github/actions/setup-docker-mirror/action.yml new file mode 100644 index 0000000..0d134c1 --- /dev/null +++ b/.github/actions/setup-docker-mirror/action.yml @@ -0,0 +1,26 @@ +name: Setup buildx with Docker Hub mirror +description: >- + Configure buildx so build-time docker.io pulls go through mirror.gcr.io + (Google's public pull-through cache for Docker Hub), avoiding Docker Hub's + anonymous rate limit. The buildx docker-container driver pulls base images + (php-cli, composer, the extension installer) and its own buildkit image + independently of the daemon's authenticated session; across the CI matrix + that trips HTTP 429. + + Daemon-side pulls (the db/mysql service at `compose up`) are pointed at the + mirror via an explicit image ref in compose.ci.yml instead. + +runs: + using: composite + steps: + # Export ACTIONS_CACHE_URL / ACTIONS_RUNTIME_TOKEN into the job env. GitHub + # only exposes these to JavaScript actions, not to `run:` steps — so without + # this, the `type=gha` cache used by `docker compose build` (a run step) + # silently no-ops and every build is cold. + - uses: crazy-max/ghaction-github-runtime@v3 + - uses: docker/setup-buildx-action@v3 + with: + driver-opts: image=mirror.gcr.io/moby/buildkit:buildx-stable-1 + buildkitd-config-inline: | + [registry."docker.io"] + mirrors = ["mirror.gcr.io"] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1c815f0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 + +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: daily + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + + - package-ecosystem: docker + directory: /.docker + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ee41eb0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,107 @@ +name: CI + +on: + push: + branches: ['main', '6'] + pull_request: + branches: ['main', '6'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + static-analysis: + name: Static Analysis + runs-on: ubuntu-latest + env: + COMPOSE: docker compose -f .docker/compose.yml -f .docker/compose.ci.yml + COMPOSE_BAKE: 'true' + CACHE_SCOPE: app-php8.3 + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + steps: + - uses: actions/checkout@v4 + - name: Log in to Docker Hub + if: env.DOCKERHUB_USERNAME != '' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: ./.github/actions/setup-docker-mirror + - name: Build image (GHA layer cache) + run: $COMPOSE build app + - name: Start services + run: | + $COMPOSE up -d --wait app || { + echo "::group::app container logs"; $COMPOSE logs app; echo "::endgroup::" + $COMPOSE ps -a + exit 1 + } + - run: task analyse + - run: task analyse-php85 + - run: task rector-dry + + php-qa: + name: PHP QA (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4', '8.5'] + env: + PHP_VERSION: ${{ matrix.php }} + COMPOSE: docker compose -f .docker/compose.yml -f .docker/compose.ci.yml + COMPOSE_BAKE: 'true' + CACHE_SCOPE: app-php${{ matrix.php }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + steps: + - uses: actions/checkout@v4 + - name: Log in to Docker Hub + if: env.DOCKERHUB_USERNAME != '' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: ./.github/actions/setup-docker-mirror + - name: Build image (GHA layer cache) + run: $COMPOSE build app + - name: Start services + run: | + $COMPOSE up -d --wait app || { + echo "::group::app container logs"; $COMPOSE logs app; echo "::endgroup::" + $COMPOSE ps -a + exit 1 + } + # PHP 8.3 owns the coverage gate; 8.4/8.5 run the suites without pcov overhead. + - if: matrix.php == '8.3' + name: Run tests with coverage + 90% gate + run: | + $COMPOSE exec -T app vendor/bin/phpunit \ + --log-junit coverage/junit.xml \ + --coverage-clover coverage/clover.xml + $COMPOSE exec -T app vendor/bin/coverage-check coverage/clover.xml 90 + - if: matrix.php != '8.3' + name: Run tests + run: >- + $COMPOSE exec -T app vendor/bin/phpunit + --log-junit coverage/junit.xml + - uses: dorny/test-reporter@v1 + if: always() + with: + name: PHPUnit Results (PHP ${{ matrix.php }}) + path: coverage/junit.xml + reporter: java-junit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e86edba --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +composer.lock +vendor/ + +# PHPUnit / coverage +.phpunit.cache/ +coverage/ diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..56839d6 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,102 @@ +version: '3' + +# Build/dev task runner for the SilverStripe E2E module. +# Install Task: https://taskfile.dev/installation + +vars: + COMPOSE: docker compose -f .docker/compose.yml + +tasks: + up: + desc: Start services (build if needed) + cmds: + - "{{.COMPOSE}} up -d --build --wait" + + down: + desc: Stop services + cmds: + - "{{.COMPOSE}} down" + + destroy: + desc: Stop services and remove volumes + cmds: + - "{{.COMPOSE}} down -v" + + build: + desc: Build images without starting + cmds: + - "{{.COMPOSE}} build" + + ensure-up: + desc: Ensure services are running and ready + cmds: + - "{{.COMPOSE}} exec app true 2>/dev/null || {{.COMPOSE}} up -d --build --wait" + + test: + desc: Run all PHP tests (unit + integration) + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/phpunit" + + test-unit: + desc: Run PHP unit tests (no database or framework) + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/phpunit --testsuite unit" + + test-integration: + desc: Run PHP integration tests (full SilverStripe environment) + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/phpunit --testsuite integration" + + analyse: + desc: Run PHPStan static analysis + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/phpstan analyse -c phpstan.neon.dist --memory-limit=512M" + + analyse-php85: + desc: Run PHPStan with the analysis target pinned to PHP 8.5 + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/phpstan analyse -c phpstan-php85.neon.dist --memory-limit=512M" + + rector: + desc: Run Rector refactoring (applies changes) + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/rector process" + + rector-dry: + desc: Run Rector in dry-run mode (preview only) + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/rector process --dry-run" + + coverage: + desc: Run all tests with coverage (HTML + Clover XML) + deps: [ensure-up] + cmds: + - >- + {{.COMPOSE}} exec app vendor/bin/phpunit + --coverage-html coverage/html + --coverage-clover coverage/clover.xml + + coverage-check: + desc: Check PHP coverage meets the 90% threshold + deps: [coverage] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/coverage-check coverage/clover.xml 90" + + flush: + desc: Clear SilverStripe cache + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/sake flush" + + dev-build: + desc: Run dev/build to rebuild the database and manifest + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/sake dev/build flush=1" diff --git a/composer.json b/composer.json index 271e404..ce282c8 100644 --- a/composer.json +++ b/composer.json @@ -14,13 +14,29 @@ }], "require": { "php": "^8.3", - "silverstripe/framework": "^6" + "silverstripe/framework": "^6", + "silverstripe/htmleditor-tinymce": "^1.1" + }, + "require-dev": { + "cambis/silverstan": "^2.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpunit/phpunit": "^11.3", + "rregeer/phpunit-coverage-check": "^0.3", + "tomasvotruba/type-coverage": "^2.0", + "wernerkrauss/silverstripe-rector": "^1.0" }, "autoload": { "psr-4": { "WeDevelop\\E2e\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "WeDevelop\\E2e\\Tests\\": "tests/" + } + }, "config": { "allow-plugins": { "composer/installers": false diff --git a/coverage/.gitignore b/coverage/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/coverage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/Tasks/GenerateTinyMCECombinedTask.php b/src/Tasks/GenerateTinyMCECombinedTask.php index 7dc2d2d..06ea1cb 100644 --- a/src/Tasks/GenerateTinyMCECombinedTask.php +++ b/src/Tasks/GenerateTinyMCECombinedTask.php @@ -7,8 +7,9 @@ use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\BuildTask; use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; -use SilverStripe\Forms\HTMLEditor\TinyMCECombinedGenerator; -use SilverStripe\Forms\HTMLEditor\TinyMCEScriptGenerator; +use SilverStripe\TinyMCE\TinyMCECombinedGenerator; +use SilverStripe\TinyMCE\TinyMCEConfig; +use SilverStripe\TinyMCE\TinyMCEScriptGenerator; use SilverStripe\i18n\i18n; use SilverStripe\PolyExecution\PolyOutput; use Symfony\Component\Console\Command\Command; @@ -31,7 +32,14 @@ protected function execute(InputInterface $input, PolyOutput $output): int /** @var TinyMCEScriptGenerator $generator */ $generator = Injector::inst()->create(TinyMCEScriptGenerator::class); foreach (array_keys($editorConfigs) as $identifier) { - $generator->getScriptURL(HTMLEditorConfig::get($identifier)); + $config = HTMLEditorConfig::get($identifier); + // Only TinyMCE-backed configs expose a combined script to pre-generate; + // skip any other HTMLEditorConfig implementation that may be registered. + if (!$config instanceof TinyMCEConfig) { + continue; + } + + $generator->getScriptURL($config); } }; @@ -42,7 +50,11 @@ protected function execute(InputInterface $input, PolyOutput $output): int i18n::with_locale($locale, $doGenerate); } } else { + // @codeCoverageIgnoreStart + // Unreachable once the framework has booted (i18n is always autoloadable); + // retained only as a defensive fallback for a stripped runtime. $doGenerate(); + // @codeCoverageIgnoreEnd } $output->writeln('Generated TinyMCE configuration files'); diff --git a/tests/Integration/GenerateTinyMCECombinedTaskTest.php b/tests/Integration/GenerateTinyMCECombinedTaskTest.php new file mode 100644 index 0000000..9baae0e --- /dev/null +++ b/tests/Integration/GenerateTinyMCECombinedTaskTest.php @@ -0,0 +1,87 @@ +run(new ArrayInput([]), $output); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString( + 'Generated TinyMCE configuration files', + $buffer->fetch(), + ); + } + + public function testTaskSkipsNonTinyMceConfigsWithoutError(): void + { + // Register a concrete HTMLEditorConfig that is NOT a TinyMCEConfig so it + // appears in HTMLEditorConfig::get_available_configs_map(). The task must + // skip it (the instanceof guard's `continue`) rather than passing it to the + // TinyMCE script generator, which only accepts TinyMCEConfig instances. + HTMLEditorConfig::set_config( + self::NON_TINYMCE_CONFIG_IDENTIFIER, + TextAreaConfig::create(), + ); + + $buffer = new BufferedOutput(); + $output = new PolyOutput( + PolyOutput::FORMAT_ANSI, + OutputInterface::VERBOSITY_NORMAL, + false, + $buffer, + ); + + $task = new GenerateTinyMCECombinedTask(); + + $exitCode = $task->run(new ArrayInput([]), $output); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString( + 'Generated TinyMCE configuration files', + $buffer->fetch(), + ); + } +} diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29