diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 57617779..bf7966bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.build.outputs.version }} - artifact_name: ${{ steps.build.outputs.name }} + artifact_name: ${{ steps.build.outputs.artifact_name }} artifact_url: ${{ steps.artifacts.outputs.artifact-url }} artifact_id: ${{ steps.artifacts.outputs.artifact-id }} steps: @@ -51,8 +51,12 @@ jobs: npm install && npm run bundle name=$(jq -r .name package.json) + version=$(jq -r .version package.json) + artifact_name="${name}.${version}" + echo "name=$name" >> $GITHUB_OUTPUT - echo "version=$(jq -r .version package.json)" >> $GITHUB_OUTPUT + echo "version=$version" >> $GITHUB_OUTPUT + echo "artifact_name=$artifact_name" >> $GITHUB_OUTPUT mkdir -p ./upload/$name mv ./bundle/* ./upload/$name/ 2>/dev/null || true @@ -61,5 +65,5 @@ jobs: id: artifacts uses: actions/upload-artifact@v4 with: - name: ${{ steps.build.outputs.name }} + name: ${{ steps.build.outputs.artifact_name }} path: ./upload diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index 7103c136..59f5b4a9 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -11,11 +11,46 @@ on: required: true type: string description: 'Playwright project name to run' + multisite: + required: false + type: boolean + default: false + description: 'If true, convert the site to multisite and create a subsite.' jobs: playwright-test: name: Playwright tests (${{ inputs.test-mode == 'default' && 'Default Mode' || 'File-based Execution' }}) runs-on: ubuntu-22.04 + services: + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: wordpress + MYSQL_USER: wordpress + MYSQL_PASSWORD: wordpress + MYSQL_ROOT_PASSWORD: root + options: >- + --health-cmd="mysqladmin ping -h localhost -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + wordpress: + image: wordpress:php8.1-apache + env: + WORDPRESS_DB_HOST: mysql:3306 + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: wordpress + WORDPRESS_DB_NAME: wordpress + WORDPRESS_DEBUG: 1 + WORDPRESS_CONFIG_EXTRA: | + define( 'FS_METHOD', 'direct' ); + define( 'WP_DEBUG_LOG', true ); + define( 'WP_DEBUG_DISPLAY', false ); + define( 'SCRIPT_DEBUG', true ); + define( 'WP_ENVIRONMENT_TYPE', 'local' ); + ports: + - 8888:80 steps: - name: Checkout source code uses: actions/checkout@v4 @@ -55,45 +90,153 @@ jobs: path: | node_modules src/vendor - key: ${{ runner.os }}-deps-${{ steps.deps-hash.outputs.deps_hash }} + key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}-${{ github.run_id }}-${{ github.job }} restore-keys: | - ${{ runner.os }}-deps- + ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}- - - name: Install workflow dependencies (wp-env, playwright) - if: steps.deps-cache.outputs.cache-hit != 'true' + - name: Install workflow dependencies + if: steps.deps-cache.outputs.cache-matched-key == '' run: npm run prepare-environment:ci && npm run bundle - name: Save vendor and node_modules cache - if: steps.deps-cache.outputs.cache-hit != 'true' + if: steps.deps-cache.outputs.cache-matched-key == '' uses: actions/cache/save@v4 with: path: | src/vendor node_modules - key: ${{ runner.os }}-deps-${{ steps.deps-hash.outputs.deps_hash }} + key: ${{ steps.deps-cache.outputs.cache-primary-key }} - - name: Start WordPress environment + - name: Wait for WordPress to be reachable + run: | + for i in $(seq 1 60); do + if curl -fsS http://localhost:8888/wp-login.php >/dev/null; then + echo "WordPress is reachable." + exit 0 + fi + echo "Waiting for WordPress... ($i/60)" + sleep 2 + done + + echo "WordPress did not start in time." + echo "::group::WordPress container logs" + docker logs "${{ job.services.wordpress.id }}" || true + echo "::endgroup::" + exit 1 + + - name: Download WP-CLI + run: | + curl -fsSL -o "${RUNNER_TEMP}/wp-cli.phar" https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar + + - name: "Install WordPress: ${{ inputs.multisite && 'Multisite' || 'Single Site' }}" + env: + WP_CONTAINER: ${{ job.services.wordpress.id }} + WP_URL: http://localhost:8888 + WP_ADMIN_USER: admin + WP_ADMIN_PASSWORD: password + WP_ADMIN_EMAIL: admin@example.org run: | - npx wp-env start + set -euo pipefail + + docker cp "${RUNNER_TEMP}/wp-cli.phar" "$WP_CONTAINER:/tmp/wp-cli.phar" + + # Install WordPress if it isn't already installed. + if ! docker exec -u root -w /var/www/html "$WP_CONTAINER" php /tmp/wp-cli.phar core is-installed --allow-root >/dev/null 2>&1; then + docker exec -u root -w /var/www/html "$WP_CONTAINER" php /tmp/wp-cli.phar core install \ + --url="$WP_URL" \ + --title="Test Blog" \ + --admin_user="$WP_ADMIN_USER" \ + --admin_password="$WP_ADMIN_PASSWORD" \ + --admin_email="$WP_ADMIN_EMAIL" \ + --skip-email \ + --allow-root + fi + + if [ "${{ inputs.multisite }}" = "true" ]; then + # Convert single site -> multisite (subdirectory). Subdomains don't work with localhost. + if ! docker exec -u root -w /var/www/html "$WP_CONTAINER" php /tmp/wp-cli.phar core is-installed --network --allow-root >/dev/null 2>&1; then + docker exec -u root -w /var/www/html "$WP_CONTAINER" php /tmp/wp-cli.phar core multisite-convert \ + --title="Test Network" \ + --base=/ \ + --allow-root + fi + + # Create a subsite for future multisite test coverage. + if ! docker exec -u root -w /var/www/html "$WP_CONTAINER" php /tmp/wp-cli.phar site list --field=path --allow-root | grep -qx "/subsite/"; then + docker exec -u root -w /var/www/html "$WP_CONTAINER" php /tmp/wp-cli.phar site create \ + --slug=subsite \ + --title="Subsite" \ + --email="$WP_ADMIN_EMAIL" \ + --allow-root + fi + fi + + - name: Install plugin into WordPress container + env: + WP_CONTAINER: ${{ job.services.wordpress.id }} + run: | + set -euo pipefail + + docker exec -u root -w /var/www/html "$WP_CONTAINER" rm -rf wp-content/plugins/code-snippets + docker cp src "$WP_CONTAINER:/var/www/html/wp-content/plugins/code-snippets" + docker exec -u root -w /var/www/html "$WP_CONTAINER" chown -R www-data:www-data wp-content/plugins/code-snippets - name: Activate code-snippets plugin - run: npx wp-env run cli wp plugin activate code-snippets + env: + WP_CONTAINER: ${{ job.services.wordpress.id }} + run: | + set -euo pipefail + if [ "${{ inputs.multisite }}" = "true" ]; then + docker exec -u root -w /var/www/html "$WP_CONTAINER" php /tmp/wp-cli.phar plugin activate code-snippets --network --allow-root + else + docker exec -u root -w /var/www/html "$WP_CONTAINER" php /tmp/wp-cli.phar plugin activate code-snippets --allow-root + fi - name: WordPress debug information + env: + WP_CONTAINER: ${{ job.services.wordpress.id }} run: | - npx wp-env run cli wp core version - npx wp-env run cli wp --info + docker exec -u root -w /var/www/html "$WP_CONTAINER" php /tmp/wp-cli.phar core version --allow-root + docker exec -u root -w /var/www/html "$WP_CONTAINER" php /tmp/wp-cli.phar --allow-root --info + + - name: Restore Playwright browsers cache + id: playwright-browsers-cache + uses: actions/cache/restore@v4 + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-browsers-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}-${{ github.job }} + restore-keys: | + ${{ runner.os }}-playwright-browsers-${{ hashFiles('package-lock.json') }}- - name: Install playwright/test + if: steps.playwright-browsers-cache.outputs.cache-matched-key == '' run: | npx playwright install chromium + - name: Save Playwright browsers cache + if: steps.playwright-browsers-cache.outputs.cache-matched-key == '' + uses: actions/cache/save@v4 + with: + path: | + ~/.cache/ms-playwright + key: ${{ steps.playwright-browsers-cache.outputs.cache-primary-key }} + - name: Run Playwright tests + env: + WP_E2E_WPCLI_MODE: gh-actions-ci + WP_E2E_WPCLI_PHAR: /tmp/wp-cli.phar + WP_E2E_WPCLI_URL: http://localhost:8888 + WP_E2E_WP_CONTAINER: ${{ job.services.wordpress.id }} + WP_E2E_MULTISITE_MODE: ${{ inputs.multisite }} run: npm run test:playwright -- --project=${{ inputs.project-name }} - - - name: Stop WordPress environment - if: always() - run: npx wp-env stop + + - name: Print WordPress logs on failure + if: failure() + run: | + echo "::group::WordPress container logs" + docker logs "${{ job.services.wordpress.id }}" || true + echo "::endgroup::" - uses: actions/upload-artifact@v4 if: always() diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 4d3274f8..51f39bd4 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -20,23 +20,25 @@ permissions: actions: read concurrency: - group: playwright-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: playwright-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}-${{ (github.event_name != 'pull_request' || (contains(github.event.pull_request.labels.*.name, 'run-tests') && (github.event.action != 'labeled' || github.event.label.name == 'run-tests' || github.event.label.name == 'multisite'))) && 'tests' || github.run_id }} + cancel-in-progress: ${{ github.event_name != 'pull_request' || (contains(github.event.pull_request.labels.*.name, 'run-tests') && (github.event.action != 'labeled' || github.event.label.name == 'run-tests' || github.event.label.name == 'multisite')) }} jobs: playwright-default: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') + if: github.event_name != 'pull_request' || (contains(github.event.pull_request.labels.*.name, 'run-tests') && (github.event.action != 'labeled' || github.event.label.name == 'run-tests' || github.event.label.name == 'multisite')) uses: ./.github/workflows/playwright-test.yml with: test-mode: 'default' project-name: 'chromium-db-snippets' + multisite: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-tests') && contains(github.event.pull_request.labels.*.name, 'multisite') }} playwright-file-based-execution: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') + if: github.event_name != 'pull_request' || (contains(github.event.pull_request.labels.*.name, 'run-tests') && (github.event.action != 'labeled' || github.event.label.name == 'run-tests' || github.event.label.name == 'multisite')) uses: ./.github/workflows/playwright-test.yml with: test-mode: 'file-based-execution' project-name: 'chromium-file-based-snippets' + multisite: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-tests') && contains(github.event.pull_request.labels.*.name, 'multisite') }} test-result: needs: [playwright-default, playwright-file-based-execution] diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 26675139..c214cf59 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -2,27 +2,169 @@ name: "(Pull Request): Build" on: pull_request: - types: [labeled] + types: [labeled, unlabeled] + +permissions: + contents: read + issues: write + actions: write concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: pr-build-${{ github.workflow }}-${{ github.event.pull_request.number }}-${{ github.event.label.name == 'build' && 'build' || github.run_id }} + cancel-in-progress: ${{ github.event.label.name == 'build' && github.event.action == 'unlabeled' }} jobs: + gate: + if: github.event.action == 'labeled' && github.event.label.name == 'build' + runs-on: ubuntu-latest + outputs: + should_build: ${{ steps.gate.outputs.should_build }} + steps: + - name: Check for in-progress build runs + id: gate + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + workflow_file="pull-request.yml" + other_run_ids="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_file}/runs?per_page=100" --paginate \ + | jq -r --argjson pr "${PR_NUMBER}" --argjson current "${GITHUB_RUN_ID}" ' + .workflow_runs[] + | select(.status != "completed") + | select(.id != $current) + | select(any(.pull_requests[]?; .number == $pr)) + | .id + ')" + + if [ -n "${other_run_ids}" ]; then + echo "::notice::Another build workflow run is already in progress; ignoring this trigger." + echo "::notice::In-progress run ids: ${other_run_ids//$'\n'/, }" + echo "should_build=false" >> "${GITHUB_OUTPUT}" + else + echo "should_build=true" >> "${GITHUB_OUTPUT}" + fi + + cleanup: + if: github.event.action == 'unlabeled' && github.event.label.name == 'build' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + steps: + - name: Cancel running build workflows for this PR + run: | + set -euo pipefail + + workflow_file="pull-request.yml" + run_ids="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_file}/runs?per_page=100" --paginate \ + | jq -r --argjson pr "${PR_NUMBER}" --argjson current "${GITHUB_RUN_ID}" ' + .workflow_runs[] + | select(.status != "completed") + | select(.id != $current) + | select(any(.pull_requests[]?; .number == $pr)) + | .id + ')" + + if [ -z "${run_ids}" ]; then + echo "::notice::No in-progress build workflow runs to cancel." + exit 0 + fi + + echo "::group::Canceling build workflow runs" + for run_id in ${run_ids}; do + echo "Canceling run ${run_id}" + gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/cancel" || true + done + echo "::endgroup::" + + - name: Delete build comments + run: | + set -euo pipefail + + marker="" + legacy_prefixes_regex='Build started.. a link to the built zip file will appear here soon..|### Download and install' + bot_logins_regex='^(code-snippets-bot|github-actions\\[bot\\])$' + + comment_ids="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ + | jq -r --arg marker "${marker}" \ + --arg legacy_prefixes_regex "${legacy_prefixes_regex}" \ + --arg bot_logins_regex "${bot_logins_regex}" ' + .[] + | select(.user.login | test($bot_logins_regex)) + | select(.body | contains($marker) or test($legacy_prefixes_regex)) + | .id + ')" + + if [ -z "${comment_ids}" ]; then + echo "::notice::No build comments found to delete." + exit 0 + fi + + echo "::group::Deleting build comments" + for comment_id in ${comment_ids}; do + echo "Deleting comment ${comment_id}" + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" || true + done + echo "::endgroup::" + comment: - if: contains(github.event.pull_request.labels.*.name, 'build') + needs: gate + if: needs.gate.outputs.should_build == 'true' runs-on: ubuntu-latest - outputs: - comment_id: ${{ steps.comment.outputs.comment-id }} + outputs: + comment_id: ${{ steps.comment.outputs.comment_id }} + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} steps: - - name: Comment + - name: Delete previous build comments + run: | + set -euo pipefail + + marker="" + legacy_prefixes_regex='Build started.. a link to the built zip file will appear here soon..|### Download and install' + bot_logins_regex='^(code-snippets-bot|github-actions\\[bot\\])$' + + comment_ids="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ + | jq -r --arg marker "${marker}" \ + --arg legacy_prefixes_regex "${legacy_prefixes_regex}" \ + --arg bot_logins_regex "${bot_logins_regex}" ' + .[] + | select(.user.login | test($bot_logins_regex)) + | select(.body | contains($marker) or test($legacy_prefixes_regex)) + | .id + ')" + + if [ -z "${comment_ids}" ]; then + echo "::notice::No previous build comments found." + exit 0 + fi + + echo "::group::Deleting previous build comments" + for comment_id in ${comment_ids}; do + echo "Deleting comment ${comment_id}" + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" || true + done + echo "::endgroup::" + + - name: Create build comment id: comment - uses: codesnippetspro/create-or-update-comment@v4 - with: - issue-number: ${{ github.event.pull_request.number }} - body: | - 👌 Build started.. a link to the built zip file will appear here soon.. - + run: | + set -euo pipefail + + marker="" + body="$(cat <> "${GITHUB_OUTPUT}" + install: needs: comment uses: ./.github/workflows/build.yml @@ -35,10 +177,18 @@ jobs: steps: - name: Publish comment if: ${{ needs.install.outputs.artifact_name && needs.install.outputs.artifact_url }} - uses: codesnippetspro/create-or-update-comment@v4 - with: - edit-mode: replace - comment-id: ${{ needs.comment.outputs.comment_id }} - body: | - ### Download and install - 📦 [${{ needs.install.outputs.artifact_name }}.${{ needs.install.outputs.version }}.zip](${{ needs.install.outputs.artifact_url }}) + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + COMMENT_ID: ${{ needs.comment.outputs.comment_id }} + run: | + set -euo pipefail + + marker="" + body="$(cat <..zip - zip_name="${{ needs.build.outputs.artifact_name }}.${{ github.event.release.tag_name }}.zip" + # zip filename format: .zip + zip_name="${{ needs.build.outputs.artifact_name }}.zip" cd ./bundle/${{ needs.build.outputs.artifact_name }} zip -r "../$zip_name" . @@ -89,4 +89,3 @@ jobs: echo "::notice::Monitor workflow progress at: $run_url" echo "workflow_url=$run_url" >> $GITHUB_OUTPUT fi - diff --git a/src/php/class-db.php b/src/php/class-db.php index 84ab399c..4254dc51 100644 --- a/src/php/class-db.php +++ b/src/php/class-db.php @@ -197,53 +197,95 @@ public static function create_table( string $table_name ): bool { return $success; } + /** + * Generate the SQL for fetching active snippets from the database. + * + * @param string[] $scopes List of scopes to retrieve in. + * + * @return array{ + * id: int, + * code: string, + * scope: string, + * table: string, + * network: bool, + * priority: int, + * } List of active snippets. + */ + public function fetch_active_snippets( array $scopes ): array { + $active_snippets = []; + + // Fetch the active snippets for the current site, if there are any. + $snippets = $this->fetch_snippets_from_table( $this->table, $scopes, true ); + if ( $snippets ) { + foreach ( $snippets as $snippet ) { + $active_snippets[] = [ + 'id' => intval( $snippet['id'] ), + 'code' => $snippet['code'], + 'scope' => $snippet['scope'], + 'table' => $this->table, + 'network' => false, + 'priority' => intval( $snippet['priority'] ), + ]; + } + } + + // If multisite is enabled, fetch all snippets from the network table, and filter down to only active snippets. + if ( is_multisite() ) { + $ms_snippets = $this->fetch_snippets_from_table( $this->ms_table, $scopes, false ); + + if ( $ms_snippets ) { + $active_shared_ids = get_option( 'active_shared_network_snippets', [] ); + $active_shared_ids = is_array( $active_shared_ids ) + ? array_map( 'intval', $active_shared_ids ) + : []; + + foreach ( $ms_snippets as $snippet ) { + $id = intval( $snippet['id'] ); + $active_value = intval( $snippet['active'] ); + + if ( ! self::is_network_snippet_enabled( $active_value, $id, $active_shared_ids ) ) { + continue; + } + + $active_snippets[] = [ + 'id' => $id, + 'code' => $snippet['code'], + 'scope' => $snippet['scope'], + 'table' => $this->ms_table, + 'network' => true, + 'priority' => intval( $snippet['priority'] ), + ]; + } + + $this->sort_active_snippets( $active_snippets ); + } + } + + return $active_snippets; + } + /** - * Fetch a list of active snippets from a database table. + * Determine whether a network snippet should execute on the current site. * - * @param string $table_name Name of table to fetch snippets from. - * @param array $scopes List of scopes to include in query. - * @param boolean $active_only Whether to only fetch active snippets from the table. + * Network snippets execute when active=1, or when the snippet is listed as active-shared for the site. + * Trashed snippets (active=-1) should never execute. * - * @return array>|false List of active snippets, if any could be retrieved. + * @param int $active_value Raw active value: 1=active, 0=inactive, -1=trashed (can be stored as a string in the database). + * @param int $snippet_id Snippet ID. + * @param int[] $active_shared_ids Active shared network snippet IDs for the current site. * - * @phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + * @return bool */ - private static function fetch_snippets_from_table( string $table_name, array $scopes, bool $active_only = true ) { - global $wpdb; - - $cache_key = sprintf( 'active_snippets_%s_%s', sanitize_key( join( '_', $scopes ) ), $table_name ); - $cached_snippets = wp_cache_get( $cache_key, CACHE_GROUP ); - - if ( is_array( $cached_snippets ) ) { - return $cached_snippets; - } - - if ( ! self::table_exists( $table_name ) ) { + public static function is_network_snippet_enabled( int $active_value, int $snippet_id, array $active_shared_ids ): bool { + if ( -1 === $active_value ) { return false; } - $scopes_format = implode( ',', array_fill( 0, count( $scopes ), '%s' ) ); - $extra_where = $active_only ? 'AND active=1' : ''; - - $snippets = $wpdb->get_results( - $wpdb->prepare( - " - SELECT id, code, scope, active, priority - FROM $table_name - WHERE scope IN ($scopes_format) $extra_where - ORDER BY priority, id", - $scopes - ), - 'ARRAY_A' - ); - - // Cache the full list of snippets. - if ( is_array( $snippets ) ) { - wp_cache_set( $cache_key, $snippets, CACHE_GROUP ); - return $snippets; + if ( 1 === $active_value ) { + return true; } - return false; + return in_array( $snippet_id, $active_shared_ids, true ); } /** @@ -282,68 +324,51 @@ static function ( $a, $b ) use ( $comparisons ) { } /** - * Generate the SQL for fetching active snippets from the database. + * Fetch a list of active snippets from a database table. * - * @param string[] $scopes List of scopes to retrieve in. + * @param string $table_name Name of table to fetch snippets from. + * @param array $scopes List of scopes to include in query. + * @param boolean $active_only Whether to only fetch active snippets from the table. * - * @return array{ - * id: int, - * code: string, - * scope: string, - * table: string, - * network: bool, - * priority: int, - * } List of active snippets. + * @return array>|false List of active snippets, if any could be retrieved. + * + * @phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare */ - public function fetch_active_snippets( array $scopes ): array { - $active_snippets = []; - - // Fetch the active snippets for the current site, if there are any. - $snippets = $this->fetch_snippets_from_table( $this->table, $scopes, true ); - if ( $snippets ) { - foreach ( $snippets as $snippet ) { - $active_snippets[] = [ - 'id' => intval( $snippet['id'] ), - 'code' => $snippet['code'], - 'scope' => $snippet['scope'], - 'table' => $this->table, - 'network' => false, - 'priority' => intval( $snippet['priority'] ), - ]; - } - } + private static function fetch_snippets_from_table( string $table_name, array $scopes, bool $active_only = true ) { + global $wpdb; - // If multisite is enabled, fetch all snippets from the network table, and filter down to only active snippets. - if ( is_multisite() ) { - $ms_snippets = $this->fetch_snippets_from_table( $this->ms_table, $scopes, false ); + $cache_key = sprintf( 'active_snippets_%s_%s', sanitize_key( join( '_', $scopes ) ), $table_name ); + $cached_snippets = wp_cache_get( $cache_key, CACHE_GROUP ); - if ( $ms_snippets ) { - $active_shared_ids = get_option( 'active_shared_network_snippets', [] ); - $active_shared_ids = is_array( $active_shared_ids ) - ? array_map( 'intval', $active_shared_ids ) - : []; + if ( is_array( $cached_snippets ) ) { + return $cached_snippets; + } - foreach ( $ms_snippets as $snippet ) { - $id = intval( $snippet['id'] ); + if ( ! self::table_exists( $table_name ) ) { + return false; + } - if ( ! $snippet['active'] && ! in_array( $id, $active_shared_ids, true ) ) { - continue; - } + $scopes_format = implode( ',', array_fill( 0, count( $scopes ), '%s' ) ); + $extra_where = $active_only ? 'AND active=1' : ''; - $active_snippets[] = [ - 'id' => $id, - 'code' => $snippet['code'], - 'scope' => $snippet['scope'], - 'table' => $this->ms_table, - 'network' => true, - 'priority' => intval( $snippet['priority'] ), - ]; - } + $snippets = $wpdb->get_results( + $wpdb->prepare( + " + SELECT id, code, scope, active, priority + FROM $table_name + WHERE scope IN ($scopes_format) $extra_where + ORDER BY priority, id", + $scopes + ), + 'ARRAY_A' + ); - $this->sort_active_snippets( $active_snippets ); - } + // Cache the full list of snippets. + if ( is_array( $snippets ) ) { + wp_cache_set( $cache_key, $snippets, CACHE_GROUP ); + return $snippets; } - return $active_snippets; + return false; } } diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index bb76103e..7f5390c6 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -361,6 +361,10 @@ public function get_cap(): string { return $this->get_network_cap_name(); } + if ( is_multisite() && ! $this->is_subsite_menu_enabled() ) { + return $this->get_network_cap_name(); + } + return $this->get_cap_name(); } diff --git a/src/php/flat-files/classes/class-snippet-files.php b/src/php/flat-files/classes/class-snippet-files.php index ae75156a..85a2ae62 100644 --- a/src/php/flat-files/classes/class-snippet-files.php +++ b/src/php/flat-files/classes/class-snippet-files.php @@ -2,6 +2,12 @@ namespace Code_Snippets; +/** + * Manage file-based snippet execution. + * + * Responsible for writing snippet code to disk, maintaining per-table config indexes, + * and retrieving the active snippet list from those config files. + */ class Snippet_Files { /** @@ -9,12 +15,34 @@ class Snippet_Files { */ private const ENABLED_FLAG_FILE = 'flat-files-enabled.flag'; + /** + * Snippet handler registry. + * + * @var Snippet_Handler_Registry + */ private Snippet_Handler_Registry $handler_registry; + /** + * File system adapter. + * + * @var File_System_Interface + */ private File_System_Interface $fs; + /** + * Config repository. + * + * @var Snippet_Config_Repository_Interface + */ private Snippet_Config_Repository_Interface $config_repo; + /** + * Constructor. + * + * @param Snippet_Handler_Registry $handler_registry Handler registry instance. + * @param File_System_Interface $fs File system adapter. + * @param Snippet_Config_Repository_Interface $config_repo Config repository instance. + */ public function __construct( Snippet_Handler_Registry $handler_registry, File_System_Interface $fs, @@ -36,10 +64,22 @@ public static function is_active(): bool { return file_exists( $flag_file_path ); } + /** + * Get the full path to the flat-file enabled flag. + * + * @return string + */ private static function get_flag_file_path(): string { return self::get_base_dir() . '/' . self::ENABLED_FLAG_FILE; } + /** + * Create or delete the enabled flag file. + * + * @param bool $enabled Whether file-based execution is enabled. + * + * @return void + */ private function handle_enabled_file_flag( bool $enabled ): void { $flag_file_path = self::get_flag_file_path(); @@ -53,6 +93,11 @@ private function handle_enabled_file_flag( bool $enabled ): void { } } + /** + * Register WordPress hooks used by file-based execution. + * + * @return void + */ public function register_hooks(): void { if ( ! $this->fs->is_writable( WP_CONTENT_DIR ) ) { return; @@ -72,9 +117,17 @@ public function register_hooks(): void { } add_filter( 'code_snippets_settings_fields', [ $this, 'add_settings_fields' ], 10, 1 ); - add_action( 'code_snippets/settings_updated', [ $this, 'create_all_flat_files' ], 10, 2 ); + add_action( 'code_snippets/settings_updated', [ $this, 'create_all_flat_files' ], 10, 1 ); } + /** + * Activate multiple snippets and regenerate their flat files. + * + * @param Snippet[] $valid_snippets Snippets to activate. + * @param string $table Table name. + * + * @return void + */ public function activate_snippets( $valid_snippets, $table ): void { foreach ( $valid_snippets as $snippet ) { $snippet->active = true; @@ -82,6 +135,14 @@ public function activate_snippets( $valid_snippets, $table ): void { } } + /** + * Write a snippet file and update its config index entry. + * + * @param Snippet $snippet Snippet object. + * @param string $table Table name. + * + * @return void + */ public function handle_snippet( Snippet $snippet, string $table ): void { if ( 0 === $snippet->id ) { return; @@ -106,6 +167,14 @@ public function handle_snippet( Snippet $snippet, string $table ): void { $this->config_repo->update( $base_dir, $snippet ); } + /** + * Delete a snippet file and remove it from the config index. + * + * @param Snippet $snippet Snippet object. + * @param bool $network Whether the snippet is network-wide. + * + * @return void + */ public function delete_snippet( Snippet $snippet, bool $network ): void { $handler = $this->handler_registry->get_handler( $snippet->type ); @@ -122,6 +191,13 @@ public function delete_snippet( Snippet $snippet, bool $network ): void { $this->config_repo->update( $base_dir, $snippet, true ); } + /** + * Activate a snippet by writing its code file and updating config. + * + * @param Snippet $snippet Snippet object. + * + * @return void + */ public function activate_snippet( Snippet $snippet ): void { $snippet = get_snippet( $snippet->id, $snippet->network ); $handler = $this->handler_registry->get_handler( $snippet->type ); @@ -144,6 +220,14 @@ public function activate_snippet( Snippet $snippet ): void { $this->config_repo->update( $base_dir, $snippet ); } + /** + * Deactivate a snippet by updating its config entry. + * + * @param int $snippet_id Snippet ID. + * @param bool $network Whether the snippet is network-wide. + * + * @return void + */ public function deactivate_snippet( int $snippet_id, bool $network ): void { $snippet = get_snippet( $snippet_id, $network ); $handler = $this->handler_registry->get_handler( $snippet->type ); @@ -158,6 +242,14 @@ public function deactivate_snippet( int $snippet_id, bool $network ): void { $this->config_repo->update( $base_dir, $snippet ); } + /** + * Get the base directory for flat files. + * + * @param string $table Optional hashed table name. + * @param string $snippet_type Optional snippet type directory. + * + * @return string + */ public static function get_base_dir( string $table = '', string $snippet_type = '' ): string { $base_dir = WP_CONTENT_DIR . '/code-snippets'; @@ -172,6 +264,14 @@ public static function get_base_dir( string $table = '', string $snippet_type = return $base_dir; } + /** + * Get the base URL for flat files. + * + * @param string $table Optional hashed table name. + * @param string $snippet_type Optional snippet type directory. + * + * @return string + */ public static function get_base_url( string $table = '', string $snippet_type = '' ): string { $base_url = WP_CONTENT_URL . '/code-snippets'; @@ -186,6 +286,13 @@ public static function get_base_url( string $table = '', string $snippet_type = return $base_url; } + /** + * Create a directory if it does not exist. + * + * @param string $dir Directory path. + * + * @return void + */ private function maybe_create_directory( string $dir ): void { if ( ! $this->fs->is_dir( $dir ) ) { $result = wp_mkdir_p( $dir ); @@ -196,16 +303,41 @@ private function maybe_create_directory( string $dir ): void { } } + /** + * Build the file path for a snippet's code file. + * + * @param string $base_dir Base directory path. + * @param int $snippet_id Snippet ID. + * @param string $ext File extension. + * + * @return string + */ private function get_snippet_file_path( string $base_dir, int $snippet_id, string $ext ): string { return trailingslashit( $base_dir ) . $snippet_id . '.' . $ext; } + /** + * Delete a file if it exists. + * + * @param string $file_path File path. + * + * @return void + */ private function delete_file( string $file_path ): void { if ( $this->fs->exists( $file_path ) ) { $this->fs->delete( $file_path ); } } + /** + * Sync the active shared network snippets list to a config file. + * + * @param string $option Option name. + * @param mixed $old_value Previous value. + * @param mixed $value New value. + * + * @return void + */ public function sync_active_shared_network_snippets( $option, $old_value, $value ): void { if ( 'active_shared_network_snippets' !== $option ) { return; @@ -214,6 +346,14 @@ public function sync_active_shared_network_snippets( $option, $old_value, $value $this->create_active_shared_network_snippets_file( $value ); } + /** + * Sync the active shared network snippets list to a config file when first added. + * + * @param string $option Option name. + * @param mixed $value Option value. + * + * @return void + */ public function sync_active_shared_network_snippets_add( $option, $value ): void { if ( 'active_shared_network_snippets' !== $option ) { return; @@ -222,6 +362,13 @@ public function sync_active_shared_network_snippets_add( $option, $value ): void $this->create_active_shared_network_snippets_file( $value ); } + /** + * Create or update the active shared network snippets config file. + * + * @param mixed $value Option value. + * + * @return void + */ private function create_active_shared_network_snippets_file( $value ): void { $table = self::get_hashed_table_name( code_snippets()->db->get_table_name( false ) ); $base_dir = self::get_base_dir( $table ); @@ -229,15 +376,31 @@ private function create_active_shared_network_snippets_file( $value ): void { $this->maybe_create_directory( $base_dir ); $file_path = trailingslashit( $base_dir ) . 'active-shared-network-snippets.php'; + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- var_export is required for writing PHP config files. $file_content = "fs->put_contents( $file_path, $file_content, FS_CHMOD_FILE ); } + /** + * Hash a table name for file system usage. + * + * @param string $table Table name. + * + * @return string + */ public static function get_hashed_table_name( string $table ): string { return wp_hash( $table ); } + /** + * Get a list of active snippets from flat file config. + * + * @param array $scopes Scopes to include. + * @param string $snippet_type Snippet type directory. + * + * @return array> + */ public static function get_active_snippets_from_flat_files( array $scopes = [], $snippet_type = 'php' @@ -245,7 +408,8 @@ public static function get_active_snippets_from_flat_files( $active_snippets = []; $db = code_snippets()->db; - $table = self::get_hashed_table_name( $db->get_table_name() ); + // Always use the site table for "local" snippets, even in Network Admin. + $table = self::get_hashed_table_name( $db->get_table_name( false ) ); $snippets = self::load_active_snippets_from_file( $table, $snippet_type, @@ -289,8 +453,9 @@ public static function get_active_snippets_from_flat_files( foreach ( $ms_snippets as $snippet ) { $id = intval( $snippet['id'] ); + $active_value = intval( $snippet['active'] ); - if ( ! $snippet['active'] && ! in_array( $id, $active_shared_ids, true ) ) { + if ( ! DB::is_network_snippet_enabled( $active_value, $id, $active_shared_ids ) ) { continue; } @@ -312,7 +477,15 @@ public static function get_active_snippets_from_flat_files( return $active_snippets; } - private static function sort_active_snippets( array &$active_snippets, $db ): void { + /** + * Sort active snippet entries for execution order. + * + * @param array> $active_snippets Active snippets list. + * @param DB $db Database instance. + * + * @return void + */ + private static function sort_active_snippets( array &$active_snippets, DB $db ): void { $comparisons = [ function ( array $a, array $b ) { return $a['priority'] <=> $b['priority']; @@ -342,6 +515,16 @@ static function ( $a, $b ) use ( $comparisons ) { ); } + /** + * Load active snippets from a flat file config index. + * + * @param string $table Hashed table directory name. + * @param string $snippet_type Snippet type directory. + * @param string[] $scopes Scopes to include. + * @param int[]|null $active_shared_ids Optional list of active shared network snippet IDs. + * + * @return array> + */ private static function load_active_snippets_from_file( string $table, string $snippet_type, @@ -371,19 +554,16 @@ private static function load_active_snippets_from_file( } $file_snippets = require $snippets_file_path; + $shared_ids = is_array( $active_shared_ids ) + ? array_map( 'intval', $active_shared_ids ) + : []; $filtered_snippets = array_filter( $file_snippets, - function ( $snippet ) use ( $scopes, $active_shared_ids ) { - $is_active = $snippet['active']; - - if ( null !== $active_shared_ids ) { - $is_active = $is_active || in_array( - intval( $snippet['id'] ), - $active_shared_ids, - true - ); - } + function ( $snippet ) use ( $scopes, $shared_ids ) { + $active_value = isset( $snippet['active'] ) ? intval( $snippet['active'] ) : 0; + + $is_active = DB::is_network_snippet_enabled( $active_value, intval( $snippet['id'] ), $shared_ids ); return ( $is_active || 'condition' === $snippet['scope'] ) && in_array( $snippet['scope'], $scopes, true ); } @@ -394,6 +574,13 @@ function ( $snippet ) use ( $scopes, $active_shared_ids ) { return $filtered_snippets; } + /** + * Add file-based execution settings fields. + * + * @param array $fields Settings fields. + * + * @return array + */ public function add_settings_fields( array $fields ): array { $fields['general']['enable_flat_files'] = [ 'name' => __( 'Enable file-based execution', 'code-snippets' ), @@ -408,7 +595,14 @@ public function add_settings_fields( array $fields ): array { return $fields; } - public function create_all_flat_files( array $settings, array $input ): void { + /** + * Recreate all flat files when file-based execution settings are updated. + * + * @param array $settings Settings data. + * + * @return void + */ + public function create_all_flat_files( array $settings ): void { if ( ! isset( $settings['general']['enable_flat_files'] ) ) { return; } @@ -423,6 +617,11 @@ public function create_all_flat_files( array $settings, array $input ): void { $this->create_active_shared_network_snippets_config_file(); } + /** + * Create snippet code files and config indexes for all active snippets. + * + * @return void + */ private function create_snippet_flat_files(): void { $db = code_snippets()->db; @@ -457,6 +656,11 @@ private function create_snippet_flat_files(): void { } } + /** + * Create active shared network snippet config files for each site (multisite) or the current site. + * + * @return void + */ private function create_active_shared_network_snippets_config_file(): void { if ( is_multisite() ) { $current_blog_id = get_current_blog_id(); diff --git a/tests/e2e/code-snippets-evaluation.spec.ts b/tests/e2e/code-snippets-evaluation.spec.ts index 48f11e93..3bccb588 100644 --- a/tests/e2e/code-snippets-evaluation.spec.ts +++ b/tests/e2e/code-snippets-evaluation.spec.ts @@ -1,8 +1,7 @@ -import util from 'util' -import { exec } from 'child_process' import { expect, test } from '@playwright/test' import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' import { SELECTORS } from './helpers/constants' +import { wpCli } from './helpers/wpCli' import type { Page } from '@playwright/test' const TEST_SNIPPET_NAME = 'E2E Snippet Test' @@ -34,27 +33,22 @@ const verifyShortcodeRendersCorrectly = async ( } const createPageWithShortcode = async (snippetId: string): Promise => { - const execAsync = util.promisify(exec) - const shortcode = `[code_snippet id=${snippetId} format name="${TEST_SNIPPET_NAME}"]` const pageContent = `

Page content before shortcode.

\n\n${shortcode}\n\n

Page content after shortcode.

` try { - const createPageCmd = [ - 'npx wp-env run cli wp post create', + const pageId = (await wpCli([ + 'post', + 'create', '--post_type=page', - '--post_title="Test Page for Snippet Shortcode"', - `--post_content='${pageContent}'`, + '--post_title=Test Page for Snippet Shortcode', + `--post_content=${pageContent}`, '--post_status=publish', '--porcelain' - ].join(' ') - - const { stdout } = await execAsync(createPageCmd) - const pageId = stdout.trim() + ])).trim() - const getUrlCmd = `npx wp-env run cli wp post url ${pageId}` - const { stdout: pageUrl } = await execAsync(getUrlCmd) - return pageUrl.trim() + const pageUrl = (await wpCli(['post', 'url', pageId])).trim() + return pageUrl } catch (error) { console.error('Failed to create page via WP-CLI:', error) throw error diff --git a/tests/e2e/flat-files.setup.ts b/tests/e2e/flat-files.setup.ts index 3a9307c4..6859f3ae 100644 --- a/tests/e2e/flat-files.setup.ts +++ b/tests/e2e/flat-files.setup.ts @@ -1,7 +1,9 @@ import { expect, test as setup } from '@playwright/test' setup('enable flat files', async ({ page }) => { - await page.goto('/wp-admin/admin.php?page=snippets-settings') + const wpAdminbase = process.env.WP_E2E_MULTISITE_MODE ? '/wp-admin/network' : '/wp-admin' + + await page.goto(`${wpAdminbase}/admin.php?page=snippets-settings`) await page.waitForSelector('#wpbody-content') await page.waitForSelector('form') diff --git a/tests/e2e/helpers/wpCli.ts b/tests/e2e/helpers/wpCli.ts new file mode 100644 index 00000000..99b3e68b --- /dev/null +++ b/tests/e2e/helpers/wpCli.ts @@ -0,0 +1,49 @@ +import util from 'util' +import { execFile } from 'child_process' + +export interface WpCliOptions { + url?: string +} + +const execFileAsync = util.promisify(execFile) + +const hasArg = (args: string[], prefix: string): boolean => + args.some(arg => arg === prefix || arg.startsWith(`${prefix}=`)) + +export const wpCli = async (args: string[], options: WpCliOptions = {}): Promise => { + const mode = (process.env.WP_E2E_WPCLI_MODE ?? '').toLowerCase() + const dockerContainer = process.env.WP_E2E_WP_CONTAINER + + const url = options.url ?? process.env.WP_E2E_WPCLI_URL + const urlArgs = url && !hasArg(args, '--url') ? [`--url=${url}`] : [] + + if (dockerContainer || 'gh-actions-ci' === mode) { + if (!dockerContainer) { + throw new Error('WP_E2E_WP_CONTAINER must be set when WP_E2E_WPCLI_MODE is gh-actions-ci.') + } + + const pharPath = process.env.WP_E2E_WPCLI_PHAR ?? '/tmp/wp-cli.phar' + const allowRootArgs = hasArg(args, '--allow-root') ? [] : ['--allow-root'] + + const { stdout } = await execFileAsync('docker', [ + 'exec', + '-u', + 'root', + '-w', + '/var/www/html', + dockerContainer, + 'php', + pharPath, + ...urlArgs, + ...allowRootArgs, + ...args + ]) + + return stdout + } + + // Default to wp-env (local dev) for backwards compatibility. + const { stdout } = await execFileAsync('npx', ['wp-env', 'run', 'cli', 'wp', ...urlArgs, ...args]) + + return stdout +}