From a99c61e2f633694abb3c74f8544d67eef2726d4d Mon Sep 17 00:00:00 2001 From: Chris Reynolds Date: Tue, 6 May 2025 13:20:36 -0600 Subject: [PATCH 1/5] Add DevRel to CODEOWNERS (#184) Update CODEOWNERS --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 632c6150..f16c59dd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @pantheon-systems/site-experience +* @pantheon-systems/site-experience @pantheon-systems/devrel From 2be707320e5d77e421175c3a2ee94753e809d7b2 Mon Sep 17 00:00:00 2001 From: Chris Reynolds Date: Tue, 6 May 2025 13:21:12 -0600 Subject: [PATCH 2/5] Fixes missing script error (#183) * update php versions * fix typo in maybe-create-symlinks --- upstream-configuration/scripts/ComposerScripts.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/upstream-configuration/scripts/ComposerScripts.php b/upstream-configuration/scripts/ComposerScripts.php index 07caf0a4..a89d3679 100644 --- a/upstream-configuration/scripts/ComposerScripts.php +++ b/upstream-configuration/scripts/ComposerScripts.php @@ -108,7 +108,7 @@ public static function applyComposerJsonUpdates(Event $event) } } - $maybe_add_symlinks = '@maybe-add-symlinks'; + $maybe_add_symlinks = '@maybe-create-symlinks'; // Check if @maybe-add-symlinks is already in post-update-cmd. If not, add it. if (!in_array($maybe_add_symlinks, $composerJson['scripts']['post-update-cmd'])) { $io->write("Adding $maybe_add_symlinks to post-update-cmd hook"); @@ -206,14 +206,16 @@ private static function bestPhpPatchVersion($pantheonPhpVersion) { // Integrated Composer requires PHP 7.1 at a minimum. $patchVersions = [ - '8.2' => '8.2.0', - '8.1' => '8.1.13', - '8.0' => '8.0.26', + '8.3' => '8.3.14', + '8.2' => '8.2.26', + '8.1' => '8.1.31', + // EOL final patch version below this line. + '8.0' => '8.0.30', '7.4' => '7.4.33', '7.3' => '7.3.33', '7.2' => '7.2.34', '7.1' => '7.1.33', - ]; + ]; if (isset($patchVersions[$pantheonPhpVersion])) { return $patchVersions[$pantheonPhpVersion]; } From f5853ff7c690feca7de15f1268e0bd2bd47ac133 Mon Sep 17 00:00:00 2001 From: Chris Reynolds Date: Tue, 6 May 2025 13:26:08 -0600 Subject: [PATCH 3/5] set service level to pro on site creation for playwright tests (#177) set service level to pro on site creation --- devops/scripts/setup-playwright-tests.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/devops/scripts/setup-playwright-tests.sh b/devops/scripts/setup-playwright-tests.sh index 5aedad8d..e22bbb6b 100755 --- a/devops/scripts/setup-playwright-tests.sh +++ b/devops/scripts/setup-playwright-tests.sh @@ -39,6 +39,8 @@ create_site() { echo "Test site already exists, skipping site creation." else terminus site:create "${site_id}" "${site_name}" "${UPSTREAM_NAME}" --org=5ae1fa30-8cc4-4894-8ca9-d50628dcba17 + echo "Site created. Setting site plan to 'pro'" + terminus service-level:set "${site_id}" pro fi terminus connection:set "${site_id}".dev git -y } From 18352c7edb912411975ba8aad05290ac64a689f5 Mon Sep 17 00:00:00 2001 From: Chris Reynolds Date: Thu, 8 May 2025 15:09:11 -0600 Subject: [PATCH 4/5] Update tests --- .../fixtures/config/application.subdom.php | 15 +- .github/tests/2-rest-url-fix.bats | 198 ++++++++++++++++++ .github/tests/custom-test.ts | 16 ++ .github/tests/wpcm.spec.ts | 4 +- .github/workflows/ci.yml | 95 ++++++++- .../workflows/manual-delete-test-sites.yml | 59 ++++++ .../workflows/{playwright.yml => test.yml} | 74 ++++++- devops/scripts/setup-playwright-tests.sh | 7 +- 8 files changed, 439 insertions(+), 29 deletions(-) create mode 100644 .github/tests/2-rest-url-fix.bats create mode 100644 .github/tests/custom-test.ts create mode 100644 .github/workflows/manual-delete-test-sites.yml rename .github/workflows/{playwright.yml => test.yml} (84%) diff --git a/.github/fixtures/config/application.subdom.php b/.github/fixtures/config/application.subdom.php index 4e62aed5..3fb643e5 100644 --- a/.github/fixtures/config/application.subdom.php +++ b/.github/fixtures/config/application.subdom.php @@ -137,17 +137,10 @@ /** * Debugging Settings */ -if ( ( isset( $_ENV['PANTHEON_ENVIRONMENT'] ) && $_ENV['PANTHEON_ENVIRONMENT'] === 'dev' ) || isset( $_ENV['LANDO'] ) ) { - Config::define( 'WP_DEBUG_DISPLAY', true ); - Config::define( 'WP_DEBUG_LOG', true ); - Config::define( 'SCRIPT_DEBUG', true ); - ini_set( 'display_errors', '1' ); -} else { - Config::define( 'WP_DEBUG_DISPLAY', false ); - Config::define( 'WP_DEBUG_LOG', false ); - Config::define( 'SCRIPT_DEBUG', false ); - ini_set( 'display_errors', '0' ); -} +Config::define( 'WP_DEBUG_DISPLAY', false ); +Config::define( 'WP_DEBUG_LOG', true ); +Config::define( 'SCRIPT_DEBUG', false ); +ini_set( 'display_errors', '0' ); /** * Allow WordPress to detect HTTPS when used behind a reverse proxy or a load balancer diff --git a/.github/tests/2-rest-url-fix.bats b/.github/tests/2-rest-url-fix.bats new file mode 100644 index 00000000..8c78c0a6 --- /dev/null +++ b/.github/tests/2-rest-url-fix.bats @@ -0,0 +1,198 @@ +#!/usr/bin/env bats + +export BATS_LIB_PATH="${BATS_LIB_PATH:-/usr/lib}" +bats_load_library bats-support +bats_load_library bats-assert + +# wp wrapper function +_wp() { + terminus wp -- ${SITE_ID}.dev "$@" +} + +# Helper function to get REST URL via WP-CLI +get_rest_url() { + _wp eval 'echo get_rest_url();' +} + +# Helper function to get home_url path via WP-CLI +get_home_url_path() { + _wp eval 'echo rtrim(parse_url(home_url(), PHP_URL_PATH) ?: "", "/");' +} + +# Helper function to check if it's a multisite installation +_is_multisite() { + # This command exits with 0 if it's a network (multisite) installation, + # and 1 otherwise. We suppress output as we only care about the exit code. + if _wp core is-installed --network > /dev/null 2>&1; then + return 0 # true, it is multisite + else + return 1 # false, it is not multisite + fi +} + +set_permalinks_to_pretty() { + _wp option update permalink_structure '/%postname%/' --quiet +} + +unset_pretty_permalinks() { + _wp option update permalink_structure '' --quiet +} + +flush_rewrites() { + _wp rewrite flush --hard --quiet +} + +setup_suite() { + # Ensure WP is installed and we are in the right directory + if ! _wp core is-installed; then + echo "WordPress not installed. Run setup script first." + exit 1 + fi +} + +teardown_test() { + flush_rewrites # Call your helper +} + +@test "Check REST URL with default (pretty) permalinks" { + set_permalinks_to_pretty + flush_rewrites + + local rest_api_base_path + if _is_multisite; then + rest_api_base_path="/wp/wp-json/" + else + rest_api_base_path="/wp-json/" + fi + SITE_URL="https://dev-${SITE_ID}.pantheonsite.io${rest_api_base_path}" + + run curl -s -o /dev/null -w '%{http_code}:%{content_type}' -L "${SITE_URL}" + assert_success "curl command failed to access ${SITE_URL}" + # Assert that the final HTTP status code is 200 (OK) and application/json + assert_output --partial "200:" "Expected HTTP 200 for ${SITE_URL}. Output: $output" + assert_output --partial ":application/json" "Expected Content-Type application/json for ${SITE_URL}. Output: $output" +} + +@test "Check REST URL with plain permalinks" { + # Set plain permalinks and flush + unset_pretty_permalinks + flush_rewrites + + run get_rest_url + assert_success + # With plain permalinks, expect ?rest_route= based on home_url + # Check if it contains the problematic /wp-json/wp/ segment (it shouldn't) + refute_output --partial "/wp-json/wp/" + # Check if it contains the expected ?rest_route= + assert_output --partial "?rest_route=/" + + # Restore pretty permalinks for subsequent tests + set_permalinks_to_pretty +} + +@test "Check REST URL with pretty permalinks *before* flush (Simulates new site)" { + # Set pretty permalinks *without* flushing + set_permalinks_to_pretty + # DO NOT FLUSH HERE + + local rest_api_base_path + + # Check home_url path to confirm /wp setup + if _is_multisite; then + run get_home_url_path + assert_success + assert_output --partial "/wp" + rest_api_base_path="/wp/wp-json/" + else + rest_api_base_path="/wp-json/" + fi + + SITE_URL="https://dev-${SITE_ID}.pantheonsite.io${rest_api_base_path}" + + run curl -s -o /dev/null -w '%{http_code}:%{content_type}' -L "${SITE_URL}" + assert_success "curl command failed to access ${SITE_URL} (before flush)" + # Assert that the final HTTP status code is 200 (OK) and application/json + # This assumes the fix ensures the correct URL works even before flushing. + assert_output --partial "200:" "Expected HTTP 200 for ${SITE_URL} (before flush). Output: $output" + assert_output --partial ":application/json" "Expected Content-Type application/json for ${SITE_URL} (before flush). Output: $output" +} + +@test "Access pretty REST API path directly with plain permalinks active" { + # Set plain permalinks and flush + unset_pretty_permalinks + flush_rewrites + + # Construct the pretty-style REST API URL for a specific endpoint + local base_domain="https://dev-${SITE_ID}.pantheonsite.io" + local rest_endpoint_full_path + if _is_multisite; then + # For multisite in /wp/, the REST API base is /wp/wp-json/ + rest_endpoint_full_path="/wp/wp-json/wp/v2/posts" + else + # For single site, the REST API base is /wp-json/ + rest_endpoint_full_path="/wp-json/wp/v2/posts" + fi + TEST_URL="${base_domain}${rest_endpoint_full_path}" + + # Make a curl request to the pretty URL + run curl -s -o /dev/null -w '%{http_code}:%{content_type}' -L "${TEST_URL}" + assert_success "curl command failed for ${TEST_URL}. Output: $output" + # Assert that the final HTTP status code is 200 (OK) and application/json + assert_output --partial "200:" "Expected HTTP 200 for ${TEST_URL}. Output: $output" + assert_output --partial ":application/json" "Expected Content-Type application/json for ${TEST_URL}. Output: $output" + + # Restore pretty permalinks for subsequent tests + set_permalinks_to_pretty +} + +@test "Validate REST API JSON output for 'hello-world' post (with plain permalinks)" { + unset_pretty_permalinks + + # Hardcode known post ID + local POST_ID=1 + local base_domain="https://dev-${SITE_ID}.pantheonsite.io" + local rest_api_base_path + if _is_multisite; then + rest_api_base_path="/wp/wp-json/" + else + rest_api_base_path="/wp-json/" + fi + local BASE_URL="${base_domain}${rest_api_base_path}" + local HELLO_WORLD_API_URL="${BASE_URL}wp/v2/posts/${POST_ID}" + + # Create temp file for body + local BODY_FILE + BODY_FILE=$(mktemp) + + # curl writes body to BODY_FILE, metadata to stdout (captured by 'run') + run curl -s -L -o "$BODY_FILE" \ + -w "HTTP_STATUS:%{http_code}\nCONTENT_TYPE:%{content_type}" \ + "${HELLO_WORLD_API_URL}" + + assert_success "curl command failed for ${HELLO_WORLD_API_URL}. Output: $output" + + # Parse and assert metadata from $output + HTTP_STATUS=$(echo "$output" | grep "HTTP_STATUS:" | cut -d: -f2) + CONTENT_TYPE=$(echo "$output" | grep "CONTENT_TYPE:" | cut -d: -f2-) + + assert_equal "$HTTP_STATUS" "200" "HTTP status was '$HTTP_STATUS', expected '200'. Full metadata: $output" + + echo "$CONTENT_TYPE" | grep -q "application/json" + assert_success "Content-Type was '$CONTENT_TYPE', expected to contain 'application/json'. Full metadata: $output" + + JSON_BODY=$(cat "$BODY_FILE") + + echo "$JSON_BODY" | jq -e . > /dev/null + assert_success "Response body is not valid JSON. Body: $JSON_BODY" + + run jq -e ".id == ${POST_ID}" <<< "$JSON_BODY" + assert_success "JSON .id mismatch. Expected ${POST_ID}. Body: $JSON_BODY" + + run jq -e '.slug == "hello-world"' <<< "$JSON_BODY" + assert_success "JSON .slug mismatch. Expected 'hello-world'. Body: $JSON_BODY" + + run jq -e '.title.rendered == "Hello world!"' <<< "$JSON_BODY" + assert_success "JSON .title.rendered mismatch. Expected 'Hello world!'. Body: $JSON_BODY" + + set_permalinks_to_pretty +} diff --git a/.github/tests/custom-test.ts b/.github/tests/custom-test.ts new file mode 100644 index 00000000..e49e8f83 --- /dev/null +++ b/.github/tests/custom-test.ts @@ -0,0 +1,16 @@ +import { test as baseTest, expect as baseExpect } from '@playwright/test'; + +// Extend base test by providing a custom 'page' fixture. +export const test = baseTest.extend({ + page: async ({ page }, use) => { + // Set custom headers for all page navigations/requests initiated by the page. + await page.setExtraHTTPHeaders({ + 'Deterrence-Bypass': 'true', + }); + // Continue with the test, providing the modified page fixture. + await use(page); + }, +}); + +// Re-export expect so you can import it from this file as well. +export { baseExpect as expect }; diff --git a/.github/tests/wpcm.spec.ts b/.github/tests/wpcm.spec.ts index daa4d933..232836f8 100644 --- a/.github/tests/wpcm.spec.ts +++ b/.github/tests/wpcm.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./custom-test"; const exampleArticle = "Hello world!"; const siteTitle = process.env.SITE_NAME || "WPCM Playwright Tests"; @@ -7,6 +7,7 @@ let graphqlEndpoint = process.env.GRAPHQL_ENDPOINT || `${siteUrl}/wp/graphql`; test("homepage loads and contains example content", async ({ page }) => { await page.goto(siteUrl); + await expect(page).toHaveTitle(siteTitle); await expect(page.getByText(exampleArticle)).toHaveText(exampleArticle); }); @@ -22,6 +23,7 @@ test("WP REST API is accessible", async ({ request }) => { test("Hello World post is accessible", async ({ page }) => { await page.goto(`${siteUrl}/hello-world/'`); + await expect(page).toHaveTitle(`${exampleArticle} – ${siteTitle}`); // Locate the element containing the desired text const welcomeText = page.locator('text=Welcome to WordPress'); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77eb6278..296d7939 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-version: ['8.1', '8.2', '8.3'] + php-version: ['8.1', '8.2', '8.3', '8.4'] steps: - uses: actions/checkout@v4 @@ -39,22 +39,97 @@ jobs: restore-keys: ${{ runner.os }}-composer- - name: Check Commits + id: commit_check # Add an ID to access outputs run: | # Don't warn about detached head. git config advice.detachedHead false - git fetch --all - git checkout -b default origin/default + git fetch --all --prune # Fetch all refs and remove stale remote-tracking branches + + # Ensure the local 'default' branch reflects 'origin/default' + git checkout -B default origin/default + + # Checkout the actual head of the pull request branch git checkout ${{ github.event.pull_request.head.sha }} - echo "This script does preliminary checks to make sure that the commits in a PR made in this repository are ready for the deploy-public-upstream script. i.e. any given commit modifies either 'normal' or 'non-release' files, never mixed." - bash ${{ github.workspace }}/devops/scripts/check-commits.sh || echo "commit_check_failed=1" >> $GITHUB_ENV - - name: Comment on PR if commit check failed - if: env.commit_check_failed == '1' + echo "Running check-commits.sh to analyze PR commits..." + + script_exit_code=0 + # Execute the script, capturing all its stderr. + script_stderr_output=$(bash ${{ github.workspace }}/devops/scripts/check-commits.sh 2>&1) || script_exit_code=$? + + echo "--- Script Standard Error Output (and stdout if any) ---" + echo "${script_stderr_output}" + echo "--- End Script Output ---" + echo "Script exit code: $script_exit_code" + + # Script itself failed (e.g., mixed files in a single commit, forbidden files) + commit_script_failed_output="false" + if [ "$script_exit_code" -ne 0 ]; then + commit_script_failed_output="true" + fi + echo "commit_script_failed=${commit_script_failed_output}" >> $GITHUB_OUTPUT + + # Mixture of 'normal' and 'non-release' commit types across the PR + has_normal_commits=$(echo "${script_stderr_output}" | grep -c "is a normal commit" || true) + has_nonrelease_commits=$(echo "${script_stderr_output}" | grep -c "is a non-release commit" || true) + + echo "Normal commits found: $has_normal_commits" + echo "Non-release commits found: $has_nonrelease_commits" + + mixed_commit_types_in_pr_output="false" + if [ "$has_normal_commits" -gt 0 ] && [ "$has_nonrelease_commits" -gt 0 ]; then + mixed_commit_types_in_pr_output="true" + fi + echo "mixed_commit_types_in_pr=${mixed_commit_types_in_pr_output}" >> $GITHUB_OUTPUT + + # Prepare overall error summary for the comment + final_error_summary="" + if [ "${commit_script_failed_output}" == "true" ]; then + # Extract lines that look like errors from the script + script_reported_errors=$(echo "${script_stderr_output}" | grep -E "contains both release and nonrelease changes|contains forbidden files" || true) + if [ -n "${script_reported_errors}" ]; then + final_error_summary+="Script reported the following issues with specific commits:\n${script_reported_errors}\n\n" + else + # If script failed but no specific errors were grepped, include the full output for context + final_error_summary+="The check-commits.sh script failed (exit code $script_exit_code). Full script output for context:\n${script_stderr_output}\n\n" + fi + fi + + if [ "${mixed_commit_types_in_pr_output}" == "true" ]; then + final_error_summary+="This PR contains a mixture of 'normal' (release) commits and 'non-release' (internal) commits. This requires careful merging (e.g., rebase and merge) to ensure only 'normal' commits are deployed to public upstream if that's the intent." + fi + + echo "final_error_summary<> $GITHUB_OUTPUT + echo -e "${final_error_summary}" >> $GITHUB_OUTPUT + echo "ERROR_SUMMARY_EOF" >> $GITHUB_OUTPUT + + - name: Comment on PR if commit check failed or types are mixed + # Trigger if the script failed OR if there's a mix of commit types + if: steps.commit_check.outputs.commit_script_failed == 'true' || steps.commit_check.outputs.mixed_commit_types_in_pr == 'true' env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + ERROR_DETAILS: ${{ steps.commit_check.outputs.final_error_summary }} run: | - gh pr comment ${{ github.event.pull_request.number }} -b "Hi from your friendly robot! :robot: It looks like there might be commits to both release and non-release files in this PR. Please review and remove any commits that don't belong." - exit 1 + COMMENT_MARKER="" + + # Check for existing comment with the marker + EXISTING_COMMENT_ID=$(gh pr view $PR_NUMBER --json comments -q ".comments[] | select(.body | contains(\"${COMMENT_MARKER}\")) | .id" || echo "") + + if [ -n "$EXISTING_COMMENT_ID" ]; then + echo "Commit check comment already exists (ID: $EXISTING_COMMENT_ID). Skipping new comment." + else + echo "No existing commit check comment found. Posting a new one." + COMMENT_BODY="Hi from your friendly robot! :robot: ${COMMENT_MARKER} + Please review the commit checks for this PR: + + > ${ERROR_DETAILS} + + If issues are present, please ensure commits modify either 'normal' or 'non-release' files (not a mix within a single commit) and do not contain forbidden files. + If this PR intentionally mixes 'normal' and 'non-release' commit types, remember to use **rebase and merge** rather than **squash** when merging to preserve individual commit integrity for the deploy process." + + gh pr comment $PR_NUMBER -b "$COMMENT_BODY" + fi - name: Check Composer lock file is up to date run: composer validate --no-check-all @@ -77,7 +152,7 @@ jobs: env: CI: 1 run: | - bats -p -t .github/tests + bats -p -t .github/tests/1-test-update-php.bats - name: Create failure status artifact if: failure() diff --git a/.github/workflows/manual-delete-test-sites.yml b/.github/workflows/manual-delete-test-sites.yml new file mode 100644 index 00000000..19b9210c --- /dev/null +++ b/.github/workflows/manual-delete-test-sites.yml @@ -0,0 +1,59 @@ +name: (Manual) Delete test sites +on: + workflow_dispatch: + # Use inputs to specify whether to delete a specific site or all sites. + inputs: + site: + description: 'Site to delete ' + required: true + default: 'all' + type: choice + options: + - all + - wpcm-playwright-tests + - wpcm-subdir-playwright-tests + +jobs: + delete-test-sites: + runs-on: ubuntu-latest + steps: + - name: Install SSH keys + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Validate Pantheon Host Key + shell: bash + run: | + echo "Host *.drush.in HostKeyAlgorithms +ssh-rsa" >> ~/.ssh/config + echo "Host *.drush.in PubkeyAcceptedKeyTypes +ssh-rsa" >> ~/.ssh/config + echo "StrictHostKeyChecking no" >> ~/.ssh/config + + - name: Install Terminus + uses: pantheon-systems/terminus-github-actions@v1 + with: + pantheon-machine-token: ${{ secrets.TERMINUS_TOKEN }} + + - name: Delete test sites + run: | + # Check if the input is 'all' or a specific site + if [[ "${{ github.event.inputs.site }}" == "all" ]]; then + echo "Deleting all test sites..." + # List of all test sites to delete + sites_to_delete=( + "wpcm-playwright-tests" + "wpcm-subdir-playwright-tests" + ) + else + echo "Single site selected: ${{ github.event.inputs.site }}" + # If a specific site is provided, use it + sites_to_delete=("${{ github.event.inputs.site }}") + fi + + for site in "${sites_to_delete[@]}"; do + echo "Deleting site: $site" + # Downgrade the site to a free plan, if applicable. + terminus plan:set $site plan-free-preferred-monthly-1 || true + terminus site:delete $site -y + done + echo "All done! 🧹" diff --git a/.github/workflows/playwright.yml b/.github/workflows/test.yml similarity index 84% rename from .github/workflows/playwright.yml rename to .github/workflows/test.yml index 33793be1..df91249a 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: WordPress Composer Playwright Tests +name: WordPress Composer Tests on: pull_request: paths-ignore: @@ -7,6 +7,7 @@ on: - '.github/workflows/phpcbf.yml' - '.github/workflows/sage-test.yml' - '.github/workflows/sync-default.yml' + - '.github/workflows/manual-delete-test-sites.yml' - '.github/tests/*.bats' - 'private/scripts/**' - 'devops/**' @@ -29,13 +30,17 @@ permissions: jobs: - playwright-single: + test-single: name: Single site runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install Bats + uses: bats-core/bats-action@3.0.1 + id: setup-bats + - name: Wait for status artifacts env: GH_TOKEN: ${{ github.token }} @@ -154,18 +159,35 @@ jobs: SITE_URL: ${{ env.SITE_URL }} run: npm run test .github/tests/wpcm.spec.ts + - name: Run Bats tests for URL fixes (Single Site) + env: + SITE_ID: wpcm-playwright-tests + TERMINUS_TOKEN: ${{ secrets.TERMINUS_TOKEN }} + run: | + echo "Running REST URL Bats tests..." + terminus auth:login --machine-token="${TERMINUS_TOKEN}" || echo "Terminus already logged in." + export BATS_LIB_PATH=${{ steps.setup-bats.outputs.lib-path }} + bats -p -t .github/tests/2-rest-url-fix.bats + - name: Delete Site if: success() shell: bash - run: terminus site:delete wpcm-playwright-tests -y + run: | + # Downgrade the site to a free plan, if applicable. + terminus plan:set wpcm-playwright-tests plan-free-preferred-monthly-1 || true + terminus site:delete wpcm-playwright-tests -y - playwright-subdir: + test-subdir: name: Subdirectory multisite runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install Bats + uses: bats-core/bats-action@3.0.1 + id: setup-bats + - name: Wait for status artifacts env: GH_TOKEN: ${{ github.token }} @@ -287,18 +309,40 @@ jobs: echo "Running Playwright tests on WordPress subdirectory subsite" npm run test .github/tests/wpcm.spec.ts + - name: Run Bats tests for URL fixes + # This step runs *after* the site setup, including the initial permalink flush. + # The Bats test itself handles permalink changes for specific test cases. + # We need to pass the SITE_ID to the Bats test environment so WP-CLI commands work via Terminus. + env: + SITE_ID: wpcm-subdir-playwright-tests + SUBSITE: foo + TERMINUS_TOKEN: ${{ secrets.TERMINUS_TOKEN }} + run: | + echo "Running REST URL Bats tests..." + # Ensure Terminus is logged in if needed by Bats WP-CLI calls + terminus auth:login --machine-token="${TERMINUS_TOKEN}" || echo "Terminus already logged in." + export BATS_LIB_PATH=${{ steps.setup-bats.outputs.lib-path }} + bats -p -t .github/tests/2-rest-url-fix.bats + - name: Delete Site if: success() shell: bash - run: terminus site:delete wpcm-subdir-playwright-tests -y + run: | + # Downgrade the site to a free plan, if applicable. + terminus plan:set wpcm-subdir-playwright-tests plan-free-preferred-monthly-1 || true + terminus site:delete wpcm-subdir-playwright-tests -y - playwright-subdom: + test-subdom: name: Subdomain multisite runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install Bats + uses: bats-core/bats-action@3.0.1 + id: setup-bats + - name: Wait for status artifacts env: GH_TOKEN: ${{ github.token }} @@ -452,6 +496,13 @@ jobs: echo "${{ env.SUBDOMAIN_URL }} - ${SUBDOMAIN_URL_TEST}" exit 1 fi + + # Ensure permalinks are pretty for playwright tests + echo "Setting permalink structure" + terminus wp "${{ env.SITE_ID }}".dev -- option update permalink_structure '/%postname%/' + terminus wp "${{ env.SITE_ID }}".dev -- rewrite flush + terminus wp "${{ env.SITE_ID }}".dev -- cache flush + terminus env:clear-cache "${{ env.SITE_ID }}".dev - name: Run Playwright tests on main site env: SITE_NAME: ${{ env.SITE_NAME }} @@ -464,3 +515,14 @@ jobs: SITE_URL: ${{ env.SUBDOMAIN_URL }} GRAPHQL_ENDPOINT: ${{ env.SUBDOMAIN_URL }}/wp/graphql run: npm run test .github/tests/wpcm.spec.ts + + - name: Run Bats tests for URL fixes (Subdomain) + env: + SITE_ID: wpcm-subdom-playwright-tests + SUBSITE: foo + TERMINUS_TOKEN: ${{ secrets.TERMINUS_TOKEN }} + run: | + echo "Running REST URL Bats tests..." + terminus auth:login --machine-token="${TERMINUS_TOKEN}" || echo "Terminus already logged in." + export BATS_LIB_PATH=${{ steps.setup-bats.outputs.lib-path }} + bats -p -t .github/tests/2-rest-url-fix.bats diff --git a/devops/scripts/setup-playwright-tests.sh b/devops/scripts/setup-playwright-tests.sh index e22bbb6b..7d6f8f79 100755 --- a/devops/scripts/setup-playwright-tests.sh +++ b/devops/scripts/setup-playwright-tests.sh @@ -40,7 +40,7 @@ create_site() { else terminus site:create "${site_id}" "${site_name}" "${UPSTREAM_NAME}" --org=5ae1fa30-8cc4-4894-8ca9-d50628dcba17 echo "Site created. Setting site plan to 'pro'" - terminus service-level:set "${site_id}" pro + terminus plan:set "${site_id}" plan-performance_small-contract-annual-1 fi terminus connection:set "${site_id}".dev git -y } @@ -94,10 +94,14 @@ install_wp() { fi terminus wp "${site_id}".dev -- core multisite-install --title="${site_name}" --admin_user=wpcm --admin_email=test@dev.null --subdomains="$is_subdomains" --url="${site_url}" +} +setup_permalinks() { + echo "Setting permalink structure" terminus wp "${site_id}".dev -- option update permalink_structure '/%postname%/' terminus wp "${site_id}".dev -- rewrite flush terminus wp "${site_id}".dev -- cache flush + terminus env:clear-cache "${site_id}".dev } status_check() { @@ -182,4 +186,5 @@ install_wp status_check set_up_subsite install_wp_graphql +setup_permalinks echo -e "${GREEN}Done${RESET} ✨" From 6f750fbc534d76c068790a4a45d6f9f0ccb3fea8 Mon Sep 17 00:00:00 2001 From: Chris Reynolds Date: Thu, 8 May 2025 15:13:35 -0600 Subject: [PATCH 5/5] Fixes a Composer script error and an issue where WP REST API urls would be broken on newly created sites. For more information see https://docs.pantheon.io/release-notes/2025/05/wordpress-composer-managed-1-33-0 --- CHANGELOG.md | 4 + web/app/mu-plugins/filters.php | 149 ++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a8888d2..6ce43de4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### v1.33.0 (2025-05-08) +* Fixes an issue where we were running `maybe-add-symlinks` but the script didn't exist. ([#183](https://github.com/pantheon-systems/wordpress-composer-managed/pull/183)) +* Fixes an issue where WP REST API urls would break on new sites before "pretty permalink" structure was set. ([#186](https://github.com/pantheon-systems/wordpress-composer-managed/pull/186)) + ### v1.32.5 (2025-02-10) * Adds the `maybe-install-symlinks` Composer script to `post-update-cmd` hook. This ensures that symlinks are created (and the `web/index.php` file is re-created) after a `composer update`. ([#175](https://github.com/pantheon-systems/wordpress-composer-managed/pull/175)) diff --git a/web/app/mu-plugins/filters.php b/web/app/mu-plugins/filters.php index df9d0984..ab6a51bd 100644 --- a/web/app/mu-plugins/filters.php +++ b/web/app/mu-plugins/filters.php @@ -3,7 +3,7 @@ * Plugin Name: Pantheon WordPress Filters * Plugin URI: https://github.com/pantheon-systems/wordpress-composer-managed * Description: Filters for Composer-managed WordPress sites on Pantheon. - * Version: 1.2.2 + * Version: 1.2.3 * Author: Pantheon Systems * Author URI: https://pantheon.io/ * License: MIT License @@ -235,3 +235,150 @@ function __rebuild_url_from_parts( array $parts ) : string { ( isset( $parts['fragment'] ) ? str_replace( '/', '', "#{$parts['fragment']}" ) : '' ) ); } + +/** + * REST API Plain Permalink Fix + * + * Extracts the REST API endpoint from a potentially malformed path. + * Handles cases like /wp-json/v2/posts or /wp-json/wp/v2/posts. + * + * @since 1.2.3 + * @param string $path The URL path component. + * @return string The extracted endpoint (e.g., /v2/posts) or '/'. + */ +function __extract_rest_endpoint( string $path ) : string { + $rest_route = '/'; // Default to base route + $wp_json_pos = strpos( $path, '/wp-json/' ); + + if ( $wp_json_pos === false ) { + return $rest_route; // Return base if /wp-json/ not found + } + + $extracted_route = substr( $path, $wp_json_pos + strlen( '/wp-json' ) ); // Get everything after /wp-json + // Special case: Handle the originally reported '/wp-json/wp/' malformation + if ( strpos( $extracted_route, 'wp/' ) === 0 ) { + $extracted_route = substr( $extracted_route, strlen( 'wp' ) ); // Remove the extra 'wp' + } + // Ensure the extracted route starts with a slash + if ( ! $extracted_route && $extracted_route[0] !== '/' ) { + $extracted_route = '/' . $extracted_route; + } + $rest_route = $extracted_route ?: '/'; // Use extracted route or default to base + + return $rest_route; +} + +/** + * Builds the correct plain permalink REST URL. + * + * @since 1.2.3 + * @param string $endpoint The REST endpoint (e.g., /v2/posts). + * @param string|null $query_str The original query string (or null). + * @param string|null $fragment The original fragment (or null). + * @return string The fully constructed plain permalink REST URL. + */ +function __build_plain_rest_url( string $endpoint, ?string $query_str, ?string $fragment ) : string { + $home_url = home_url(); // Should be https://.../wp + // Ensure endpoint starts with / + $endpoint = '/' . ltrim( $endpoint, '/' ); + // Construct the base plain permalink URL + $correct_url = rtrim( $home_url, '/' ) . '/?rest_route=' . $endpoint; + + // Append original query parameters (if any, besides rest_route) + if ( ! empty( $query_str ) ) { + parse_str( $query_str, $query_params ); + unset( $query_params['rest_route'] ); // Ensure no leftover rest_route + if ( ! empty( $query_params ) ) { + // Check if $correct_url already has '?' (it should) + $correct_url .= '&' . http_build_query( $query_params ); + } + } + // Append fragment if present + if ( ! empty( $fragment ) ) { + $correct_url .= '#' . $fragment; + } + + // Use normalization helper if available + if ( function_exists( __NAMESPACE__ . '\\__normalize_wp_url' ) ) { + return __normalize_wp_url( $correct_url ); + } + + return $correct_url; // Return without full normalization as fallback +} + +/** + * Corrects generated REST API URL when plain permalinks are active but WordPress + * incorrectly generates a pretty-permalink-style path. Forces the URL + * back to the expected ?rest_route= format using helpers. + * + * @since 1.2.3 + * @param string $url The potentially incorrect REST URL generated by WP. + * @return string The corrected REST URL in plain permalink format. + */ +function filter_force_plain_rest_url_format( string $url ) : string { + $parsed_url = parse_url($url); + + // Check if it looks like a pretty permalink URL (has /wp-json/ in path) + // AND lacks the ?rest_route= query parameter. + $has_wp_json_path = isset( $parsed_url['path'] ) && strpos( $parsed_url['path'], '/wp-json/' ) !== false; + $has_rest_route_query = isset( $parsed_url['query'] ) && strpos( $parsed_url['query'], 'rest_route=' ) !== false; + + if ( $has_wp_json_path && ! $has_rest_route_query ) { + // It's using a pretty path format when it shouldn't be. + $endpoint = __extract_rest_endpoint( $parsed_url['path'] ); + return __build_plain_rest_url( $endpoint, $parsed_url['query'] ?? null, $parsed_url['fragment'] ?? null ); + } + + // If the URL didn't match the problematic pattern, return it normalized. + return __normalize_wp_url($url); +} + +/** + * Handles incoming requests using a pretty REST API path format when plain + * permalinks are active. It sets the correct 'rest_route' query variable + * internally instead of performing an external redirect. + * + * @since 1.2.3 + * @param \WP $wp The WP object, passed by reference. + */ +function handle_pretty_rest_request_on_plain_permalinks( \WP &$wp ) { + // Only run if it's not an admin request. Permalink structure checked by the hook caller. + if ( is_admin() ) { + return; + } + + // Use REQUEST_URI as it's more reliable for the raw request path before WP parsing. + $request_uri = $_SERVER['REQUEST_URI'] ?? ''; + // Get the path part before any query string. + $request_path = strtok($request_uri, '?'); + + // Define the pretty permalink base path we expect if pretty permalinks *were* active. + $home_url_path = rtrim( parse_url( home_url(), PHP_URL_PATH ) ?: '', '/' ); // e.g., /wp + $pretty_rest_path_base = $home_url_path . '/wp-json/'; // e.g., /wp/wp-json/ + + // Check if the actual request path starts with this pretty base. + if ( strpos( $request_path, $pretty_rest_path_base ) === 0 ) { + // Extract the endpoint part *after* the base. + $endpoint = substr( $request_path, strlen( $pretty_rest_path_base ) ); + // Ensure endpoint starts with a slash, default to base if empty. + $endpoint = '/' . ltrim($endpoint, '/'); + // If the result is just '/', set it back to empty string for root endpoint ?rest_route=/ + $endpoint = ($endpoint === '/') ? '' : $endpoint; + + // Check if rest_route is already set (e.g., from query string), if so, don't overwrite. + // This prevents conflicts if someone manually crafts a URL like /wp/wp-json/posts?rest_route=/users + if ( ! isset( $wp->query_vars['rest_route'] ) ) { + // Directly set the query variable for the REST API. + $wp->query_vars['rest_route'] = $endpoint; + + } + // No redirect, no exit. Let WP continue processing with the modified query vars. + } +} + +// Only add the REST URL *generation* fix and the request handler if plain permalinks are enabled. +if ( ! get_option('permalink_structure') ) { + add_filter('rest_url', __NAMESPACE__ . '\\filter_force_plain_rest_url_format', 10, 1); + // Hook the request handling logic to parse_request. Pass the $wp object by reference. + add_action('parse_request', __NAMESPACE__ . '\\handle_pretty_rest_request_on_plain_permalinks', 1, 1); +}