diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..0bc1c9a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,76 @@ +name: Code Quality + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ master ] + +jobs: + phpcs: + name: PHP CodeSniffer + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + tools: cs2pr + + - name: Install dependencies + run: | + composer global require "squizlabs/php_codesniffer=*" + composer global require "wp-coding-standards/wpcs=*" + composer global require "phpcompatibility/phpcompatibility-wp=*" + + - name: Configure PHPCS + run: | + phpcs --config-set installed_paths ~/.composer/vendor/wp-coding-standards/wpcs,~/.composer/vendor/phpcompatibility/phpcompatibility-wp + phpcs --config-set default_standard WordPress + + - name: Run PHPCS + run: | + phpcs --standard=WordPress --extensions=php --ignore=scssphp/,tests/,bin/ --report=checkstyle . | cs2pr + + phpstan: + name: PHPStan Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + + - name: Install dependencies + run: | + composer global require phpstan/phpstan + + - name: Create PHPStan config + run: | + cat > phpstan.neon << EOF + parameters: + level: 5 + paths: + - wp-scss.php + - options.php + - class/ + excludePaths: + - scssphp/ + - tests/ + ignoreErrors: + # WordPress functions might not be recognized + - '#Function (get_option|update_option|add_option|wp_kses|sanitize_text_field) not found#' + - '#Function (get_stylesheet_directory|get_template_directory|wp_get_upload_dir) not found#' + - '#Function (add_action|add_filter|apply_filters) not found#' + EOF + + - name: Run PHPStan + run: phpstan analyse --no-progress --error-format=github \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..348adfa --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,68 @@ +name: Tests + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + php: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + wordpress: ['latest', '6.0', '5.9'] + exclude: + # Exclude PHP 8.2 with older WordPress versions that don't support it + - php: '8.2' + wordpress: '5.9' + - php: '8.2' + wordpress: '6.0' + + name: PHP ${{ matrix.php }} / WordPress ${{ matrix.wordpress }} + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: wp_scss_test + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, mysql, mysqli, pdo_mysql, bcmath, soap, intl, gd, exif, iconv, imagick + coverage: none + + - name: Install WordPress test suite + run: | + bash bin/install-wp-tests.sh wp_scss_test root password 127.0.0.1:3306 ${{ matrix.wordpress }} + + - name: Run tests + run: | + cd $GITHUB_WORKSPACE + phpunit + + - name: Run tests with coverage (PHP 8.1 only) + if: matrix.php == '8.1' && matrix.wordpress == 'latest' + run: | + cd $GITHUB_WORKSPACE + phpunit --coverage-clover=coverage.xml + + - name: Upload coverage to Codecov + if: matrix.php == '8.1' && matrix.wordpress == 'latest' + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella \ No newline at end of file diff --git a/README-TESTING.md b/README-TESTING.md new file mode 100644 index 0000000..651ec2e --- /dev/null +++ b/README-TESTING.md @@ -0,0 +1,120 @@ +# Testing WP-SCSS Plugin + +This document explains how to run tests for the WP-SCSS plugin to ensure the settings persistence bug fix and other functionality work correctly. + +## Test Setup + +### Prerequisites + +1. **PHP 7.2+** (same as plugin requirement) +2. **MySQL/MariaDB** for test database +3. **PHPUnit** (automatically installed with WordPress test suite) + +### Installation + +1. **Install WordPress Test Suite:** + ```bash + # Run from plugin root directory + bin/install-wp-tests.sh wp_scss_test root password localhost latest + ``` + + Replace the database credentials as needed: + - `wp_scss_test` - test database name (will be created) + - `root` - database user + - `password` - database password + - `localhost` - database host + +2. **Verify Setup:** + ```bash + # Check if test environment is ready + ls /tmp/wordpress-tests-lib/ + ``` + +## Running Tests + +### Run All Tests +```bash +phpunit +``` + +### Run Specific Test Files +```bash +# Test only settings functionality +phpunit tests/test-settings.php + +# Test only integration scenarios +phpunit tests/test-wp-scss-integration.php +``` + +### Run Specific Test Methods +```bash +# Test the specific bug fix +phpunit --filter test_bug_fix_all_settings_preserved + +# Test base location change scenario +phpunit --filter test_base_location_change_persistence +``` + +## Test Coverage + +### Settings Sanitization Tests (`test-settings.php`) + +- **test_sanitize_preserves_all_fields** - Ensures all 9 form fields are preserved +- **test_sanitize_adds_trailing_slashes** - Verifies directory path formatting +- **test_sanitize_checkbox_fields** - Tests checkbox handling (checked/unchecked) +- **test_sanitize_handles_empty_fields** - Tests behavior with missing data +- **test_sanitize_text_fields** - Tests XSS protection via sanitize_text_field() +- **test_bug_fix_all_settings_preserved** - Regression test for the original bug + +### Integration Tests (`test-wp-scss-integration.php`) + +- **test_settings_persistence_workflow** - Full save/retrieve cycle +- **test_base_location_change_persistence** - Specific bug scenario reproduction +- **test_development_mode_persistence** - Development settings scenarios +- **test_checkbox_form_behavior** - Real WordPress checkbox behavior +- **test_old_sanitize_method_would_fail** - Proves the bug was actually fixed + +## The Bug We Fixed + +**Original Problem:** The `sanitize()` method only preserved `scss_dir` and `css_dir` fields, causing all other settings (base location, compilation mode, etc.) to be lost on save. + +**Tests That Prove the Fix:** +- `test_bug_fix_all_settings_preserved` - Shows all 9 fields are now saved +- `test_base_location_change_persistence` - Reproduces exact user scenario +- `test_old_sanitize_method_would_fail` - Demonstrates the old broken behavior + +## Test Database + +Tests use a separate database (`wp_scss_test` by default) that is: +- Automatically created by the install script +- Cleaned between test runs +- Completely separate from your development/production WordPress + +## Continuous Integration + +These tests can be integrated into CI/CD pipelines: + +```yaml +# Example GitHub Actions workflow +- name: Setup test database + run: | + sudo systemctl start mysql + bin/install-wp-tests.sh wp_scss_test root password localhost latest + +- name: Run tests + run: phpunit +``` + +## Troubleshooting + +**"Could not find wp-tests-config.php"** +- Run the install script: `bin/install-wp-tests.sh ...` + +**Database connection errors** +- Verify MySQL is running +- Check database credentials in install command +- Ensure database user has CREATE privileges + +**Class not found errors** +- Ensure you're running tests from the plugin root directory +- Check that wp-scss.php and options.php exist \ No newline at end of file diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh new file mode 100755 index 0000000..a4a2fcf --- /dev/null +++ b/bin/install-wp-tests.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash + +if [ $# -lt 3 ]; then + echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" + exit 1 +fi + +DB_NAME=$1 +DB_USER=$2 +DB_PASS=$3 +DB_HOST=${4-localhost} +WP_VERSION=${5-latest} +SKIP_DB_CREATE=${6-false} + +TMPDIR=${TMPDIR-/tmp} +TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") +WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} +WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/} + +download() { + if [ `which curl` ]; then + curl -s "$1" > "$2"; + elif [ `which wget` ]; then + wget -nv -O "$2" "$1" + fi +} + +if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then + WP_TESTS_TAG="branches/$WP_VERSION" +elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then + if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then + # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x + WP_TESTS_TAG="tags/${WP_VERSION%??}" + else + WP_TESTS_TAG="tags/$WP_VERSION" + fi +elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + WP_TESTS_TAG="trunk" +else + # http serves a single offer, whereas https serves multiple. we only want one + download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json + grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json + LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') + if [[ -z "$LATEST_VERSION" ]]; then + echo "Latest WordPress version could not be found" + exit 1 + fi + WP_TESTS_TAG="tags/$LATEST_VERSION" +fi + +set -ex + +install_wp() { + + if [ -d $WP_CORE_DIR ]; then + return; + fi + + mkdir -p $WP_CORE_DIR + + if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + mkdir -p $TMPDIR/wordpress-nightly + download https://wordpress.org/nightly-builds/wordpress-latest.zip $TMPDIR/wordpress-nightly/wordpress-nightly.zip + unzip -q $TMPDIR/wordpress-nightly/wordpress-nightly.zip -d $TMPDIR/wordpress-nightly/ + mv $TMPDIR/wordpress-nightly/wordpress/* $WP_CORE_DIR + else + if [ $WP_VERSION == 'latest' ]; then + local ARCHIVE_NAME='latest' + elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then + # https serves multiple offers, whereas http serves single. + download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json + if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then + # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x + LATEST_VERSION=${WP_VERSION%??} + else + # otherwise, use the exact version + LATEST_VERSION=$WP_VERSION + fi + local ARCHIVE_NAME="wordpress-$LATEST_VERSION" + else + local ARCHIVE_NAME="wordpress-$WP_VERSION" + fi + download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz + tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR + fi + + download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php +} + +install_test_suite() { + # portable in-place argument for both GNU sed and Mac OSX sed + if [[ $(uname -s) == 'Darwin' ]]; then + local ioption='-i .bak' + else + local ioption='-i' + fi + + # set up testing suite if it doesn't yet exist + if [ ! -d $WP_TESTS_DIR ]; then + # set up testing suite + mkdir -p $WP_TESTS_DIR + svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes + svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data + fi + + if [ ! -f wp-tests-config.php ]; then + download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php + # remove all forward slashes in the end + WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") + sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php + fi + +} + +install_db() { + + if [ ${SKIP_DB_CREATE} = "true" ]; then + return 0 + fi + + # parse DB_HOST for port or socket references + local PARTS=(${DB_HOST//\:/ }) + local DB_HOSTNAME=${PARTS[0]}; + local DB_SOCK_OR_PORT=${PARTS[1]}; + local EXTRA="" + + if ! [ -z $DB_HOSTNAME ] ; then + if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then + EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" + elif ! [ -z $DB_SOCK_OR_PORT ] ; then + EXTRA=" --socket=$DB_SOCK_OR_PORT" + elif ! [ -z $DB_HOSTNAME ] ; then + EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" + fi + fi + + # create database + mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA +} + +install_wp +install_test_suite +install_db \ No newline at end of file diff --git a/options.php b/options.php index 6288a4e..bce56c6 100644 --- a/options.php +++ b/options.php @@ -253,18 +253,42 @@ public function page_init() { * @param array $input Contains all settings fields as array keys */ public function sanitize( $input ) { - foreach( ['scss_dir', 'css_dir'] as $dir ){ + $new_input = array(); + + // Sanitize directory paths + foreach( ['scss_dir', 'css_dir', 'cache_dir'] as $dir ){ if( !empty( $input[$dir] ) ) { - $input[$dir] = sanitize_text_field( $input[$dir] ); + $new_input[$dir] = sanitize_text_field( $input[$dir] ); // Add a trailing slash if not already present - if(substr($input[$dir], -1) != '/'){ - $input[$dir] .= '/'; + if(substr($new_input[$dir], -1) != '/'){ + $new_input[$dir] .= '/'; } } } - return $input; + // Sanitize other text fields + if( !empty( $input['base_compiling_folder'] ) ) { + $new_input['base_compiling_folder'] = sanitize_text_field( $input['base_compiling_folder'] ); + } + + if( !empty( $input['compiling_options'] ) ) { + $new_input['compiling_options'] = sanitize_text_field( $input['compiling_options'] ); + } + + if( !empty( $input['sourcemap_options'] ) ) { + $new_input['sourcemap_options'] = sanitize_text_field( $input['sourcemap_options'] ); + } + + if( !empty( $input['errors'] ) ) { + $new_input['errors'] = sanitize_text_field( $input['errors'] ); + } + + // Sanitize checkbox fields + $new_input['enqueue'] = !empty( $input['enqueue'] ) ? '1' : '0'; + $new_input['always_recompile'] = !empty( $input['always_recompile'] ) ? '1' : '0'; + + return $new_input; } /** diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..45084e7 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + ./tests/ + + + + + + \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..20536c7 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,30 @@ +settings_instance = new Wp_Scss_Settings(); + } + + public function tearDown(): void { + parent::tearDown(); + + // Clean up options + delete_option( 'wpscss_options' ); + } + + /** + * Test that the sanitize method preserves all form fields + * This tests the bug fix for settings not being saved + */ + public function test_sanitize_preserves_all_fields() { + $input = array( + 'base_compiling_folder' => 'Current Theme', + 'scss_dir' => '/scss/', + 'css_dir' => '/css/', + 'cache_dir' => '/cache/', + 'compiling_options' => 'compressed', + 'sourcemap_options' => 'SOURCE_MAP_NONE', + 'errors' => 'show', + 'enqueue' => '1', + 'always_recompile' => '1' + ); + + $result = $this->settings_instance->sanitize( $input ); + + // Test that all fields are preserved + $this->assertEquals( 'Current Theme', $result['base_compiling_folder'] ); + $this->assertEquals( '/scss/', $result['scss_dir'] ); + $this->assertEquals( '/css/', $result['css_dir'] ); + $this->assertEquals( '/cache/', $result['cache_dir'] ); + $this->assertEquals( 'compressed', $result['compiling_options'] ); + $this->assertEquals( 'SOURCE_MAP_NONE', $result['sourcemap_options'] ); + $this->assertEquals( 'show', $result['errors'] ); + $this->assertEquals( '1', $result['enqueue'] ); + $this->assertEquals( '1', $result['always_recompile'] ); + } + + /** + * Test directory path sanitization with trailing slash addition + */ + public function test_sanitize_adds_trailing_slashes() { + $input = array( + 'scss_dir' => '/scss', + 'css_dir' => '/css', + 'cache_dir' => '/cache' + ); + + $result = $this->settings_instance->sanitize( $input ); + + $this->assertEquals( '/scss/', $result['scss_dir'] ); + $this->assertEquals( '/css/', $result['css_dir'] ); + $this->assertEquals( '/cache/', $result['cache_dir'] ); + } + + /** + * Test that directory paths already with trailing slashes are preserved + */ + public function test_sanitize_preserves_existing_trailing_slashes() { + $input = array( + 'scss_dir' => '/scss/', + 'css_dir' => '/css/', + 'cache_dir' => '/cache/' + ); + + $result = $this->settings_instance->sanitize( $input ); + + $this->assertEquals( '/scss/', $result['scss_dir'] ); + $this->assertEquals( '/css/', $result['css_dir'] ); + $this->assertEquals( '/cache/', $result['cache_dir'] ); + } + + /** + * Test checkbox field handling + */ + public function test_sanitize_checkbox_fields() { + // Test checkbox checked + $input_checked = array( + 'enqueue' => '1', + 'always_recompile' => '1' + ); + + $result = $this->settings_instance->sanitize( $input_checked ); + $this->assertEquals( '1', $result['enqueue'] ); + $this->assertEquals( '1', $result['always_recompile'] ); + + // Test checkbox unchecked (empty) + $input_unchecked = array(); + + $result = $this->settings_instance->sanitize( $input_unchecked ); + $this->assertEquals( '0', $result['enqueue'] ); + $this->assertEquals( '0', $result['always_recompile'] ); + } + + /** + * Test handling of empty/missing fields + */ + public function test_sanitize_handles_empty_fields() { + $input = array( + 'base_compiling_folder' => '', + 'scss_dir' => '', + 'css_dir' => '/css/', + 'compiling_options' => '', + ); + + $result = $this->settings_instance->sanitize( $input ); + + // Empty fields should not be set in result + $this->assertArrayNotHasKey( 'base_compiling_folder', $result ); + $this->assertArrayNotHasKey( 'scss_dir', $result ); + $this->assertArrayNotHasKey( 'compiling_options', $result ); + + // Non-empty fields should be preserved + $this->assertEquals( '/css/', $result['css_dir'] ); + + // Checkboxes should default to '0' + $this->assertEquals( '0', $result['enqueue'] ); + $this->assertEquals( '0', $result['always_recompile'] ); + } + + /** + * Test text field sanitization + */ + public function test_sanitize_text_fields() { + $input = array( + 'base_compiling_folder' => 'Current Theme', + 'compiling_options' => 'compressed