From e72aea5effa3959b9b5312c5be7ddaef149fcfa4 Mon Sep 17 00:00:00 2001 From: Chris Reynolds Date: Wed, 10 Jul 2024 09:36:50 -0600 Subject: [PATCH] [CMSP-1459] Fixes URL normalization process (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add new helper function that parses and normalizes a url from parts * Update URL normalization function to refactor using __rebuild_url_from_parts * check if endpoint is defined already * dont' use anonymous function * add host if exists * replace graphql endpoint filter with an action hook to prepopulate this is a setting that can be changed, so this way we're setting it to what we think it should be without forcing users to use our path * move the prepopulate action and make it more robust use dynamic site_url and make the setting conditional based on whether there's already a /wp/ i the siteurl * add exclusions to site_url filter for graphql * add playwright tests * change ci to install * don't create the site if it already exists * add git config for the user * wait for the last action to finish before switching to sftp mode * set pretty permalinks for playwright test site * switch the existing site to git mode * force yes * check the h2 instead of the site title that's a theme thing. the canary sites are running a different theme * test that the resource urls have /wp/ in the path * missing " * expect the resource url to be truthy on a request * append the commit message from the PR to the commit to the test site * use github token so we can use gh * add package.json to export-ignore * move the commit msg var up so we haven't cd'd into the local copy when we're looking for the commit message * we don't need `page` at all * this doesn't change anything other than seeing if we're getting the PR number * fix the commit message * Include the git commit message body so it's not just truncated if it's long * don't fail if there's nothing to commit * add caching for dependencies * fix hello world test * emdash problems * fix the locator on the welcome message test * generate lock files so we can use the cache * pass default values so the substr check isn't null * change test to toContainText * only generate the npm lock use the composer.json instead of composer.lock use the conditional to check for cache * party pooper 💩 Co-authored-by: Phil Tyler * bail early --------- Co-authored-by: Phil Tyler --- .gitattributes | 1 + .github/tests/wpcm.spec.ts | 65 ++++++++++ .github/workflows/playwright.yml | 113 ++++++++++++++++++ package.json | 12 ++ .../scripts/ComposerScripts.php | 6 +- web/app/mu-plugins/filters.php | 67 +++++++++-- 6 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 .github/tests/wpcm.spec.ts create mode 100644 .github/workflows/playwright.yml create mode 100644 package.json diff --git a/.gitattributes b/.gitattributes index db8a36b2..4560403d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,4 @@ /.editorconfig export-ignore /.gitattributes export-ignore /.github export-ignore +/package.json export-ignore diff --git a/.github/tests/wpcm.spec.ts b/.github/tests/wpcm.spec.ts new file mode 100644 index 00000000..de8861e3 --- /dev/null +++ b/.github/tests/wpcm.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "@playwright/test"; + +const exampleArticle = "Hello world!"; +const siteTitle = "WPCM Playwright Tests"; +const siteUrl = process.env.SITE_URL || "https://dev-wpcm-playwright-tests.pantheonsite.io"; + +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); +}); + +test("WP REST API is accessible", async ({ request }) => { + const apiRoot = await request.get(`${siteUrl}/wp-json`); + expect(apiRoot.ok()).toBeTruthy(); +}); + +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'); + await expect(welcomeText).toContainText('Welcome to WordPress'); +}); + +test("validate core resource URLs", async ({ request }) => { + const coreResources = [ + 'wp-includes/js/dist/interactivity.min.js', + 'wp-includes/css/dist/editor.min.css', + ]; + + for ( const resource of coreResources ) { + const resourceUrl = `${siteUrl}/wp/${resource}`; + const response = await request.get(resourceUrl); + await expect(response).toBeTruthy(); + } +}); + +test("graphql is able to access hello world post", async ({ request }) => { + const query = ` + query { + posts(where: { search: "${exampleArticle}" }) { + edges { + node { + title + } + } + } + } + `; + + const response = await request.post(`${siteUrl}/wp/graphql`, { + data: { + query: query + }, + headers: { + 'Content-Type': 'application/json' + } + }); + + const responseBody = await response.json(); + + expect(responseBody.data.posts.edges.length).toBeGreaterThan(0); + expect(responseBody.data.posts.edges[0].node.title).toBe(exampleArticle); +}); diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..f3d7442c --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,113 @@ +name: WordPress (Composer Managed) Playwright Tests +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +permissions: + contents: write + +jobs: + playwright: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Generate lock files + run: | + npm install --package-lock-only + + - name: Set up cache for dependencies + uses: actions/cache@v4 + id: cache + with: + path: | + ~/.composer/cache + ./vendor + ~/.npm + ./node_modules + key: ${{ runner.os }}-deps-${{ hashFiles( '**/composer.json', '**/package-lock.json' ) }} + restore-keys: ${{ runner.os }}-deps- + + - name: Install Composer dependencies + if: steps.cache.outputs.cache-hit != true + run: composer update --no-progress --prefer-dist --optimize-autoloader + + - name: Install NPM dependencies + if: steps.cache.outputs.cache-hit != true + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Install SSH keys + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Get latest Terminus release + uses: pantheon-systems/terminus-github-actions@v1 + with: + pantheon-machine-token: ${{ secrets.TERMINUS_TOKEN }} + - name: Validate Pantheon Host Key + 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: Log into Terminus + run: | + terminus auth:login --machine-token=${{ secrets.TERMINUS_TOKEN }} + + - name: Create Site + run: | + if terminus site:info wpcm-playwright-tests; then + echo "Test site already exists, skipping site creation." + # If the site exists already, we should switch it to git mode. + terminus connection:set wpcm-playwright-tests.dev git -y + else + terminus site:create wpcm-playwright-tests 'WordPress (Composer Managed) Playwright Test Site' 'WordPress (Composer Managed)' --org=5ae1fa30-8cc4-4894-8ca9-d50628dcba17 + fi + + - name: Clone the site locally and copy PR updates + env: + GH_TOKEN: ${{ github.token }} + run: | + git config --global user.email "cms-platform+sage-testing@pantheon.io" + git config --global user.name "Pantheon WPCM Bot" + PR_NUMBER=$(echo ${{ github.event.pull_request.number }}) + echo "Pull Request Number: ${PR_NUMBER}" + COMMIT_MSG=$(gh pr view ${PR_NUMBER} --json commits --jq '.commits[-1] | "\(.messageHeadline) \(.messageBody)"') + echo "Commit Message: ${COMMIT_MSG}" + terminus local:clone wpcm-playwright-tests + cd ~/pantheon-local-copies/wpcm-playwright-tests + rsync -a --exclude='.git' ${{ github.workspace }}/ . + git add -A + git commit -m "Update to latest commit: ${COMMIT_MSG}" || true + git push origin master || true + + - name: Status Check + run: terminus wp wpcm-playwright-tests.dev -- cli info + + - name: Install WordPress + run: | + terminus wp wpcm-playwright-tests.dev -- core install --title='WPCM Playwright Tests' --admin_user=wpcm --admin_email=test@dev.null + terminus wp wpcm-playwright-tests.dev -- option update permalink_structure '/%postname%/' + terminus wp wpcm-playwright-tests.dev -- rewrite flush + terminus wp wpcm-playwright-tests.dev -- cache flush + + - name: Install WP GraphQL + run: | + terminus workflow:wait wpcm-playwright-tests.dev + terminus connection:set wpcm-playwright-tests.dev sftp + terminus wp wpcm-playwright-tests.dev -- plugin install --activate wp-graphql + + - name: Run Playwright Tests + run: npm run test .github/tests/wpcm.spec.ts + + - name: Delete Site + if: success() + run: terminus site:delete wpcm-playwright-tests -y diff --git a/package.json b/package.json new file mode 100644 index 00000000..3842ab81 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "@pantheon-systems/wordpress-composer-managed", + "version": "1.31.0", + "description": "Automated testing for WordPress (Composer Managed).", + "scripts": { + "test": "playwright test --trace on", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.28.0" + } +} diff --git a/upstream-configuration/scripts/ComposerScripts.php b/upstream-configuration/scripts/ComposerScripts.php index 07cb923e..7d6828de 100644 --- a/upstream-configuration/scripts/ComposerScripts.php +++ b/upstream-configuration/scripts/ComposerScripts.php @@ -55,9 +55,9 @@ public static function applyComposerJsonUpdates(Event $event) // is the same as the Pantheon PHP version (which is only major.minor). // If they do not match, force an update to the platform PHP version. If they // have the same major.minor version, then - $platformPhpVersion = static::getCurrentPlatformPhp($event); - $pantheonPhpVersion = static::getPantheonPhpVersion($event); - $updatedPlatformPhpVersion = static::bestPhpPatchVersion($pantheonPhpVersion); + $platformPhpVersion = static::getCurrentPlatformPhp($event) ?? ''; + $pantheonPhpVersion = static::getPantheonPhpVersion($event) ?? ''; + $updatedPlatformPhpVersion = static::bestPhpPatchVersion($pantheonPhpVersion) ?? ''; if ((substr($platformPhpVersion, 0, strlen($pantheonPhpVersion)) != $pantheonPhpVersion) && !empty($updatedPlatformPhpVersion)) { $io->write("Setting platform.php from '$platformPhpVersion' to '$updatedPlatformPhpVersion' to conform to pantheon php version."); $composerJson['config']['platform']['php'] = $updatedPlatformPhpVersion; diff --git a/web/app/mu-plugins/filters.php b/web/app/mu-plugins/filters.php index bcaedb38..b9b3fa7d 100644 --- a/web/app/mu-plugins/filters.php +++ b/web/app/mu-plugins/filters.php @@ -96,6 +96,28 @@ function fix_core_resource_urls( string $url ) : string { } } +/** + * Prepopulate GraphQL endpoint URL with default value if unset. + * This will ensure that the URL is not changed from /wp/graphql to /graphql by our other filtering unless that's what the user wants. + * + * @since 1.1.0 + */ +function prepopulate_graphql_endpoint_url() { + $options = get_option( 'graphql_general_settings' ); + + // Bail early if options have already been set. + if ( $options ) { + return; + } + + $options = []; + $site_path = site_url(); + $endpoint = ( ! empty( $site_path ) || strpos( $site_path, 'wp' ) !== false ) ? 'graphql' : 'wp/graphql'; + $options['graphql_endpoint'] = $endpoint; + update_option( 'graphql_general_settings', $options ); +} +add_action( 'graphql_init', __NAMESPACE__ . '\\prepopulate_graphql_endpoint_url' ); + /** * Drop the /wp, if it exists, from URLs on the main site (single site or multisite). * @@ -106,6 +128,15 @@ function fix_core_resource_urls( string $url ) : string { * @return string The filtered URL. */ function adjust_main_site_urls( string $url ) : string { + if ( doing_action( 'graphql_init' ) ) { + return $url; + } + + // Explicit handling for /wp/graphql + if ( strpos( $url, '/graphql' ) !== false ) { + return $url; + } + // If this is the main site, drop the /wp. if ( is_main_site() && ! __is_login_url( $url ) ) { $url = str_replace( '/wp/', '/', $url ); @@ -157,11 +188,11 @@ function __is_login_url( string $url ) : bool { } // Check if the URL is a login or admin page - if (strpos($url, 'wp-login') !== false || strpos($url, 'wp-admin') !== false) { + if ( strpos( $url, 'wp-login' ) !== false || strpos($url, 'wp-admin' ) !== false) { return true; } - return false + return false; } /** @@ -172,15 +203,31 @@ function __is_login_url( string $url ) : bool { * @return string The normalized URL. */ function __normalize_wp_url( string $url ): string { - $scheme = parse_url( $url, PHP_URL_SCHEME ); - $scheme_with_separator = $scheme ? $scheme . '://' : ''; + // Parse the URL into components. + $parts = parse_url( $url ); - // Remove the scheme from the URL if it exists. - $remaining_url = $scheme ? substr( $url, strlen($scheme_with_separator ) ) : $url; + // Normalize the URL to remove any double slashes. + if ( isset( $parts['path'] ) ) { + $parts['path'] = preg_replace( '#/+#', '/', $parts['path'] ); + } - // Normalize the remaining URL to remove any double slashes. - $normalized_url = str_replace( '//', '/', $remaining_url ); + // Rebuild and return the full normalized URL. + return __rebuild_url_from_parts( $parts ); +} - // Reconstruct and return the full normalized URL. - return $scheme_with_separator . $normalized_url; +/** + * Rebuild parsed URL from parts. + * + * @since 1.1.0 + * @param array $parts URL parts from parse_url. + * @return string Re-parsed URL. + */ +function __rebuild_url_from_parts( array $parts ) : string { + return trailingslashit( + ( isset( $parts['scheme'] ) ? "{$parts['scheme']}:" : '' ) . + ( isset( $parts['host'] ) ? "{$parts['host']}" : '' ) . + ( isset( $parts['path'] ) ? untrailingslashit( "{$parts['path']}" ) : '' ) . + ( isset( $parts['query'] ) ? str_replace( '/', '', "?{$parts['query']}" ) : '' ) . + ( isset( $parts['fragment'] ) ? str_replace( '/', '', "#{$parts['fragment']}" ) : '' ) + ); }